mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-07-10 21:23:43 +02:00
NeuroSploit v3.2 - Autonomous AI Penetration Testing Platform
116 modules | 100 vuln types | 18 API routes | 18 frontend pages Major features: - VulnEngine: 100 vuln types, 526+ payloads, 12 testers, anti-hallucination prompts - Autonomous Agent: 3-stream auto pentest, multi-session (5 concurrent), pause/resume/stop - CLI Agent: Claude Code / Gemini CLI / Codex CLI inside Kali containers - Validation Pipeline: negative controls, proof of execution, confidence scoring, judge - AI Reasoning: ReACT engine, token budget, endpoint classifier, CVE hunter, deep recon - Multi-Agent: 5 specialists + orchestrator + researcher AI + vuln type agents - RAG System: BM25/TF-IDF/ChromaDB vectorstore, few-shot, reasoning templates - Smart Router: 20 providers (8 CLI OAuth + 12 API), tier failover, token refresh - Kali Sandbox: container-per-scan, 56 tools, VPN support, on-demand install - Full IA Testing: methodology-driven comprehensive pentest sessions - Notifications: Discord, Telegram, WhatsApp/Twilio multi-channel alerts - Frontend: React/TypeScript with 18 pages, real-time WebSocket updates
This commit is contained in:
Executable
+3
@@ -0,0 +1,3 @@
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
__all__ = ["BaseTester"]
|
||||
+532
@@ -0,0 +1,532 @@
|
||||
"""
|
||||
NeuroSploit v3 - Advanced Injection Vulnerability Testers
|
||||
|
||||
Testers for LDAP, XPath, GraphQL, CRLF, Header, Email, EL, Log, HTML, CSV, and ORM injection.
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class LdapInjectionTester(BaseTester):
|
||||
"""Tester for LDAP Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "ldap_injection"
|
||||
self.error_patterns = [
|
||||
r"javax\.naming\.NamingException",
|
||||
r"LDAPException",
|
||||
r"ldap_search\(\)",
|
||||
r"ldap_bind\(\)",
|
||||
r"Invalid DN syntax",
|
||||
r"Bad search filter",
|
||||
r"DSA is unavailable",
|
||||
r"LDAP error code \d+",
|
||||
r"cn=.*,\s*ou=.*,\s*dc=",
|
||||
r"objectClass=",
|
||||
r"No such object",
|
||||
r"invalid attribute description",
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for LDAP injection indicators"""
|
||||
# Check for LDAP error messages
|
||||
for pattern in self.error_patterns:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
return True, 0.8, f"LDAP error detected: {match.group(0)[:100]}"
|
||||
|
||||
# Wildcard injection - check if directory listing returned
|
||||
if "*" in payload:
|
||||
# Multiple DN entries suggest directory enumeration
|
||||
dn_count = len(re.findall(r"dn:\s+\S+", response_body, re.IGNORECASE))
|
||||
if dn_count > 1:
|
||||
return True, 0.85, f"LDAP wildcard returned {dn_count} directory entries"
|
||||
|
||||
# Filter manipulation - check for unexpected data volume
|
||||
if ")(|" in payload or "*)(objectClass" in payload:
|
||||
if response_status == 200 and len(response_body) > 5000:
|
||||
return True, 0.6, "LDAP filter manipulation may have returned extra data"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class XpathInjectionTester(BaseTester):
|
||||
"""Tester for XPath Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "xpath_injection"
|
||||
self.error_patterns = [
|
||||
r"XPathException",
|
||||
r"Invalid XPath",
|
||||
r"xpath syntax error",
|
||||
r"javax\.xml\.xpath",
|
||||
r"XPathEvalError",
|
||||
r"xmlXPathEval:",
|
||||
r"XPATH syntax error",
|
||||
r"DOMXPath",
|
||||
r"SimpleXMLElement::xpath\(\)",
|
||||
r"lxml\.etree\.XPathEvalError",
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for XPath injection indicators"""
|
||||
# XPath error messages
|
||||
for pattern in self.error_patterns:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
return True, 0.85, f"XPath error detected: {match.group(0)[:100]}"
|
||||
|
||||
# Boolean-based XPath injection - true condition returning data
|
||||
if ("' or '1'='1" in payload or "or 1=1" in payload):
|
||||
if response_status == 200:
|
||||
# Check for XML-like data in response
|
||||
xml_tags = re.findall(r"<[a-zA-Z][^>]*>", response_body)
|
||||
if len(xml_tags) > 5:
|
||||
return True, 0.65, "XPath boolean injection may have returned XML data"
|
||||
|
||||
# Check for exposed XML node data
|
||||
if "' | //" in payload or "extractvalue(" in payload.lower():
|
||||
if re.search(r"<\?xml\s+version=", response_body):
|
||||
return True, 0.7, "XML document exposed via XPath injection"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class GraphqlInjectionTester(BaseTester):
|
||||
"""Tester for GraphQL Injection / Introspection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "graphql_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for GraphQL introspection and injection indicators"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Introspection query response - schema exposure
|
||||
if "__schema" in payload or "__type" in payload or "introspection" in payload.lower():
|
||||
if '"__schema"' in response_body or '"__type"' in response_body:
|
||||
return True, 0.9, "GraphQL introspection enabled - schema exposed"
|
||||
if '"types"' in response_body and '"queryType"' in response_body:
|
||||
return True, 0.9, "GraphQL schema types exposed via introspection"
|
||||
|
||||
# GraphQL error messages revealing structure
|
||||
graphql_errors = [
|
||||
r'"errors"\s*:\s*\[',
|
||||
r"Cannot query field",
|
||||
r"Unknown argument",
|
||||
r"Field .* not found in type",
|
||||
r"Syntax Error.*GraphQL",
|
||||
r"GraphQL error",
|
||||
]
|
||||
for pattern in graphql_errors:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
# Error messages can reveal field/type names
|
||||
if re.search(r'"message"\s*:\s*".*(?:field|type|argument)', response_body, re.IGNORECASE):
|
||||
return True, 0.7, "GraphQL error reveals schema information"
|
||||
|
||||
# Mutation that returned success unexpectedly
|
||||
if "mutation" in payload.lower() and response_status == 200:
|
||||
if '"data"' in response_body and '"errors"' not in response_body:
|
||||
return True, 0.5, "GraphQL mutation succeeded - verify authorization"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CrlfInjectionTester(BaseTester):
|
||||
"""Tester for CRLF Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "crlf_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for CRLF injection - injected headers appearing in response"""
|
||||
# Check if our injected header appeared in response headers
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
|
||||
if "x-test" in headers_lower:
|
||||
return True, 0.95, f"CRLF injection confirmed: X-Test header injected with value '{headers_lower['x-test']}'"
|
||||
|
||||
if "x-injected" in headers_lower:
|
||||
return True, 0.95, f"CRLF injection confirmed: X-Injected header present"
|
||||
|
||||
# Check for Set-Cookie injection
|
||||
if "set-cookie" in headers_lower and "neurosploit" in str(headers_lower.get("set-cookie", "")).lower():
|
||||
return True, 0.9, "CRLF injection: injected Set-Cookie header detected"
|
||||
|
||||
# Check if payload characters are reflected unencoded in Location header
|
||||
if "location" in headers_lower:
|
||||
location = headers_lower["location"]
|
||||
if "\r\n" in location or "%0d%0a" in location.lower():
|
||||
return True, 0.8, "CRLF characters in Location header"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HeaderInjectionTester(BaseTester):
|
||||
"""Tester for Host Header Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "header_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for Host header reflected in response URLs"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check if injected host value appears in response links
|
||||
evil_markers = ["evil.com", "attacker.com", "neurosploit.test"]
|
||||
for marker in evil_markers:
|
||||
if marker in payload.lower():
|
||||
if marker in body_lower:
|
||||
# Check if it appears in URLs, links, or redirects
|
||||
url_pattern = rf'(?:href|src|action|url|link|redirect)\s*[=:]\s*["\']?[^"\']*{re.escape(marker)}'
|
||||
if re.search(url_pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Host header injected into response URL: {marker}"
|
||||
return True, 0.7, f"Injected host value '{marker}' reflected in response"
|
||||
|
||||
# Password reset poisoning check
|
||||
if "password" in body_lower and "reset" in body_lower:
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
if response_status in [200, 302]:
|
||||
return True, 0.5, "Password reset response may use Host header for link generation"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class EmailInjectionTester(BaseTester):
|
||||
"""Tester for Email Header Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "email_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for email header injection success indicators"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check for CC/BCC injection indicators
|
||||
if any(h in payload.lower() for h in ["cc:", "bcc:", "\r\nto:", "%0acc:", "%0abcc:"]):
|
||||
# Successful email send with injected headers
|
||||
if response_status == 200:
|
||||
success_indicators = [
|
||||
"email sent", "message sent", "mail sent",
|
||||
"successfully sent", "email delivered", "sent successfully",
|
||||
]
|
||||
for indicator in success_indicators:
|
||||
if indicator in body_lower:
|
||||
return True, 0.75, f"Email injection: '{indicator}' after CC/BCC injection attempt"
|
||||
|
||||
# Check for multiple recipient confirmation
|
||||
if re.search(r"(?:sent to|delivered to|recipients?)\s*:?\s*\d+", response_body, re.IGNORECASE):
|
||||
return True, 0.7, "Email sent to multiple recipients after injection"
|
||||
|
||||
# SMTP error leak
|
||||
smtp_errors = [r"SMTP error", r"550 \d+", r"relay access denied", r"mail\(\).*failed"]
|
||||
for pattern in smtp_errors:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.6, "SMTP error revealed - email injection attempted"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ELInjectionTester(BaseTester):
|
||||
"""Tester for Expression Language (EL) Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "el_injection"
|
||||
self.math_results = {
|
||||
"${7*7}": "49",
|
||||
"#{7*7}": "49",
|
||||
"${3*11}": "33",
|
||||
"#{3*11}": "33",
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for EL injection - expression evaluation in response"""
|
||||
# Check mathematical expression results
|
||||
for expr, result in self.math_results.items():
|
||||
if expr in payload and result in response_body:
|
||||
# Make sure the result isn't just the expression echoed
|
||||
if expr not in response_body:
|
||||
return True, 0.95, f"EL injection confirmed: {expr} evaluated to {result}"
|
||||
|
||||
# Java class names exposed via EL
|
||||
java_indicators = [
|
||||
r"java\.lang\.\w+",
|
||||
r"java\.io\.File",
|
||||
r"Runtime\.getRuntime",
|
||||
r"ProcessBuilder",
|
||||
r"javax\.\w+\.\w+",
|
||||
r"org\.apache\.\w+",
|
||||
r"getClass\(\)\.forName",
|
||||
]
|
||||
for pattern in java_indicators:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.8, f"Java class exposure via EL injection: {pattern}"
|
||||
|
||||
# Spring EL specific
|
||||
if "T(java.lang" in payload:
|
||||
if re.search(r"class\s+\w+|java\.\w+", response_body):
|
||||
return True, 0.7, "Spring EL injection indicator - Java class reference in response"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class LogInjectionTester(BaseTester):
|
||||
"""Tester for Log Injection / Log4Shell / JNDI Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "log_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for log injection and JNDI callback indicators"""
|
||||
# JNDI/Log4Shell detection via context callback
|
||||
if "${jndi:" in payload.lower():
|
||||
# Check context for callback confirmation
|
||||
if context.get("callback_received"):
|
||||
return True, 0.95, "JNDI injection confirmed via callback"
|
||||
# Check for Log4j error in response
|
||||
log4j_indicators = [
|
||||
r"log4j", r"Log4jException", r"JNDI lookup",
|
||||
r"javax\.naming", r"InitialContext",
|
||||
]
|
||||
for pattern in log4j_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.7, f"Log4j/JNDI indicator in response: {pattern}"
|
||||
|
||||
# Newline injection in log-like responses
|
||||
if "\n" in payload or "%0a" in payload.lower() or "\\n" in payload:
|
||||
# Check if response includes log-format lines with our injected content
|
||||
log_patterns = [
|
||||
r"\[\d{4}-\d{2}-\d{2}.*\].*neurosploit",
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}.*neurosploit",
|
||||
r"(?:INFO|WARN|ERROR|DEBUG)\s+.*neurosploit",
|
||||
]
|
||||
for pattern in log_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.7, "Log injection: injected content appears in log-format output"
|
||||
|
||||
# Generic log forging check
|
||||
if "neurosploit" in payload and response_status == 200:
|
||||
if re.search(r"(?:log|audit|event).*neurosploit", response_body, re.IGNORECASE):
|
||||
return True, 0.5, "Injected marker appears in log/audit output"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HtmlInjectionTester(BaseTester):
|
||||
"""Tester for HTML Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "html_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for rendered HTML tags in response"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check for injected HTML tags rendered in response
|
||||
html_tests = [
|
||||
(r"<b>neurosploit</b>", "Bold tag rendered"),
|
||||
(r"<i>neurosploit</i>", "Italic tag rendered"),
|
||||
(r"<u>neurosploit</u>", "Underline tag rendered"),
|
||||
(r'<a\s+href=["\']?[^"\']*["\']?>neurosploit</a>', "Anchor tag rendered"),
|
||||
(r'<img\s+src=["\']?[^"\']*["\']?', "Image tag rendered"),
|
||||
(r'<form\s+[^>]*action=', "Form tag rendered"),
|
||||
(r'<iframe\s+', "IFrame tag rendered"),
|
||||
(r'<marquee>', "Marquee tag rendered"),
|
||||
(r'<h1>neurosploit</h1>', "H1 tag rendered"),
|
||||
(r'<div\s+style=', "Styled div rendered"),
|
||||
]
|
||||
|
||||
for pattern, description in html_tests:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
# Verify it wasn't already there (check if payload was actually injected)
|
||||
if any(tag in payload.lower() for tag in ["<b>", "<i>", "<u>", "<a ", "<img", "<form", "<iframe", "<marquee", "<h1>", "<div"]):
|
||||
return True, 0.8, f"HTML injection: {description}"
|
||||
|
||||
# Check for payload reflection without encoding
|
||||
if "<" in payload and ">" in payload:
|
||||
# Find the injected tag in response
|
||||
tag_match = re.search(r"<(\w+)[^>]*>", payload)
|
||||
if tag_match:
|
||||
tag_name = tag_match.group(1)
|
||||
if f"<{tag_name}" in response_body and f"<{tag_name}" not in response_body:
|
||||
return True, 0.75, f"HTML tag <{tag_name}> reflected without encoding"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CsvInjectionTester(BaseTester):
|
||||
"""Tester for CSV Injection (Formula Injection) vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "csv_injection"
|
||||
self.formula_chars = ["=", "+", "-", "@", "\t", "\r"]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for formula characters preserved in CSV export responses"""
|
||||
headers_lower = {k.lower(): v.lower() for k, v in response_headers.items()}
|
||||
content_type = headers_lower.get("content-type", "")
|
||||
|
||||
# Check if response is CSV or spreadsheet
|
||||
is_csv = "text/csv" in content_type or "spreadsheet" in content_type
|
||||
is_export = "content-disposition" in headers_lower and any(
|
||||
ext in headers_lower.get("content-disposition", "")
|
||||
for ext in [".csv", ".xls", ".xlsx"]
|
||||
)
|
||||
|
||||
if is_csv or is_export:
|
||||
# Check if formula characters are preserved without escaping
|
||||
for char in self.formula_chars:
|
||||
if char in payload and payload in response_body:
|
||||
return True, 0.8, f"CSV injection: formula character '{char}' preserved in export"
|
||||
|
||||
# Check for specific formula patterns
|
||||
formula_patterns = [
|
||||
r'[=+\-@].*(?:HYPERLINK|IMPORTXML|IMPORTDATA|cmd|powershell)',
|
||||
r'=\w+\(.*\)',
|
||||
]
|
||||
for pattern in formula_patterns:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.7, "CSV injection: formula pattern found in export data"
|
||||
|
||||
# Non-CSV response but payload was stored
|
||||
if response_status in [200, 201] and any(c in payload for c in self.formula_chars[:4]):
|
||||
if payload in response_body:
|
||||
return True, 0.4, "Formula characters accepted and stored - verify CSV export"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class OrmInjectionTester(BaseTester):
|
||||
"""Tester for ORM Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "orm_injection"
|
||||
self.error_patterns = [
|
||||
r"Hibernate.*Exception",
|
||||
r"javax\.persistence",
|
||||
r"org\.hibernate",
|
||||
r"ActiveRecord::.*Error",
|
||||
r"Sequelize.*Error",
|
||||
r"SQLAlchemy.*Error",
|
||||
r"Doctrine.*Exception",
|
||||
r"TypeORM.*Error",
|
||||
r"Prisma.*Error",
|
||||
r"EntityFramework.*Exception",
|
||||
r"LINQ.*Exception",
|
||||
r"django\.db.*Error",
|
||||
r"peewee\.\w+Error",
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for ORM injection indicators"""
|
||||
# ORM error messages
|
||||
for pattern in self.error_patterns:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
return True, 0.8, f"ORM error detected: {match.group(0)[:100]}"
|
||||
|
||||
# Filter manipulation - unexpected data returned
|
||||
if any(op in payload for op in ["__gt", "__lt", "__ne", "$ne", "$gt", ">=", "!="]):
|
||||
if response_status == 200:
|
||||
# Check context for baseline comparison
|
||||
if "baseline_length" in context:
|
||||
diff = abs(len(response_body) - context["baseline_length"])
|
||||
if diff > 500:
|
||||
return True, 0.6, f"ORM filter manipulation: response size differs by {diff} bytes"
|
||||
|
||||
# Check for data volume suggesting bypassed filters
|
||||
if "__all" in payload or "objects.all" in payload:
|
||||
if response_status == 200 and len(response_body) > 10000:
|
||||
return True, 0.5, "ORM injection may have bypassed query filters - large data returned"
|
||||
|
||||
return False, 0.0, None
|
||||
Executable
+347
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
NeuroSploit v3 - Authentication Vulnerability Testers
|
||||
|
||||
Testers for Auth Bypass, JWT, Session Fixation
|
||||
"""
|
||||
import re
|
||||
import base64
|
||||
import json
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class AuthBypassTester(BaseTester):
|
||||
"""Tester for Authentication Bypass"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "auth_bypass"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for authentication bypass"""
|
||||
# Check for successful auth indicators after bypass payload
|
||||
auth_success = [
|
||||
"welcome", "dashboard", "logged in", "authenticated",
|
||||
"success", "admin", "profile"
|
||||
]
|
||||
|
||||
if response_status == 200:
|
||||
body_lower = response_body.lower()
|
||||
for indicator in auth_success:
|
||||
if indicator in body_lower:
|
||||
# Check if this was with a bypass payload
|
||||
bypass_indicators = ["' or '1'='1", "admin'--", "' or 1=1"]
|
||||
if any(bp in payload.lower() for bp in bypass_indicators):
|
||||
return True, 0.8, f"Auth bypass possible: '{indicator}' found after injection"
|
||||
|
||||
# Check for redirect to authenticated area
|
||||
location = response_headers.get("Location", "")
|
||||
if response_status in [301, 302]:
|
||||
if "dashboard" in location or "admin" in location or "home" in location:
|
||||
return True, 0.7, f"Auth bypass: Redirect to {location}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class JWTManipulationTester(BaseTester):
|
||||
"""Tester for JWT Token Manipulation"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "jwt_manipulation"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for JWT manipulation vulnerabilities"""
|
||||
# Check if manipulated JWT was accepted
|
||||
if response_status == 200:
|
||||
# Algorithm none attack
|
||||
if '"alg":"none"' in payload or '"alg": "none"' in payload:
|
||||
return True, 0.9, "JWT 'none' algorithm accepted"
|
||||
|
||||
# Check for elevated privileges response
|
||||
elevated_indicators = ["admin", "administrator", "role.*admin"]
|
||||
for pattern in elevated_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, "JWT manipulation: Elevated privileges detected"
|
||||
|
||||
# Check for JWT-specific errors
|
||||
jwt_errors = [
|
||||
r"invalid.*token", r"jwt.*expired", r"signature.*invalid",
|
||||
r"token.*malformed", r"unauthorized"
|
||||
]
|
||||
for pattern in jwt_errors:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
# Error means it's checking - note for further testing
|
||||
return False, 0.0, None
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SessionFixationTester(BaseTester):
|
||||
"""Tester for Session Fixation"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "session_fixation"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for session fixation vulnerability"""
|
||||
# Check Set-Cookie header
|
||||
set_cookie = response_headers.get("Set-Cookie", "")
|
||||
|
||||
# If session ID in URL was accepted
|
||||
if "JSESSIONID=" in payload or "PHPSESSID=" in payload:
|
||||
if response_status == 200:
|
||||
# Check if session was NOT regenerated
|
||||
if not set_cookie or "JSESSIONID" not in set_cookie:
|
||||
return True, 0.7, "Session ID from URL accepted without regeneration"
|
||||
|
||||
# Check for session in URL
|
||||
if re.search(r'[?&](?:session|sid|PHPSESSID|JSESSIONID)=', response_body):
|
||||
return True, 0.6, "Session ID exposed in URL"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class WeakPasswordTester(BaseTester):
|
||||
"""Tester for Weak Password acceptance"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "weak_password"
|
||||
self.weak_passwords = [
|
||||
"123456", "password", "12345678", "qwerty", "abc123",
|
||||
"111111", "123123", "admin", "letmein", "welcome",
|
||||
"1234", "1", "a"
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for successful login/registration with weak passwords"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check if payload contains a weak password
|
||||
payload_has_weak = any(wp in payload for wp in self.weak_passwords)
|
||||
if not payload_has_weak:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check for successful auth with weak password
|
||||
if response_status in [200, 201, 302]:
|
||||
success_indicators = [
|
||||
r'"(?:access_)?token"\s*:', r'"session"\s*:',
|
||||
r"(?:login|registration|signup)\s+successful",
|
||||
r'"authenticated"\s*:\s*true', r'"success"\s*:\s*true',
|
||||
r"welcome", r"dashboard", r"logged\s*in",
|
||||
]
|
||||
for pattern in success_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
matched_pw = next((wp for wp in self.weak_passwords if wp in payload), "unknown")
|
||||
return True, 0.85, f"Weak password accepted: '{matched_pw}' allowed for authentication"
|
||||
|
||||
# Redirect to authenticated area
|
||||
location = response_headers.get("Location", "")
|
||||
if response_status == 302 and any(x in location.lower() for x in ["dashboard", "home", "profile", "account"]):
|
||||
matched_pw = next((wp for wp in self.weak_passwords if wp in payload), "unknown")
|
||||
return True, 0.8, f"Weak password accepted: Redirect to authenticated area with '{matched_pw}'"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class DefaultCredentialsTester(BaseTester):
|
||||
"""Tester for Default Credentials acceptance"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "default_credentials"
|
||||
self.default_creds = [
|
||||
("admin", "admin"), ("admin", "password"), ("admin", "admin123"),
|
||||
("root", "root"), ("root", "toor"), ("root", "password"),
|
||||
("administrator", "administrator"), ("admin", "1234"),
|
||||
("test", "test"), ("guest", "guest"), ("user", "user"),
|
||||
("admin", "changeme"), ("admin", "default"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for successful login with default credentials"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check if payload matches default creds
|
||||
payload_lower = payload.lower()
|
||||
matched_cred = None
|
||||
for username, password in self.default_creds:
|
||||
if username in payload_lower and password in payload_lower:
|
||||
matched_cred = f"{username}/{password}"
|
||||
break
|
||||
|
||||
if not matched_cred:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check for successful login
|
||||
if response_status in [200, 201, 302]:
|
||||
auth_success = [
|
||||
r'"(?:access_)?token"\s*:', r'"session"\s*:',
|
||||
r"(?:login|auth)\s+successful", r'"success"\s*:\s*true',
|
||||
r'"authenticated"\s*:\s*true', r"welcome",
|
||||
r"dashboard", r"admin\s*panel",
|
||||
]
|
||||
for pattern in auth_success:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Default credentials accepted: {matched_cred}"
|
||||
|
||||
# Redirect to admin/dashboard
|
||||
location = response_headers.get("Location", "")
|
||||
if response_status == 302 and any(x in location.lower() for x in ["dashboard", "admin", "home", "panel"]):
|
||||
return True, 0.85, f"Default credentials accepted: {matched_cred} (redirect to {location})"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class TwoFactorBypassTester(BaseTester):
|
||||
"""Tester for Two-Factor Authentication Bypass"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "two_factor_bypass"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for authenticated access without completing 2FA"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check if we reached authenticated content without 2FA
|
||||
if response_status == 200:
|
||||
# Authenticated area indicators
|
||||
auth_area_patterns = [
|
||||
r"dashboard", r"my\s*account", r"profile",
|
||||
r"settings", r"admin\s*panel", r'"user"\s*:\s*\{',
|
||||
r'"email"\s*:\s*"[^"]+"', r'"role"\s*:',
|
||||
]
|
||||
# 2FA page indicators (we should NOT see these if bypassed)
|
||||
twofa_page_patterns = [
|
||||
r"(?:enter|verify)\s+(?:your\s+)?(?:otp|code|token|2fa)",
|
||||
r"two.?factor", r"verification\s+code",
|
||||
r"authenticator", r"sms\s+code",
|
||||
]
|
||||
|
||||
has_auth_content = any(re.search(p, response_body, re.IGNORECASE) for p in auth_area_patterns)
|
||||
is_twofa_page = any(re.search(p, response_body, re.IGNORECASE) for p in twofa_page_patterns)
|
||||
|
||||
if has_auth_content and not is_twofa_page:
|
||||
# Check if payload suggests 2FA bypass attempt
|
||||
bypass_indicators = [
|
||||
"2fa", "otp", "mfa", "verify", "code",
|
||||
"step2", "second", "challenge",
|
||||
]
|
||||
if any(bi in payload.lower() for bi in bypass_indicators):
|
||||
return True, 0.85, "2FA bypass: Authenticated area accessed without completing 2FA"
|
||||
|
||||
# Direct navigation bypass
|
||||
if context.get("skip_2fa") or context.get("direct_access"):
|
||||
return True, 0.9, "2FA bypass: Direct navigation to authenticated page bypassed 2FA"
|
||||
|
||||
# Redirect skipping 2FA step
|
||||
if response_status in [301, 302]:
|
||||
location = response_headers.get("Location", "").lower()
|
||||
if any(x in location for x in ["dashboard", "home", "account"]):
|
||||
if "verify" not in location and "2fa" not in location and "otp" not in location:
|
||||
return True, 0.7, "2FA bypass: Redirect to authenticated area skipping verification"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class OauthMisconfigTester(BaseTester):
|
||||
"""Tester for OAuth Misconfiguration"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "oauth_misconfig"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for OAuth misconfiguration (open redirect, token leakage)"""
|
||||
# Check for open redirect in OAuth flow
|
||||
if response_status in [301, 302, 303, 307]:
|
||||
location = response_headers.get("Location", "")
|
||||
|
||||
# External redirect in OAuth callback
|
||||
if "redirect_uri" in payload.lower() or "callback" in payload.lower():
|
||||
# Check if redirecting to attacker-controlled domain
|
||||
evil_domains = ["evil.com", "attacker.com", "malicious.com"]
|
||||
if any(domain in location for domain in evil_domains):
|
||||
return True, 0.9, f"OAuth misconfig: Open redirect in OAuth flow to {location}"
|
||||
|
||||
# Check if arbitrary redirect_uri accepted
|
||||
if payload in location:
|
||||
return True, 0.85, "OAuth misconfig: Arbitrary redirect_uri accepted"
|
||||
|
||||
# Check for token in URL parameters (should be in fragment or POST)
|
||||
if response_status in [200, 302]:
|
||||
location = response_headers.get("Location", "")
|
||||
# Token in query string instead of fragment
|
||||
token_in_url = re.search(
|
||||
r'[?&](?:access_token|token|code)=([A-Za-z0-9._-]+)',
|
||||
location
|
||||
)
|
||||
if token_in_url:
|
||||
return True, 0.8, "OAuth misconfig: Token/code exposed in URL query parameters"
|
||||
|
||||
# Token in response body URL
|
||||
token_in_body = re.search(
|
||||
r'(?:redirect|callback|return)["\']?\s*[:=]\s*["\']?https?://[^"\'>\s]*[?&]access_token=',
|
||||
response_body, re.IGNORECASE
|
||||
)
|
||||
if token_in_body:
|
||||
return True, 0.75, "OAuth misconfig: Access token in redirect URL"
|
||||
|
||||
# Check for missing state parameter (CSRF in OAuth)
|
||||
if "state=" not in response_body and "state=" not in response_headers.get("Location", ""):
|
||||
if re.search(r"(?:authorize|oauth|auth)\?", response_body, re.IGNORECASE):
|
||||
return True, 0.6, "OAuth misconfig: Missing state parameter (CSRF risk)"
|
||||
|
||||
return False, 0.0, None
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
NeuroSploit v3 - Authorization Vulnerability Testers
|
||||
|
||||
Testers for IDOR, BOLA, Privilege Escalation
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class IDORTester(BaseTester):
|
||||
"""Tester for Insecure Direct Object Reference"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "idor"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for IDOR vulnerability"""
|
||||
# Check if we got data for a different ID
|
||||
if response_status == 200:
|
||||
# Look for user data indicators
|
||||
user_data_patterns = [
|
||||
r'"user_?id"\s*:\s*\d+',
|
||||
r'"email"\s*:\s*"[^"]+"',
|
||||
r'"name"\s*:\s*"[^"]+"',
|
||||
r'"account"\s*:',
|
||||
r'"profile"\s*:'
|
||||
]
|
||||
|
||||
for pattern in user_data_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
# Check if ID in payload differs from context user
|
||||
if "original_id" in context:
|
||||
if context["original_id"] not in payload:
|
||||
return True, 0.8, f"IDOR: Accessed different user's data"
|
||||
|
||||
# Generic data access check
|
||||
if len(response_body) > 50:
|
||||
return True, 0.6, "IDOR: Response contains data - verify authorization"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class BOLATester(BaseTester):
|
||||
"""Tester for Broken Object Level Authorization"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "bola"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for BOLA in APIs"""
|
||||
# BOLA in REST APIs
|
||||
if response_status == 200:
|
||||
# Check for successful data access
|
||||
data_indicators = [
|
||||
r'"data"\s*:\s*\{',
|
||||
r'"items"\s*:\s*\[',
|
||||
r'"result"\s*:\s*\{',
|
||||
r'"id"\s*:\s*\d+'
|
||||
]
|
||||
|
||||
for pattern in data_indicators:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.7, "BOLA: API returned object data - verify authorization"
|
||||
|
||||
# Check for enumeration possibilities
|
||||
if response_status in [200, 404]:
|
||||
# Different status for valid vs invalid IDs indicates BOLA risk
|
||||
return True, 0.5, "BOLA: Different responses for IDs - enumeration possible"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class PrivilegeEscalationTester(BaseTester):
|
||||
"""Tester for Privilege Escalation"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "privilege_escalation"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for privilege escalation"""
|
||||
if response_status == 200:
|
||||
# Check for admin/elevated access indicators
|
||||
elevated_access = [
|
||||
r'"role"\s*:\s*"admin"',
|
||||
r'"is_?admin"\s*:\s*true',
|
||||
r'"admin"\s*:\s*true',
|
||||
r'"privilege"\s*:\s*"(?:admin|root|superuser)"',
|
||||
r'"permissions"\s*:\s*\[.*"admin".*\]'
|
||||
]
|
||||
|
||||
for pattern in elevated_access:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Privilege escalation: Elevated role in response"
|
||||
|
||||
# Check for admin functionality access
|
||||
admin_functions = [
|
||||
"user management", "delete user", "admin panel",
|
||||
"system settings", "all users", "user list"
|
||||
]
|
||||
body_lower = response_body.lower()
|
||||
for func in admin_functions:
|
||||
if func in body_lower:
|
||||
return True, 0.7, f"Privilege escalation: Admin functionality '{func}' accessible"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class BflaTester(BaseTester):
|
||||
"""Tester for Broken Function Level Authorization (BFLA)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "bfla"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for admin functionality accessible to regular user"""
|
||||
if response_status == 200:
|
||||
# Admin-specific data patterns
|
||||
admin_data_patterns = [
|
||||
r'"users"\s*:\s*\[',
|
||||
r'"all_users"\s*:',
|
||||
r'"admin_settings"\s*:',
|
||||
r'"system_config"\s*:',
|
||||
r'"audit_log"\s*:',
|
||||
r'"role"\s*:\s*"admin"',
|
||||
r'"permissions"\s*:\s*\[',
|
||||
r'"api_keys"\s*:\s*\[',
|
||||
]
|
||||
|
||||
for pattern in admin_data_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
# Check if this was an admin endpoint accessed as regular user
|
||||
admin_url_indicators = [
|
||||
"/admin", "/manage", "/users", "/settings",
|
||||
"/config", "/system", "/audit", "/logs",
|
||||
]
|
||||
if any(ind in payload.lower() for ind in admin_url_indicators):
|
||||
return True, 0.85, f"BFLA: Admin data returned for non-admin request"
|
||||
|
||||
# Admin page content
|
||||
admin_content = [
|
||||
"user management", "system configuration", "admin dashboard",
|
||||
"manage users", "all accounts", "server status",
|
||||
"delete user", "create admin",
|
||||
]
|
||||
body_lower = response_body.lower()
|
||||
for content in admin_content:
|
||||
if content in body_lower:
|
||||
return True, 0.8, f"BFLA: Admin functionality '{content}' accessible to regular user"
|
||||
|
||||
# Admin endpoint should return 403 for non-admin
|
||||
if response_status not in [401, 403] and context.get("is_admin_endpoint"):
|
||||
if response_status == 200:
|
||||
return True, 0.7, "BFLA: Admin endpoint returned 200 instead of 403"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class MassAssignmentTester(BaseTester):
|
||||
"""Tester for Mass Assignment vulnerability"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "mass_assignment"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for extra parameters being accepted and modifying model state"""
|
||||
if response_status in [200, 201]:
|
||||
# Check if privileged fields were accepted
|
||||
privileged_fields = [
|
||||
(r'"(?:is_)?admin"\s*:\s*true', "admin flag"),
|
||||
(r'"role"\s*:\s*"admin"', "admin role"),
|
||||
(r'"role"\s*:\s*"superuser"', "superuser role"),
|
||||
(r'"verified"\s*:\s*true', "verified status"),
|
||||
(r'"is_staff"\s*:\s*true', "staff flag"),
|
||||
(r'"is_superuser"\s*:\s*true', "superuser flag"),
|
||||
(r'"balance"\s*:\s*\d{4,}', "balance modification"),
|
||||
(r'"credits"\s*:\s*\d{3,}', "credits modification"),
|
||||
(r'"discount"\s*:\s*\d+', "discount field"),
|
||||
(r'"price"\s*:\s*0', "price zeroed"),
|
||||
]
|
||||
|
||||
# Check if payload attempted mass assignment
|
||||
mass_assign_indicators = [
|
||||
"admin", "role", "is_staff", "is_superuser",
|
||||
"verified", "balance", "credits", "price", "discount",
|
||||
]
|
||||
payload_has_mass_assign = any(ind in payload.lower() for ind in mass_assign_indicators)
|
||||
|
||||
if payload_has_mass_assign:
|
||||
for pattern, field_name in privileged_fields:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.85, f"Mass assignment: Privileged field '{field_name}' accepted and reflected"
|
||||
|
||||
# Check if response changed compared to baseline
|
||||
if context.get("baseline_body"):
|
||||
baseline = context["baseline_body"]
|
||||
if response_body != baseline and len(response_body) > len(baseline):
|
||||
return True, 0.6, "Mass assignment: Response differs from baseline after extra parameters"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ForcedBrowsingTester(BaseTester):
|
||||
"""Tester for Forced Browsing (direct access to restricted URLs)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "forced_browsing"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for direct access to restricted URLs without proper authorization"""
|
||||
# Should get 401/403/302 for restricted content, not 200
|
||||
if response_status == 200:
|
||||
# Sensitive content indicators
|
||||
sensitive_patterns = [
|
||||
(r"(?:admin|management)\s+(?:panel|dashboard|console)", "admin panel"),
|
||||
(r'"(?:password|secret|api_key|token)"\s*:\s*"[^"]+"', "sensitive data"),
|
||||
(r"(?:backup|dump|export)\s+(?:file|data|database)", "backup files"),
|
||||
(r"phpinfo\(\)", "PHP info page"),
|
||||
(r"(?:configuration|config)\s+(?:file|settings)", "configuration page"),
|
||||
(r"(?:internal|private)\s+(?:api|endpoint|documentation)", "internal docs"),
|
||||
(r"(?:debug|diagnostic)\s+(?:info|page|console)", "debug page"),
|
||||
(r"(?:user|customer)\s+(?:list|database|records)", "user records"),
|
||||
]
|
||||
|
||||
for pattern, desc in sensitive_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, f"Forced browsing: Restricted content accessible - {desc}"
|
||||
|
||||
# Check for restricted URL patterns in payload
|
||||
restricted_paths = [
|
||||
"/admin", "/backup", "/config", "/internal",
|
||||
"/debug", "/private", "/management", "/phpinfo",
|
||||
"/.git", "/.env", "/wp-admin", "/server-status",
|
||||
]
|
||||
if any(path in payload.lower() for path in restricted_paths):
|
||||
if len(response_body) > 200:
|
||||
return True, 0.7, "Forced browsing: Restricted URL returned content (200 OK)"
|
||||
|
||||
# Check that redirect isn't to the same restricted page (false positive)
|
||||
if response_status in [301, 302]:
|
||||
location = response_headers.get("Location", "").lower()
|
||||
if "login" in location or "signin" in location or "auth" in location:
|
||||
return False, 0.0, None # Properly redirecting to login
|
||||
|
||||
return False, 0.0, None
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
NeuroSploit v3 - Base Vulnerability Tester
|
||||
|
||||
Base class for all vulnerability testers.
|
||||
"""
|
||||
from typing import Tuple, Dict, List, Optional, Any
|
||||
from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
|
||||
|
||||
|
||||
class BaseTester:
|
||||
"""Base class for vulnerability testers"""
|
||||
|
||||
def __init__(self):
|
||||
self.name = "base"
|
||||
|
||||
def build_request(
|
||||
self,
|
||||
endpoint,
|
||||
payload: str
|
||||
) -> Tuple[str, Dict, Dict, Optional[str]]:
|
||||
"""
|
||||
Build a test request with the payload.
|
||||
|
||||
Returns:
|
||||
Tuple of (url, params, headers, body)
|
||||
"""
|
||||
url = endpoint.url
|
||||
params = {}
|
||||
headers = {"User-Agent": "NeuroSploit/3.0"}
|
||||
body = None
|
||||
|
||||
# Inject payload into parameters if endpoint has them
|
||||
if endpoint.parameters:
|
||||
for param in endpoint.parameters:
|
||||
param_name = param.get("name", param) if isinstance(param, dict) else param
|
||||
params[param_name] = payload
|
||||
else:
|
||||
# Try to inject into URL query string
|
||||
parsed = urlparse(url)
|
||||
if parsed.query:
|
||||
query_params = parse_qs(parsed.query)
|
||||
for key in query_params:
|
||||
query_params[key] = [payload]
|
||||
new_query = urlencode(query_params, doseq=True)
|
||||
url = urlunparse(parsed._replace(query=new_query))
|
||||
else:
|
||||
# Add as query parameter
|
||||
params["test"] = payload
|
||||
|
||||
return url, params, headers, body
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""
|
||||
Analyze response to determine if vulnerable.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_vulnerable, confidence, evidence)
|
||||
"""
|
||||
return False, 0.0, None
|
||||
|
||||
def check_timeout_vulnerability(self, vuln_type: str) -> bool:
|
||||
"""Check if timeout indicates vulnerability for this type"""
|
||||
return False
|
||||
|
||||
def get_injection_points(self, endpoint) -> List[Dict]:
|
||||
"""Get all injection points for an endpoint"""
|
||||
points = []
|
||||
|
||||
# URL parameters
|
||||
if endpoint.parameters:
|
||||
for param in endpoint.parameters:
|
||||
param_name = param.get("name", param) if isinstance(param, dict) else param
|
||||
points.append({
|
||||
"type": "parameter",
|
||||
"name": param_name,
|
||||
"location": "query"
|
||||
})
|
||||
|
||||
# Parse URL for query params
|
||||
parsed = urlparse(endpoint.url)
|
||||
if parsed.query:
|
||||
query_params = parse_qs(parsed.query)
|
||||
for key in query_params:
|
||||
if not any(p.get("name") == key for p in points):
|
||||
points.append({
|
||||
"type": "parameter",
|
||||
"name": key,
|
||||
"location": "query"
|
||||
})
|
||||
|
||||
# Headers that might be injectable
|
||||
injectable_headers = ["User-Agent", "Referer", "X-Forwarded-For", "Cookie"]
|
||||
for header in injectable_headers:
|
||||
points.append({
|
||||
"type": "header",
|
||||
"name": header,
|
||||
"location": "header"
|
||||
})
|
||||
|
||||
return points
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
NeuroSploit v3 - Client-Side Vulnerability Testers
|
||||
|
||||
Testers for CORS, Clickjacking, Open Redirect
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class CORSTester(BaseTester):
|
||||
"""Tester for CORS Misconfiguration"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "cors_misconfig"
|
||||
|
||||
def build_request(self, endpoint, payload: str) -> Tuple[str, Dict, Dict, Optional[str]]:
|
||||
"""Build CORS test request with Origin header"""
|
||||
headers = {
|
||||
"User-Agent": "NeuroSploit/3.0",
|
||||
"Origin": payload # payload is the test origin
|
||||
}
|
||||
return endpoint.url, {}, headers, None
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for CORS misconfiguration"""
|
||||
acao = response_headers.get("Access-Control-Allow-Origin", "")
|
||||
acac = response_headers.get("Access-Control-Allow-Credentials", "")
|
||||
|
||||
# Wildcard with credentials
|
||||
if acao == "*" and acac.lower() == "true":
|
||||
return True, 0.95, "CORS: Wildcard origin with credentials allowed"
|
||||
|
||||
# Origin reflection
|
||||
if acao == payload:
|
||||
if acac.lower() == "true":
|
||||
return True, 0.9, f"CORS: Arbitrary origin '{payload}' reflected with credentials"
|
||||
return True, 0.7, f"CORS: Arbitrary origin '{payload}' reflected"
|
||||
|
||||
# Wildcard (without credentials still risky)
|
||||
if acao == "*":
|
||||
return True, 0.5, "CORS: Wildcard origin allowed"
|
||||
|
||||
# Null origin accepted
|
||||
if acao == "null":
|
||||
return True, 0.8, "CORS: Null origin accepted"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ClickjackingTester(BaseTester):
|
||||
"""Tester for Clickjacking vulnerability"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "clickjacking"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for clickjacking protection"""
|
||||
# Check X-Frame-Options
|
||||
xfo = response_headers.get("X-Frame-Options", "").upper()
|
||||
|
||||
# Check CSP frame-ancestors
|
||||
csp = response_headers.get("Content-Security-Policy", "")
|
||||
has_frame_ancestors = "frame-ancestors" in csp.lower()
|
||||
|
||||
if not xfo and not has_frame_ancestors:
|
||||
return True, 0.8, "Clickjacking: No X-Frame-Options or frame-ancestors CSP"
|
||||
|
||||
if xfo and xfo not in ["DENY", "SAMEORIGIN"]:
|
||||
return True, 0.7, f"Clickjacking: Weak X-Frame-Options: {xfo}"
|
||||
|
||||
# Check for JS frame busting that can be bypassed
|
||||
frame_busters = [
|
||||
r"if\s*\(\s*top\s*[!=]=",
|
||||
r"if\s*\(\s*self\s*[!=]=\s*top",
|
||||
r"if\s*\(\s*parent\s*[!=]="
|
||||
]
|
||||
for pattern in frame_busters:
|
||||
if re.search(pattern, response_body):
|
||||
if not xfo and not has_frame_ancestors:
|
||||
return True, 0.6, "Clickjacking: Only JS frame-busting (bypassable)"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class OpenRedirectTester(BaseTester):
|
||||
"""Tester for Open Redirect vulnerability"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "open_redirect"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for open redirect"""
|
||||
# Check redirect status and Location header
|
||||
if response_status in [301, 302, 303, 307, 308]:
|
||||
location = response_headers.get("Location", "")
|
||||
|
||||
# Check if our payload URL is in Location
|
||||
if payload in location:
|
||||
return True, 0.9, f"Open redirect: Redirecting to {location}"
|
||||
|
||||
# Check for partial match (domain)
|
||||
if "evil.com" in payload and "evil.com" in location:
|
||||
return True, 0.9, "Open redirect: External domain in redirect"
|
||||
|
||||
# Check for meta refresh redirect
|
||||
meta_refresh = re.search(
|
||||
r'<meta[^>]+http-equiv=["\']?refresh["\']?[^>]+content=["\']?\d+;\s*url=([^"\'>\s]+)',
|
||||
response_body, re.IGNORECASE
|
||||
)
|
||||
if meta_refresh:
|
||||
redirect_url = meta_refresh.group(1)
|
||||
if payload in redirect_url:
|
||||
return True, 0.8, f"Open redirect via meta refresh: {redirect_url}"
|
||||
|
||||
# Check for JavaScript redirect
|
||||
js_redirects = [
|
||||
rf'location\.href\s*=\s*["\']?{re.escape(payload)}',
|
||||
rf'location\.assign\s*\(["\']?{re.escape(payload)}',
|
||||
rf'location\.replace\s*\(["\']?{re.escape(payload)}'
|
||||
]
|
||||
for pattern in js_redirects:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.7, "Open redirect via JavaScript"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class DomClobberingTester(BaseTester):
|
||||
"""Tester for DOM Clobbering vulnerability"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "dom_clobbering"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for HTML injection that could override JS variables via DOM clobbering"""
|
||||
if response_status == 200:
|
||||
# Check if injected HTML with id/name attributes is reflected
|
||||
clobber_patterns = [
|
||||
r'<(?:a|form|img|input|iframe|embed|object)\s+[^>]*(?:id|name)\s*=\s*["\']?(?:' + re.escape(payload.split("=")[0] if "=" in payload else payload) + r')',
|
||||
r'<a\s+[^>]*id=["\'][^"\']+["\'][^>]*href=["\']',
|
||||
r'<form\s+[^>]*name=["\'][^"\']+["\']',
|
||||
]
|
||||
for pattern in clobber_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, "DOM Clobbering: Injected HTML with id/name attribute reflected"
|
||||
|
||||
# Check for common clobberable global variables
|
||||
clobber_targets = [
|
||||
r'<[^>]+id=["\'](?:location|document|window|self|top|frames|opener|parent)["\']',
|
||||
r'<[^>]+name=["\'](?:location|document|window|self|top|frames|opener|parent)["\']',
|
||||
]
|
||||
for pattern in clobber_targets:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.85, "DOM Clobbering: HTML element with JS global variable name injected"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class PostMessageVulnTester(BaseTester):
|
||||
"""Tester for postMessage vulnerability (missing origin check)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "postmessage_vuln"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for addEventListener('message') without origin validation"""
|
||||
if response_status == 200:
|
||||
# Find message event listeners
|
||||
message_listener = re.search(
|
||||
r'addEventListener\s*\(\s*["\']message["\']',
|
||||
response_body
|
||||
)
|
||||
if message_listener:
|
||||
# Check if origin is NOT validated nearby
|
||||
# Get surrounding context (500 chars after listener)
|
||||
listener_pos = message_listener.start()
|
||||
handler_block = response_body[listener_pos:listener_pos + 500]
|
||||
|
||||
origin_checks = [
|
||||
r'\.origin\s*[!=]==?\s*["\']',
|
||||
r'event\.origin',
|
||||
r'e\.origin',
|
||||
r'msg\.origin',
|
||||
r'origin\s*===',
|
||||
]
|
||||
has_origin_check = any(re.search(p, handler_block) for p in origin_checks)
|
||||
|
||||
if not has_origin_check:
|
||||
return True, 0.85, "postMessage vulnerability: Message listener without origin validation"
|
||||
else:
|
||||
# Origin check exists but might be weak
|
||||
if re.search(r'\.origin\s*[!=]==?\s*["\']["\']', handler_block):
|
||||
return True, 0.7, "postMessage vulnerability: Origin check appears to be empty string"
|
||||
|
||||
# Check for postMessage with wildcard origin
|
||||
wildcard_post = re.search(
|
||||
r'\.postMessage\s*\([^)]+,\s*["\']\*["\']',
|
||||
response_body
|
||||
)
|
||||
if wildcard_post:
|
||||
return True, 0.75, "postMessage vulnerability: postMessage with wildcard '*' target origin"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class WebsocketHijackTester(BaseTester):
|
||||
"""Tester for WebSocket Cross-Origin Hijacking"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "websocket_hijack"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for WebSocket connections accepting cross-origin requests"""
|
||||
# WebSocket upgrade accepted (101 Switching Protocols)
|
||||
if response_status == 101:
|
||||
# Check if Origin header was sent and accepted
|
||||
upgrade = response_headers.get("Upgrade", "").lower()
|
||||
if upgrade == "websocket":
|
||||
return True, 0.8, "WebSocket hijack: Cross-origin WebSocket upgrade accepted"
|
||||
|
||||
# Check for WebSocket endpoint in response without origin validation
|
||||
if response_status == 200:
|
||||
ws_patterns = [
|
||||
r'new\s+WebSocket\s*\(\s*["\']wss?://',
|
||||
r'ws://[^"\'>\s]+',
|
||||
r'wss://[^"\'>\s]+',
|
||||
]
|
||||
for pattern in ws_patterns:
|
||||
if re.search(pattern, response_body):
|
||||
# Check for lack of CORS-like origin checking
|
||||
if "origin" not in response_body.lower() or context.get("cross_origin_accepted"):
|
||||
return True, 0.7, "WebSocket hijack: WebSocket endpoint found without apparent origin validation"
|
||||
|
||||
# 403 on WebSocket with wrong origin is good (not vulnerable)
|
||||
if response_status == 403:
|
||||
return False, 0.0, None
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class PrototypePollutionTester(BaseTester):
|
||||
"""Tester for JavaScript Prototype Pollution"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "prototype_pollution"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for __proto__ pollution indicators"""
|
||||
if response_status == 200:
|
||||
# Check if __proto__ payload was processed
|
||||
proto_indicators = [
|
||||
r'__proto__',
|
||||
r'constructor\.prototype',
|
||||
r'Object\.prototype',
|
||||
]
|
||||
|
||||
# Payload should contain proto pollution attempt
|
||||
is_proto_payload = any(
|
||||
ind in payload for ind in ["__proto__", "constructor", "prototype"]
|
||||
)
|
||||
|
||||
if is_proto_payload:
|
||||
# Check for pollution effect in response
|
||||
pollution_effects = [
|
||||
r'"__proto__"\s*:\s*\{',
|
||||
r'"polluted"\s*:\s*true',
|
||||
r'"isAdmin"\s*:\s*true',
|
||||
r'"__proto__":\s*\{[^}]*\}',
|
||||
]
|
||||
for pattern in pollution_effects:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.85, "Prototype pollution: __proto__ property accepted and reflected"
|
||||
|
||||
# Check if server processed the prototype chain modification
|
||||
if response_status == 200 and "__proto__" in response_body:
|
||||
return True, 0.7, "Prototype pollution: __proto__ present in server response"
|
||||
|
||||
# Check for error indicating proto processing
|
||||
if re.search(r"(?:cannot|unable to).*(?:merge|assign|extend).*proto", response_body, re.IGNORECASE):
|
||||
return True, 0.6, "Prototype pollution: Server attempted to process __proto__"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CssInjectionTester(BaseTester):
|
||||
"""Tester for CSS Injection vulnerability"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "css_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for CSS code rendered in style context"""
|
||||
if response_status == 200:
|
||||
# Check if CSS payload is reflected in style context
|
||||
css_contexts = [
|
||||
# Inside <style> tags
|
||||
r'<style[^>]*>(?:[^<]*?)' + re.escape(payload)[:30].replace("\\", "\\\\"),
|
||||
# Inside style attribute
|
||||
r'style\s*=\s*["\'][^"\']*' + re.escape(payload)[:30].replace("\\", "\\\\"),
|
||||
]
|
||||
|
||||
for pattern in css_contexts:
|
||||
try:
|
||||
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
|
||||
return True, 0.85, "CSS injection: Payload reflected in style context"
|
||||
except re.error:
|
||||
continue
|
||||
|
||||
# Check for common CSS injection payloads reflected
|
||||
css_attack_patterns = [
|
||||
r'expression\s*\(',
|
||||
r'url\s*\(\s*["\']?javascript:',
|
||||
r'@import\s+["\']?https?://',
|
||||
r'background:\s*url\s*\(\s*["\']?https?://[^"\')\s]*attacker',
|
||||
r'behavior:\s*url\s*\(',
|
||||
r'-moz-binding:\s*url\s*\(',
|
||||
]
|
||||
for pattern in css_attack_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, "CSS injection: Dangerous CSS property reflected"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class TabnabbingTester(BaseTester):
|
||||
"""Tester for Reverse Tabnabbing vulnerability"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "tabnabbing"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for target=_blank links without rel=noopener"""
|
||||
if response_status == 200:
|
||||
# Find all target=_blank links
|
||||
blank_links = re.finditer(
|
||||
r'<a\s+[^>]*target\s*=\s*["\']_blank["\'][^>]*>',
|
||||
response_body,
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
vulnerable_count = 0
|
||||
for match in blank_links:
|
||||
link_tag = match.group(0)
|
||||
# Check for rel=noopener or rel=noreferrer
|
||||
has_protection = re.search(
|
||||
r'rel\s*=\s*["\'][^"\']*(?:noopener|noreferrer)[^"\']*["\']',
|
||||
link_tag,
|
||||
re.IGNORECASE
|
||||
)
|
||||
if not has_protection:
|
||||
vulnerable_count += 1
|
||||
|
||||
if vulnerable_count > 0:
|
||||
confidence = min(0.5 + vulnerable_count * 0.1, 0.8)
|
||||
return True, confidence, f"Tabnabbing: {vulnerable_count} target=_blank link(s) without rel=noopener/noreferrer"
|
||||
|
||||
return False, 0.0, None
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
NeuroSploit v3 - Cloud & Supply Chain Vulnerability Testers
|
||||
|
||||
Testers for S3 misconfiguration, cloud metadata, subdomain takeover,
|
||||
vulnerable dependencies, container escape, and serverless misconfiguration.
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class S3BucketMisconfigTester(BaseTester):
|
||||
"""Tester for S3 Bucket Misconfiguration vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "s3_bucket_misconfig"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for S3 bucket listing or misconfiguration"""
|
||||
# Bucket listing XML response
|
||||
if re.search(r"<ListBucketResult\s", response_body):
|
||||
return True, 0.95, "S3 bucket listing enabled - bucket contents exposed"
|
||||
|
||||
# Bucket listing with objects
|
||||
if "<Contents>" in response_body and "<Key>" in response_body:
|
||||
keys = re.findall(r"<Key>([^<]+)</Key>", response_body)
|
||||
if keys:
|
||||
return True, 0.95, f"S3 bucket listing: {len(keys)} objects exposed (e.g., {keys[0][:50]})"
|
||||
|
||||
# NoSuchBucket - potential subdomain takeover
|
||||
if "NoSuchBucket" in response_body:
|
||||
bucket_match = re.search(r"<BucketName>([^<]+)</BucketName>", response_body)
|
||||
bucket_name = bucket_match.group(1) if bucket_match else "unknown"
|
||||
return True, 0.8, f"S3 bucket '{bucket_name}' does not exist - potential takeover"
|
||||
|
||||
# AccessDenied with bucket info
|
||||
if "AccessDenied" in response_body and "s3.amazonaws.com" in response_body:
|
||||
return True, 0.5, "S3 bucket exists but access denied - bucket enumerated"
|
||||
|
||||
# Public write/upload success
|
||||
if response_status in [200, 204] and context.get("is_upload_test"):
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
if "x-amz-request-id" in headers_lower:
|
||||
return True, 0.9, "S3 bucket allows public write access"
|
||||
|
||||
# Bucket policy exposed
|
||||
if '"Statement"' in response_body and '"Effect"' in response_body:
|
||||
if '"s3:' in response_body:
|
||||
return True, 0.85, "S3 bucket policy exposed"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CloudMetadataExposureTester(BaseTester):
|
||||
"""Tester for Cloud Metadata Exposure vulnerabilities (SSRF to metadata)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "cloud_metadata_exposure"
|
||||
self.metadata_indicators = {
|
||||
# AWS
|
||||
"aws": [
|
||||
(r"ami-[0-9a-f]{8,17}", "AWS AMI ID"),
|
||||
(r"i-[0-9a-f]{8,17}", "AWS Instance ID"),
|
||||
(r"arn:aws:[a-z0-9-]+:[a-z0-9-]*:\d{12}:", "AWS ARN"),
|
||||
(r"AKIA[0-9A-Z]{16}", "AWS Access Key"),
|
||||
(r"169\.254\.169\.254", "AWS metadata endpoint"),
|
||||
(r"\"(?:AccessKeyId|SecretAccessKey|Token)\"", "AWS IAM credentials"),
|
||||
(r"ec2\.internal", "AWS internal hostname"),
|
||||
(r"\"accountId\"\s*:\s*\"\d{12}\"", "AWS Account ID"),
|
||||
],
|
||||
# GCP
|
||||
"gcp": [
|
||||
(r"projects/\d+/", "GCP Project reference"),
|
||||
(r"metadata\.google\.internal", "GCP metadata endpoint"),
|
||||
(r"\"access_token\"\s*:\s*\"ya29\.", "GCP OAuth token"),
|
||||
(r"compute\.googleapis\.com", "GCP Compute API"),
|
||||
(r"serviceAccounts/[^/]+/token", "GCP service account token"),
|
||||
],
|
||||
# Azure
|
||||
"azure": [
|
||||
(r"metadata\.azure\.com", "Azure metadata endpoint"),
|
||||
(r"(?:subscriptionId|resourceGroupName)\"\s*:\s*\"", "Azure resource info"),
|
||||
(r"\.blob\.core\.windows\.net", "Azure Blob Storage"),
|
||||
(r"\.vault\.azure\.net", "Azure Key Vault"),
|
||||
(r"\"access_token\"\s*:\s*\"eyJ", "Azure JWT token"),
|
||||
],
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for cloud metadata in response"""
|
||||
findings = []
|
||||
|
||||
for provider, patterns in self.metadata_indicators.items():
|
||||
for pattern, description in patterns:
|
||||
if re.search(pattern, response_body):
|
||||
findings.append(f"{provider.upper()}: {description}")
|
||||
|
||||
if findings:
|
||||
# IAM credentials or tokens are critical
|
||||
critical = any(k in f for f in findings for k in ["credentials", "Access Key", "token", "Token"])
|
||||
confidence = 0.95 if critical else 0.8
|
||||
return True, confidence, f"Cloud metadata exposure: {', '.join(findings[:3])}"
|
||||
|
||||
# Check for metadata endpoint access (SSRF to cloud metadata)
|
||||
metadata_urls = ["169.254.169.254", "metadata.google.internal",
|
||||
"metadata.azure.com", "100.100.100.200"]
|
||||
for url in metadata_urls:
|
||||
if url in payload and response_status == 200 and len(response_body) > 50:
|
||||
return True, 0.85, f"Cloud metadata accessible via SSRF ({url})"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SubdomainTakeoverTester(BaseTester):
|
||||
"""Tester for Subdomain Takeover vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "subdomain_takeover"
|
||||
self.takeover_fingerprints = [
|
||||
# AWS S3
|
||||
(r"NoSuchBucket", "S3 bucket - NoSuchBucket"),
|
||||
(r"The specified bucket does not exist", "S3 bucket does not exist"),
|
||||
# GitHub Pages
|
||||
(r"There isn't a GitHub Pages site here", "GitHub Pages - unclaimed"),
|
||||
(r"For root URLs.*GitHub Pages", "GitHub Pages not configured"),
|
||||
# Heroku
|
||||
(r"No such app", "Heroku - app not found"),
|
||||
(r"herokucdn\.com/error-pages/no-such-app", "Heroku - no such app"),
|
||||
# Shopify
|
||||
(r"Sorry, this shop is currently unavailable", "Shopify - shop unavailable"),
|
||||
# Tumblr
|
||||
(r"There's nothing here\.", "Tumblr - unclaimed"),
|
||||
(r"Whatever you were looking for doesn't currently exist", "Tumblr not found"),
|
||||
# WordPress.com
|
||||
(r"Do you want to register.*wordpress\.com", "WordPress.com - unclaimed"),
|
||||
# Azure
|
||||
(r"404 Web Site not found", "Azure - web app not found"),
|
||||
# Fastly
|
||||
(r"Fastly error: unknown domain", "Fastly - unknown domain"),
|
||||
# Pantheon
|
||||
(r"404 error unknown site", "Pantheon - unknown site"),
|
||||
# Zendesk
|
||||
(r"Help Center Closed", "Zendesk - closed"),
|
||||
# Unbounce
|
||||
(r"The requested URL was not found on this server.*unbounce", "Unbounce - not found"),
|
||||
# Surge.sh
|
||||
(r"project not found", "Surge.sh - not found"),
|
||||
# Fly.io
|
||||
(r"404.*fly\.io", "Fly.io - not found"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for cloud provider error pages indicating takeover opportunity"""
|
||||
for pattern, description in self.takeover_fingerprints:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Subdomain takeover: {description}"
|
||||
|
||||
# CNAME pointing to unclaimed resource
|
||||
if context.get("cname_target"):
|
||||
cname = context["cname_target"]
|
||||
unclaimed_domains = [
|
||||
".s3.amazonaws.com", ".herokuapp.com", ".github.io",
|
||||
".azurewebsites.net", ".cloudfront.net", ".fastly.net",
|
||||
".ghost.io", ".myshopify.com", ".surge.sh",
|
||||
]
|
||||
for domain in unclaimed_domains:
|
||||
if cname.endswith(domain) and response_status in [404, 0]:
|
||||
return True, 0.85, f"Subdomain takeover: CNAME to {cname} returns {response_status}"
|
||||
|
||||
# NXDOMAIN with CNAME
|
||||
if context.get("dns_nxdomain") and context.get("has_cname"):
|
||||
return True, 0.8, "Subdomain takeover: CNAME exists but target domain is NXDOMAIN"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class VulnerableDependencyTester(BaseTester):
|
||||
"""Tester for Vulnerable Dependency detection"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "vulnerable_dependency"
|
||||
self.vulnerable_libs = [
|
||||
# JavaScript
|
||||
(r"jquery[/.-](?:1\.\d+|2\.\d+|3\.[0-4]\.\d+)", "jQuery < 3.5.0 (XSS)"),
|
||||
(r"angular[/.-]1\.[0-5]\.\d+", "AngularJS < 1.6 (sandbox escape)"),
|
||||
(r"lodash[/.-](?:[0-3]\.\d+|4\.(?:1[0-6]|[0-9])\.\d+)", "Lodash < 4.17.21 (prototype pollution)"),
|
||||
(r"bootstrap[/.-](?:[1-3]\.\d+|4\.[0-3]\.\d+)", "Bootstrap < 4.3.1 (XSS)"),
|
||||
(r"moment[/.-](?:[01]\.\d+|2\.(?:[0-9]|1[0-8])\.\d+)", "Moment.js < 2.19.3 (ReDoS)"),
|
||||
(r"handlebars[/.-](?:[0-3]\.\d+|4\.[0-6]\.\d+)", "Handlebars < 4.7.7 (prototype pollution)"),
|
||||
# Python
|
||||
(r"Django[/=](?:1\.\d+|2\.[01]\.\d+|3\.0\.\d+)", "Django < 3.1 (multiple CVEs)"),
|
||||
(r"Flask[/=](?:0\.\d+|1\.[01]\.\d+)", "Flask < 2.0 (known issues)"),
|
||||
(r"requests[/=]2\.(?:[0-9]|1\d|2[0-4])\.\d+", "Requests < 2.25 (CVE-2023-32681)"),
|
||||
# Java
|
||||
(r"log4j[/-]2\.(?:[0-9]|1[0-4])\.\d+", "Log4j < 2.15 (Log4Shell CVE-2021-44228)"),
|
||||
(r"spring-core[/-](?:[1-4]\.\d+|5\.[0-2]\.\d+)", "Spring < 5.3 (Spring4Shell)"),
|
||||
(r"jackson-databind[/-]2\.(?:[0-8]|9\.[0-9])\.\d*", "Jackson < 2.9.10 (deserialization)"),
|
||||
# PHP
|
||||
(r"laravel/framework[/:]\s*v?(?:[1-7]\.\d+|8\.[0-7]\d)", "Laravel < 8.80 (known issues)"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for known vulnerable library version strings"""
|
||||
findings = []
|
||||
|
||||
# Check response body (JS files, package.json, error pages)
|
||||
for pattern, description in self.vulnerable_libs:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
findings.append(f"{match.group(0)} - {description}")
|
||||
|
||||
# Check headers for version info
|
||||
headers_str = "\n".join(f"{k}: {v}" for k, v in response_headers.items())
|
||||
for pattern, description in self.vulnerable_libs:
|
||||
match = re.search(pattern, headers_str, re.IGNORECASE)
|
||||
if match and description not in str(findings):
|
||||
findings.append(f"{match.group(0)} - {description}")
|
||||
|
||||
if findings:
|
||||
# Log4Shell and Spring4Shell are critical
|
||||
critical = any(k in f for f in findings for k in ["Log4Shell", "Spring4Shell", "deserialization"])
|
||||
confidence = 0.9 if critical else 0.75
|
||||
return True, confidence, f"Vulnerable dependency: {'; '.join(findings[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ContainerEscapeTester(BaseTester):
|
||||
"""Tester for Container Escape / Container Misconfiguration vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "container_escape"
|
||||
self.container_indicators = [
|
||||
# Docker
|
||||
(r"\.dockerenv", "Docker environment file accessible"),
|
||||
(r"docker\.sock", "Docker socket exposed"),
|
||||
(r"/var/run/docker\.sock", "Docker socket path"),
|
||||
# Cgroup
|
||||
(r"docker[/-][0-9a-f]{12,64}", "Docker container cgroup"),
|
||||
(r"/proc/self/cgroup.*docker", "Docker cgroup detected"),
|
||||
(r"/proc/self/cgroup.*kubepods", "Kubernetes pod cgroup"),
|
||||
# Kubernetes
|
||||
(r"KUBERNETES_SERVICE_HOST", "Kubernetes service host env"),
|
||||
(r"KUBERNETES_PORT", "Kubernetes port env"),
|
||||
(r"/var/run/secrets/kubernetes\.io", "Kubernetes secrets path"),
|
||||
(r"serviceaccount/token", "Kubernetes service account token"),
|
||||
(r"kube-system", "Kubernetes system namespace"),
|
||||
# Container runtime
|
||||
(r"containerd", "containerd runtime"),
|
||||
(r"runc", "runc runtime"),
|
||||
# Process namespace
|
||||
(r"process\s+1\b.*(?:init|systemd|tini|dumb-init)", "Container init process"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for Docker/container indicators in response"""
|
||||
findings = []
|
||||
|
||||
for pattern, description in self.container_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
findings.append(description)
|
||||
|
||||
if findings:
|
||||
# Docker socket or K8s secrets are critical
|
||||
critical = any(k in f for f in findings for k in ["socket", "secrets", "token"])
|
||||
confidence = 0.9 if critical else 0.7
|
||||
return True, confidence, f"Container exposure: {', '.join(findings[:3])}"
|
||||
|
||||
# Privileged container detection
|
||||
if context.get("is_capability_check"):
|
||||
# Check for extra capabilities
|
||||
cap_patterns = [
|
||||
r"cap_sys_admin", r"cap_sys_ptrace", r"cap_net_admin",
|
||||
r"cap_dac_override", r"cap_sys_rawio",
|
||||
]
|
||||
for pattern in cap_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, f"Privileged container: {pattern} capability detected"
|
||||
|
||||
# Mount namespace check
|
||||
if re.search(r"/dev/(?:sda|xvda|nvme)\d*\s", response_body):
|
||||
return True, 0.6, "Host block devices visible from container"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ServerlessMisconfigTester(BaseTester):
|
||||
"""Tester for Serverless Misconfiguration vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "serverless_misconfig"
|
||||
self.env_patterns = [
|
||||
# AWS Lambda
|
||||
(r"AWS_LAMBDA_FUNCTION_NAME\s*[=:]\s*(\S+)", "Lambda function name"),
|
||||
(r"AWS_SECRET_ACCESS_KEY\s*[=:]\s*(\S+)", "Lambda AWS secret key"),
|
||||
(r"AWS_SESSION_TOKEN\s*[=:]\s*(\S+)", "Lambda session token"),
|
||||
(r"AWS_LAMBDA_LOG_GROUP_NAME", "Lambda log group"),
|
||||
(r"_HANDLER\s*[=:]\s*(\S+)", "Lambda handler path"),
|
||||
(r"LAMBDA_TASK_ROOT\s*[=:]\s*(\S+)", "Lambda task root"),
|
||||
(r"AWS_EXECUTION_ENV\s*[=:]\s*(\S+)", "Lambda execution environment"),
|
||||
# Google Cloud Functions
|
||||
(r"FUNCTION_NAME\s*[=:]\s*(\S+)", "Cloud Function name"),
|
||||
(r"GCLOUD_PROJECT\s*[=:]\s*(\S+)", "GCP project ID"),
|
||||
(r"GOOGLE_CLOUD_PROJECT\s*[=:]\s*(\S+)", "GCP project"),
|
||||
(r"GCP_PROJECT\s*[=:]\s*(\S+)", "GCP project"),
|
||||
(r"FUNCTION_REGION\s*[=:]\s*(\S+)", "Cloud Function region"),
|
||||
# Azure Functions
|
||||
(r"FUNCTIONS_WORKER_RUNTIME\s*[=:]\s*(\S+)", "Azure Function runtime"),
|
||||
(r"AzureWebJobsStorage\s*[=:]\s*(\S+)", "Azure storage connection string"),
|
||||
(r"WEBSITE_SITE_NAME\s*[=:]\s*(\S+)", "Azure site name"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for serverless environment variable exposure"""
|
||||
findings = []
|
||||
|
||||
for pattern, description in self.env_patterns:
|
||||
match = re.search(pattern, response_body)
|
||||
if match:
|
||||
value = match.group(1) if match.lastindex else ""
|
||||
# Redact sensitive values
|
||||
if "key" in description.lower() or "token" in description.lower() or "secret" in description.lower():
|
||||
display = f"{description} (REDACTED)"
|
||||
else:
|
||||
display = f"{description}: {value[:30]}"
|
||||
findings.append(display)
|
||||
|
||||
if findings:
|
||||
# Credentials are critical
|
||||
critical = any(k in f for f in findings for k in ["secret", "token", "REDACTED"])
|
||||
confidence = 0.95 if critical else 0.75
|
||||
return True, confidence, f"Serverless misconfiguration: {', '.join(findings[:3])}"
|
||||
|
||||
# Function source code exposure
|
||||
if context.get("is_source_request"):
|
||||
source_indicators = [
|
||||
r"exports\.handler\s*=", r"def lambda_handler\(",
|
||||
r"def main\(req:", r"module\.exports",
|
||||
r"def hello_http\(request\):",
|
||||
]
|
||||
for pattern in source_indicators:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.8, "Serverless misconfiguration: function source code exposed"
|
||||
|
||||
# Invocation error details
|
||||
error_patterns = [
|
||||
r"\"errorType\"\s*:\s*\"(\w+)\"",
|
||||
r"\"stackTrace\"\s*:\s*\[",
|
||||
r"Runtime\.HandlerNotFound",
|
||||
r"\"errorMessage\"\s*:\s*\".*(?:import|require|module)",
|
||||
]
|
||||
for pattern in error_patterns:
|
||||
match = re.search(pattern, response_body)
|
||||
if match:
|
||||
return True, 0.7, f"Serverless misconfiguration: detailed error exposed ({match.group(0)[:60]})"
|
||||
|
||||
return False, 0.0, None
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
NeuroSploit v3 - Data Exposure Vulnerability Testers
|
||||
|
||||
Testers for sensitive data exposure, information disclosure, API key exposure,
|
||||
source code disclosure, backup file exposure, and version disclosure.
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class SensitiveDataExposureTester(BaseTester):
|
||||
"""Tester for Sensitive Data Exposure (PII leakage)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "sensitive_data_exposure"
|
||||
self.pii_patterns = [
|
||||
# SSN (US)
|
||||
(r"\b\d{3}-\d{2}-\d{4}\b", "SSN pattern"),
|
||||
# Credit card numbers (Visa, MC, Amex, Discover)
|
||||
(r"\b4\d{3}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", "Visa card number"),
|
||||
(r"\b5[1-5]\d{2}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", "MasterCard number"),
|
||||
(r"\b3[47]\d{2}[\s-]?\d{6}[\s-]?\d{5}\b", "Amex card number"),
|
||||
(r"\b6(?:011|5\d{2})[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b", "Discover card number"),
|
||||
# Email addresses in bulk (10+ suggests a data leak)
|
||||
(r"[\w.+-]+@[\w-]+\.[\w.-]+", "email address"),
|
||||
# Phone numbers (US format)
|
||||
(r"\b\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b", "phone number"),
|
||||
# Passport numbers
|
||||
(r"\b[A-Z]\d{8}\b", "passport number pattern"),
|
||||
# Private keys
|
||||
(r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----", "private key"),
|
||||
# Password hashes
|
||||
(r"\$2[aby]?\$\d{1,2}\$[./A-Za-z0-9]{53}", "bcrypt hash"),
|
||||
(r"\b[a-f0-9]{32}\b", "MD5 hash"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for PII patterns in response"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
findings = []
|
||||
|
||||
for pattern, description in self.pii_patterns:
|
||||
matches = re.findall(pattern, response_body)
|
||||
if matches:
|
||||
# Private keys and password hashes are always significant
|
||||
if "private key" in description:
|
||||
return True, 0.95, f"Sensitive data exposure: {description} found in response"
|
||||
if "bcrypt hash" in description:
|
||||
return True, 0.9, f"Sensitive data exposure: {description} found in response"
|
||||
|
||||
# For patterns like emails, phone numbers - check for bulk exposure
|
||||
if description in ["email address", "phone number"]:
|
||||
if len(matches) >= 5:
|
||||
findings.append(f"{len(matches)} {description}s")
|
||||
elif description == "MD5 hash":
|
||||
if len(matches) >= 3:
|
||||
findings.append(f"{len(matches)} {description}es")
|
||||
else:
|
||||
findings.append(f"{description} ({matches[0][:20]}...)")
|
||||
|
||||
if findings:
|
||||
confidence = min(0.9, 0.6 + 0.1 * len(findings))
|
||||
return True, confidence, f"Sensitive data exposure: {', '.join(findings[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class InformationDisclosureTester(BaseTester):
|
||||
"""Tester for Information Disclosure vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "information_disclosure"
|
||||
self.disclosure_patterns = [
|
||||
# Server version headers
|
||||
(r"Server:\s*(.+)", "header", "Server version"),
|
||||
(r"X-Powered-By:\s*(.+)", "header", "Technology stack"),
|
||||
(r"X-AspNet-Version:\s*(.+)", "header", "ASP.NET version"),
|
||||
(r"X-AspNetMvc-Version:\s*(.+)", "header", "ASP.NET MVC version"),
|
||||
]
|
||||
self.body_patterns = [
|
||||
# Path disclosure
|
||||
(r"(?:/var/www|/home/\w+|/srv/|/opt/\w+|C:\\inetpub|C:\\Users\\\w+)[/\\]\S+", "Internal path"),
|
||||
# Stack traces
|
||||
(r"Traceback \(most recent call last\)", "Python stack trace"),
|
||||
(r"at \w+\.\w+\([\w.]+:\d+\)", "Java stack trace"),
|
||||
(r"(?:Fatal error|Warning|Notice):\s+.*\sin\s+/\S+\s+on line \d+", "PHP error with path"),
|
||||
(r"Microsoft \.NET Framework Version:\d+", ".NET framework version"),
|
||||
# Database info
|
||||
(r"(?:MySQL|PostgreSQL|Oracle|MSSQL)\s+\d+\.\d+", "Database version"),
|
||||
# Debug info
|
||||
(r"(?:DEBUG|TRACE)\s*=\s*(?:true|True|1)", "Debug mode enabled"),
|
||||
(r"(?:SECRET_KEY|DB_PASSWORD|API_SECRET)\s*[=:]\s*\S+", "Secret in debug output"),
|
||||
# Internal IPs
|
||||
(r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b", "Internal IP address"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for information disclosure in headers and body"""
|
||||
findings = []
|
||||
|
||||
# Check headers
|
||||
headers_str = "\n".join(f"{k}: {v}" for k, v in response_headers.items())
|
||||
for pattern, location, description in self.disclosure_patterns:
|
||||
match = re.search(pattern, headers_str, re.IGNORECASE)
|
||||
if match:
|
||||
findings.append(f"{description}: {match.group(1)[:50]}")
|
||||
|
||||
# Check body
|
||||
for pattern, description in self.body_patterns:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
findings.append(f"{description}: {match.group(0)[:80]}")
|
||||
|
||||
if findings:
|
||||
# Stack traces and secrets are higher severity
|
||||
high_severity = any("stack trace" in f.lower() or "secret" in f.lower() or "debug" in f.lower() for f in findings)
|
||||
confidence = 0.85 if high_severity else 0.7
|
||||
return True, confidence, f"Information disclosure: {'; '.join(findings[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ApiKeyExposureTester(BaseTester):
|
||||
"""Tester for API Key Exposure vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "api_key_exposure"
|
||||
self.key_patterns = [
|
||||
# AWS
|
||||
(r"AKIA[0-9A-Z]{16}", "AWS Access Key"),
|
||||
(r"(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[=:]\s*[A-Za-z0-9/+=]{40}", "AWS Secret Key"),
|
||||
# Google
|
||||
(r"AIza[0-9A-Za-z\-_]{35}", "Google API Key"),
|
||||
(r"ya29\.[0-9A-Za-z\-_]+", "Google OAuth Token"),
|
||||
# Stripe
|
||||
(r"sk_live_[0-9a-zA-Z]{24,}", "Stripe Secret Key"),
|
||||
(r"pk_live_[0-9a-zA-Z]{24,}", "Stripe Publishable Key"),
|
||||
(r"rk_live_[0-9a-zA-Z]{24,}", "Stripe Restricted Key"),
|
||||
# GitHub
|
||||
(r"gh[pousr]_[A-Za-z0-9_]{36,}", "GitHub Token"),
|
||||
# Slack
|
||||
(r"xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*", "Slack Token"),
|
||||
# Twilio
|
||||
(r"SK[0-9a-fA-F]{32}", "Twilio API Key"),
|
||||
# SendGrid
|
||||
(r"SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}", "SendGrid API Key"),
|
||||
# Heroku
|
||||
(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", "Heroku API Key / UUID"),
|
||||
# Generic patterns
|
||||
(r"(?:api[_-]?key|apikey|api[_-]?secret)\s*[=:]\s*['\"]?([A-Za-z0-9_\-]{20,})['\"]?", "Generic API Key"),
|
||||
(r"(?:access[_-]?token|auth[_-]?token)\s*[=:]\s*['\"]?([A-Za-z0-9_\-.]{20,})['\"]?", "Access Token"),
|
||||
# Bearer tokens in JS
|
||||
(r"['\"]Bearer\s+[A-Za-z0-9_\-\.]{20,}['\"]", "Hardcoded Bearer Token"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for API key patterns in response"""
|
||||
findings = []
|
||||
|
||||
for pattern, description in self.key_patterns:
|
||||
matches = re.findall(pattern, response_body)
|
||||
if matches:
|
||||
# Skip UUIDs unless in specific context (too many false positives)
|
||||
if "UUID" in description:
|
||||
continue
|
||||
# Redact the actual key in evidence
|
||||
sample = matches[0] if isinstance(matches[0], str) else matches[0]
|
||||
redacted = sample[:8] + "..." + sample[-4:] if len(sample) > 12 else sample[:4] + "..."
|
||||
findings.append(f"{description} ({redacted})")
|
||||
|
||||
if findings:
|
||||
# AWS/Stripe secret keys are critical
|
||||
critical = any(k in f for f in findings for k in ["AWS Secret", "Stripe Secret", "Secret Key"])
|
||||
confidence = 0.95 if critical else 0.85
|
||||
return True, confidence, f"API key exposure: {', '.join(findings[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SourceCodeDisclosureTester(BaseTester):
|
||||
"""Tester for Source Code Disclosure vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "source_code_disclosure"
|
||||
self.source_indicators = [
|
||||
# Git
|
||||
(r"\[core\]\s*\n\s*repositoryformatversion", "Git config exposed"),
|
||||
(r"\[remote \"origin\"\]", "Git config with remote"),
|
||||
(r"ref: refs/heads/", "Git HEAD reference exposed"),
|
||||
# Source maps
|
||||
(r"\"version\"\s*:\s*3,\s*\"sources\"", "JavaScript source map"),
|
||||
(r"//[#@]\s*sourceMappingURL=", "Source map reference"),
|
||||
# PHP source
|
||||
(r"<\?php\s", "PHP source code"),
|
||||
(r"<\?=", "PHP short tag source"),
|
||||
# Python source
|
||||
(r"^(?:import |from \w+ import |def \w+\(|class \w+)", "Python source code"),
|
||||
# Java/JSP
|
||||
(r"<%@?\s*page\s+", "JSP source code"),
|
||||
(r"package\s+\w+\.\w+;", "Java package declaration"),
|
||||
# Environment files
|
||||
(r"(?:DB_PASSWORD|SECRET_KEY|DATABASE_URL)\s*=\s*\S+", "Environment file content"),
|
||||
# Composer/package files with private repos
|
||||
(r"\"require\".*\"private/", "Private package reference"),
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for source code indicators in response"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
for pattern, description in self.source_indicators:
|
||||
match = re.search(pattern, response_body, re.MULTILINE)
|
||||
if match:
|
||||
# Git config is high confidence
|
||||
if "Git" in description:
|
||||
return True, 0.95, f"Source code disclosure: {description}"
|
||||
# Environment file is critical
|
||||
if "Environment" in description:
|
||||
return True, 0.95, f"Source code disclosure: {description}"
|
||||
# Source maps lower confidence (often intentional)
|
||||
if "source map" in description.lower():
|
||||
return True, 0.6, f"Source code disclosure: {description}"
|
||||
return True, 0.8, f"Source code disclosure: {description}"
|
||||
|
||||
# Check for common source file access patterns
|
||||
source_paths = [".git/config", ".env", ".htaccess", "web.config",
|
||||
"wp-config.php", "config.php", "settings.py"]
|
||||
for path in source_paths:
|
||||
if path in payload and response_status == 200 and len(response_body) > 50:
|
||||
return True, 0.7, f"Source code disclosure: {path} accessible"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class BackupFileExposureTester(BaseTester):
|
||||
"""Tester for Backup File Exposure vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "backup_file_exposure"
|
||||
self.file_signatures = [
|
||||
# SQL dumps
|
||||
(r"-- MySQL dump \d+", "MySQL database dump"),
|
||||
(r"-- PostgreSQL database dump", "PostgreSQL database dump"),
|
||||
(r"CREATE TABLE\s+[`\"]\w+[`\"]", "SQL DDL statements"),
|
||||
(r"INSERT INTO\s+[`\"]\w+[`\"]", "SQL data dump"),
|
||||
# Archive signatures (in text responses)
|
||||
(r"PK\x03\x04", "ZIP archive"),
|
||||
# Tar
|
||||
(r"ustar\s", "TAR archive"),
|
||||
# Config backups
|
||||
(r"<\?xml.*<configuration>", "XML configuration backup"),
|
||||
(r"server\s*\{[^}]*listen\s+\d+", "Nginx config backup"),
|
||||
(r"<VirtualHost\s+", "Apache config backup"),
|
||||
]
|
||||
self.backup_extensions = [
|
||||
".bak", ".backup", ".old", ".orig", ".save",
|
||||
".swp", ".swo", ".tmp", ".temp", ".copy",
|
||||
"~", ".sql", ".tar.gz", ".zip", ".dump",
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for backup file content in response"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check file signatures
|
||||
for pattern, description in self.file_signatures:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Backup file exposure: {description} detected"
|
||||
|
||||
# Check if backup extension in request returned content
|
||||
for ext in self.backup_extensions:
|
||||
if ext in payload and response_status == 200 and len(response_body) > 100:
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
content_type = headers_lower.get("content-type", "").lower()
|
||||
# Non-HTML responses to backup file requests are suspicious
|
||||
if "text/html" not in content_type:
|
||||
return True, 0.75, f"Backup file exposure: {ext} file served ({content_type})"
|
||||
# HTML response but contains code-like content
|
||||
if re.search(r"(?:function |class |import |require\(|define\()", response_body):
|
||||
return True, 0.7, f"Backup file exposure: {ext} file contains source code"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class VersionDisclosureTester(BaseTester):
|
||||
"""Tester for Version Disclosure mapping to known CVEs"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "version_disclosure"
|
||||
# Software versions with known critical CVEs
|
||||
self.vulnerable_versions = {
|
||||
r"Apache/2\.4\.49\b": "CVE-2021-41773 (path traversal)",
|
||||
r"Apache/2\.4\.50\b": "CVE-2021-42013 (path traversal bypass)",
|
||||
r"nginx/1\.(?:[0-9]|1[0-7])\.\d+": "Potential nginx < 1.18 vulnerabilities",
|
||||
r"PHP/(?:5\.\d|7\.[0-3])\.\d+": "Outdated PHP version with known CVEs",
|
||||
r"OpenSSL/1\.0\.\d": "OpenSSL 1.0.x - multiple known CVEs",
|
||||
r"jQuery/(?:1\.\d|2\.\d|3\.[0-4])\.\d+": "jQuery < 3.5 - XSS via htmlPrefilter",
|
||||
r"WordPress/(?:[1-4]\.\d|5\.[0-7])": "Outdated WordPress version",
|
||||
r"Drupal/(?:[1-7]\.\d|8\.[0-5])": "Outdated Drupal version",
|
||||
r"Rails/(?:[1-4]\.\d|5\.[01])": "Outdated Rails version",
|
||||
r"Spring Framework/(?:[1-4]\.\d|5\.[0-2])": "Outdated Spring version",
|
||||
r"Express/(?:[1-3]\.\d|4\.(?:1[0-6]))": "Outdated Express.js version",
|
||||
r"Django/(?:1\.\d|2\.[01]|3\.0)": "Outdated Django version",
|
||||
r"Log4j.(?:2\.(?:0|1[0-4])\.\d)": "CVE-2021-44228 (Log4Shell)",
|
||||
r"Tomcat/(?:[1-8]\.\d|9\.[0-3]\d\.\d)": "Potentially outdated Tomcat",
|
||||
r"IIS/(?:[1-9]\.0|10\.0)": "IIS version disclosure",
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for version strings mapping to known CVEs"""
|
||||
# Combine headers and body for scanning
|
||||
headers_str = "\n".join(f"{k}: {v}" for k, v in response_headers.items())
|
||||
full_text = headers_str + "\n" + response_body
|
||||
|
||||
findings = []
|
||||
|
||||
for pattern, cve_info in self.vulnerable_versions.items():
|
||||
match = re.search(pattern, full_text, re.IGNORECASE)
|
||||
if match:
|
||||
version_str = match.group(0)
|
||||
findings.append(f"{version_str} - {cve_info}")
|
||||
|
||||
if findings:
|
||||
# Known CVEs are high confidence
|
||||
has_cve = any("CVE-" in f for f in findings)
|
||||
confidence = 0.9 if has_cve else 0.7
|
||||
return True, confidence, f"Version disclosure: {'; '.join(findings[:3])}"
|
||||
|
||||
# Generic version disclosure in Server header
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
server = headers_lower.get("server", "")
|
||||
if re.search(r"/\d+\.\d+", server):
|
||||
return True, 0.5, f"Version disclosure in Server header: {server}"
|
||||
|
||||
return False, 0.0, None
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
NeuroSploit v3 - File Access Vulnerability Testers
|
||||
|
||||
Testers for LFI, RFI, Path Traversal, XXE, File Upload
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class LFITester(BaseTester):
|
||||
"""Tester for Local File Inclusion"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "lfi"
|
||||
self.file_signatures = {
|
||||
# Linux files
|
||||
r"root:.*:0:0:": "/etc/passwd",
|
||||
r"\[boot loader\]": "Windows boot.ini",
|
||||
r"\[operating systems\]": "Windows boot.ini",
|
||||
r"# /etc/hosts": "/etc/hosts",
|
||||
r"localhost": "/etc/hosts",
|
||||
r"\[global\]": "Samba config",
|
||||
r"include.*php": "PHP config",
|
||||
# Windows files
|
||||
r"\[extensions\]": "Windows win.ini",
|
||||
r"for 16-bit app support": "Windows system.ini",
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for LFI indicators"""
|
||||
for pattern, file_name in self.file_signatures.items():
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.95, f"LFI confirmed: {file_name} content detected"
|
||||
|
||||
# Check for path in error messages
|
||||
path_patterns = [
|
||||
r"failed to open stream.*No such file",
|
||||
r"include\(.*\): failed to open stream",
|
||||
r"Warning.*file_get_contents",
|
||||
r"fopen\(.*\): failed"
|
||||
]
|
||||
for pattern in path_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.6, "LFI indicator: File operation error with path"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class RFITester(BaseTester):
|
||||
"""Tester for Remote File Inclusion"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "rfi"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for RFI indicators"""
|
||||
# Check if our remote content was included
|
||||
if "neurosploit_rfi_test" in response_body:
|
||||
return True, 0.95, "RFI confirmed: Remote content executed"
|
||||
|
||||
# Check for URL-related errors
|
||||
rfi_errors = [
|
||||
r"failed to open stream: HTTP request failed",
|
||||
r"allow_url_include",
|
||||
r"URL file-access is disabled"
|
||||
]
|
||||
for pattern in rfi_errors:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.5, f"RFI indicator: {pattern}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class PathTraversalTester(BaseTester):
|
||||
"""Tester for Path Traversal"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "path_traversal"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for path traversal indicators"""
|
||||
# Same as LFI essentially
|
||||
file_contents = [
|
||||
r"root:.*:0:0:",
|
||||
r"\[boot loader\]",
|
||||
r"# /etc/",
|
||||
r"127\.0\.0\.1.*localhost"
|
||||
]
|
||||
for pattern in file_contents:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.9, f"Path traversal successful: File content detected"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class XXETester(BaseTester):
|
||||
"""Tester for XML External Entity Injection"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "xxe"
|
||||
|
||||
def build_request(self, endpoint, payload: str) -> Tuple[str, Dict, Dict, Optional[str]]:
|
||||
"""Build XXE request with XML body"""
|
||||
headers = {
|
||||
"User-Agent": "NeuroSploit/3.0",
|
||||
"Content-Type": "application/xml"
|
||||
}
|
||||
return endpoint.url, {}, headers, payload
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for XXE indicators"""
|
||||
# File content indicators
|
||||
xxe_indicators = [
|
||||
r"root:.*:0:0:",
|
||||
r"\[boot loader\]",
|
||||
r"# /etc/hosts",
|
||||
r"<!ENTITY",
|
||||
]
|
||||
for pattern in xxe_indicators:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.9, f"XXE confirmed: External entity processed"
|
||||
|
||||
# Error indicators
|
||||
xxe_errors = [
|
||||
r"XML parsing error",
|
||||
r"External entity",
|
||||
r"DOCTYPE.*ENTITY",
|
||||
r"libxml"
|
||||
]
|
||||
for pattern in xxe_errors:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.6, f"XXE indicator: XML error with entity reference"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class FileUploadTester(BaseTester):
|
||||
"""Tester for Arbitrary File Upload"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "file_upload"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for file upload vulnerability indicators"""
|
||||
# Check for successful upload indicators
|
||||
if response_status in [200, 201]:
|
||||
success_indicators = [
|
||||
"uploaded successfully",
|
||||
"file saved",
|
||||
"upload complete",
|
||||
'"success"\\s*:\\s*true',
|
||||
'"status"\\s*:\\s*"ok"'
|
||||
]
|
||||
for pattern in success_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.7, "File uploaded successfully - verify execution"
|
||||
|
||||
# Check for path disclosure in response
|
||||
if re.search(r'["\']?(?:path|url|file)["\']?\s*:\s*["\'][^"\']+\.(php|asp|jsp)', response_body, re.IGNORECASE):
|
||||
return True, 0.8, "Executable file path returned - possible RCE"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ArbitraryFileReadTester(BaseTester):
|
||||
"""Tester for Arbitrary File Read vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "arbitrary_file_read"
|
||||
self.sensitive_file_patterns = {
|
||||
# /etc/passwd format
|
||||
r"root:.*:0:0:": "/etc/passwd",
|
||||
r"daemon:.*:\d+:\d+:": "/etc/passwd",
|
||||
r"nobody:.*:\d+:\d+:": "/etc/passwd",
|
||||
# .env file patterns
|
||||
r"(?:DB_PASSWORD|DATABASE_URL|SECRET_KEY|API_KEY|APP_SECRET)\s*=": ".env file",
|
||||
r"(?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY)\s*=": ".env file (AWS credentials)",
|
||||
# SSH key headers
|
||||
r"-----BEGIN (?:RSA |DSA |EC |OPENSSH )?PRIVATE KEY-----": "SSH/TLS private key",
|
||||
r"-----BEGIN CERTIFICATE-----": "TLS certificate",
|
||||
# Shadow file
|
||||
r"root:\$[0-9a-z]+\$": "/etc/shadow",
|
||||
# Config files
|
||||
r"<\?php.*\$db": "PHP config with DB credentials",
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for sensitive file contents in response"""
|
||||
for pattern, file_desc in self.sensitive_file_patterns.items():
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.95, f"Arbitrary file read confirmed: {file_desc} content detected"
|
||||
|
||||
# Check for base64-encoded sensitive content
|
||||
base64_pattern = re.findall(r'[A-Za-z0-9+/]{40,}={0,2}', response_body)
|
||||
for b64_match in base64_pattern[:5]: # Check first 5 matches
|
||||
try:
|
||||
import base64
|
||||
decoded = base64.b64decode(b64_match).decode('utf-8', errors='ignore')
|
||||
if re.search(r"root:.*:0:0:", decoded) or re.search(r"-----BEGIN.*PRIVATE KEY-----", decoded):
|
||||
return True, 0.9, "Arbitrary file read: Base64-encoded sensitive file content"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ArbitraryFileDeleteTester(BaseTester):
|
||||
"""Tester for Arbitrary File Delete vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "arbitrary_file_delete"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for successful file deletion indicators"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check for explicit deletion success messages
|
||||
delete_success_patterns = [
|
||||
r"file\s+(?:has been\s+)?(?:deleted|removed)\s+successfully",
|
||||
r"(?:deleted|removed)\s+successfully",
|
||||
r'"success"\s*:\s*true.*(?:delet|remov)',
|
||||
r'"status"\s*:\s*"(?:deleted|removed)"',
|
||||
r'"message"\s*:\s*".*(?:deleted|removed).*"',
|
||||
]
|
||||
for pattern in delete_success_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.85, "Arbitrary file delete: Deletion success confirmed in response"
|
||||
|
||||
# Check for 200/204 on DELETE request with traversal path
|
||||
traversal_indicators = ["../", "..\\", "%2e%2e", "..%2f", "..%5c"]
|
||||
has_traversal = any(t in payload.lower() for t in traversal_indicators)
|
||||
|
||||
if has_traversal:
|
||||
if response_status == 204:
|
||||
return True, 0.8, "Arbitrary file delete: 204 No Content after path traversal delete"
|
||||
if response_status == 200:
|
||||
return True, 0.7, "Arbitrary file delete: 200 OK after path traversal delete request"
|
||||
|
||||
# Check for file-not-found on subsequent access (context-based)
|
||||
if context.get("follow_up_status") == 404:
|
||||
return True, 0.85, "Arbitrary file delete: File not found after deletion request"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ZipSlipTester(BaseTester):
|
||||
"""Tester for Zip Slip (path traversal in archive extraction)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "zip_slip"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for path traversal in archive extraction"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Check for traversal path acceptance in response
|
||||
traversal_patterns = [
|
||||
r"\.\./\.\./\.\./",
|
||||
r"\.\.\\\.\.\\\.\.\\",
|
||||
r"%2e%2e%2f",
|
||||
r"%2e%2e/",
|
||||
]
|
||||
for pattern in traversal_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
# Traversal path echoed in response
|
||||
if response_status in [200, 201]:
|
||||
return True, 0.8, "Zip Slip: Path traversal sequence accepted in archive extraction"
|
||||
|
||||
# Check for successful extraction with traversal payload
|
||||
if response_status in [200, 201]:
|
||||
extraction_success = [
|
||||
r"extract(?:ed|ion)\s+(?:successful|complete)",
|
||||
r"(?:file|archive)\s+(?:uploaded|processed)\s+successfully",
|
||||
r'"extracted"\s*:\s*true',
|
||||
r'"files"\s*:\s*\[.*\.\./.*\]',
|
||||
]
|
||||
for pattern in extraction_success:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
if any(t in payload for t in ["../", "..\\", "%2e%2e"]):
|
||||
return True, 0.85, "Zip Slip: Archive with traversal paths extracted successfully"
|
||||
|
||||
# Check for file written outside expected directory
|
||||
overwrite_indicators = [
|
||||
r"(?:overwr(?:ote|itten)|replaced)\s+.*(?:/etc/|/var/|/tmp/|C:\\)",
|
||||
r"(?:created|wrote)\s+.*\.\./",
|
||||
]
|
||||
for pattern in overwrite_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, "Zip Slip: File written outside extraction directory"
|
||||
|
||||
return False, 0.0, None
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
"""
|
||||
NeuroSploit v3 - Infrastructure Vulnerability Testers
|
||||
|
||||
Testers for Security Headers, SSL/TLS, HTTP Methods
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class SecurityHeadersTester(BaseTester):
|
||||
"""Tester for Missing Security Headers"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "security_headers"
|
||||
self.required_headers = {
|
||||
"Strict-Transport-Security": "HSTS not configured",
|
||||
"X-Content-Type-Options": "X-Content-Type-Options not set",
|
||||
"X-Frame-Options": "X-Frame-Options not set",
|
||||
"Content-Security-Policy": "CSP not configured",
|
||||
"X-XSS-Protection": "X-XSS-Protection not set (legacy but still useful)",
|
||||
"Referrer-Policy": "Referrer-Policy not configured"
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for missing security headers"""
|
||||
missing = []
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
|
||||
for header, message in self.required_headers.items():
|
||||
if header.lower() not in headers_lower:
|
||||
missing.append(message)
|
||||
|
||||
# Check for weak CSP
|
||||
csp = headers_lower.get("content-security-policy", "")
|
||||
if csp:
|
||||
weak_csp = []
|
||||
if "unsafe-inline" in csp:
|
||||
weak_csp.append("unsafe-inline")
|
||||
if "unsafe-eval" in csp:
|
||||
weak_csp.append("unsafe-eval")
|
||||
if "*" in csp:
|
||||
weak_csp.append("wildcard sources")
|
||||
if weak_csp:
|
||||
missing.append(f"Weak CSP: {', '.join(weak_csp)}")
|
||||
|
||||
if missing:
|
||||
confidence = min(0.3 + len(missing) * 0.1, 0.8)
|
||||
return True, confidence, f"Missing/weak headers: {'; '.join(missing[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SSLTester(BaseTester):
|
||||
"""Tester for SSL/TLS Issues"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "ssl_issues"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for SSL/TLS issues"""
|
||||
issues = []
|
||||
|
||||
# Check HSTS
|
||||
hsts = response_headers.get("Strict-Transport-Security", "")
|
||||
if not hsts:
|
||||
issues.append("HSTS not enabled")
|
||||
else:
|
||||
# Check HSTS max-age
|
||||
max_age_match = re.search(r'max-age=(\d+)', hsts)
|
||||
if max_age_match:
|
||||
max_age = int(max_age_match.group(1))
|
||||
if max_age < 31536000: # Less than 1 year
|
||||
issues.append(f"HSTS max-age too short: {max_age}s")
|
||||
|
||||
if "includeSubDomains" not in hsts:
|
||||
issues.append("HSTS missing includeSubDomains")
|
||||
|
||||
# Check for HTTP resources on HTTPS page
|
||||
if "https://" in (context.get("url", "") or ""):
|
||||
http_resources = re.findall(r'(?:src|href)=["\']http://[^"\']+', response_body)
|
||||
if http_resources:
|
||||
issues.append(f"Mixed content: {len(http_resources)} HTTP resources")
|
||||
|
||||
if issues:
|
||||
return True, 0.6, f"SSL/TLS issues: {'; '.join(issues)}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HTTPMethodsTester(BaseTester):
|
||||
"""Tester for Dangerous HTTP Methods"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "http_methods"
|
||||
self.dangerous_methods = ["TRACE", "TRACK", "PUT", "DELETE", "CONNECT"]
|
||||
|
||||
def build_request(self, endpoint, payload: str) -> Tuple[str, Dict, Dict, Optional[str]]:
|
||||
"""Build OPTIONS request to check allowed methods"""
|
||||
headers = {
|
||||
"User-Agent": "NeuroSploit/3.0"
|
||||
}
|
||||
# payload is the HTTP method to test
|
||||
return endpoint.url, {}, headers, None
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for dangerous HTTP methods"""
|
||||
# Check Allow header from OPTIONS response
|
||||
allow = response_headers.get("Allow", "")
|
||||
dangerous_found = []
|
||||
|
||||
for method in self.dangerous_methods:
|
||||
if method in allow.upper():
|
||||
dangerous_found.append(method)
|
||||
|
||||
# TRACE method enables XST attacks
|
||||
if "TRACE" in dangerous_found or "TRACK" in dangerous_found:
|
||||
return True, 0.7, f"Dangerous methods enabled: {', '.join(dangerous_found)} (XST risk)"
|
||||
|
||||
if dangerous_found:
|
||||
return True, 0.5, f"Potentially dangerous methods: {', '.join(dangerous_found)}"
|
||||
|
||||
# Check if specific method test succeeded
|
||||
if payload.upper() in self.dangerous_methods:
|
||||
if response_status == 200:
|
||||
return True, 0.6, f"{payload} method accepted"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class DirectoryListingTester(BaseTester):
|
||||
"""Tester for Directory Listing exposure"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "directory_listing"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for directory listing patterns"""
|
||||
if response_status == 200:
|
||||
listing_patterns = [
|
||||
r"<title>Index of\s*/",
|
||||
r"Index of\s*/",
|
||||
r"<h1>Index of",
|
||||
r"Directory listing for\s*/",
|
||||
r"<title>Directory listing",
|
||||
r'<a\s+href="\.\./">\.\./</a>',
|
||||
r"Parent Directory</a>",
|
||||
r'\[DIR\]',
|
||||
r'\[TXT\]',
|
||||
r"<pre>.*<a href=",
|
||||
]
|
||||
|
||||
for pattern in listing_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, "Directory listing: Server directory contents exposed"
|
||||
|
||||
# Check for Apache/Nginx-specific listing
|
||||
if re.search(r'<address>Apache/[\d.]+ .* Server at', response_body, re.IGNORECASE):
|
||||
if "Index of" in response_body:
|
||||
return True, 0.95, "Directory listing: Apache directory listing enabled"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class DebugModeTester(BaseTester):
|
||||
"""Tester for Debug Mode/Page exposure"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "debug_mode"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for debug pages, stack traces with source paths"""
|
||||
# Werkzeug/Flask debugger
|
||||
werkzeug_patterns = [
|
||||
r"Werkzeug\s+Debugger",
|
||||
r"werkzeug\.debug",
|
||||
r"<div class=\"debugger\">",
|
||||
r"The debugger caught an exception",
|
||||
r"__debugger__",
|
||||
]
|
||||
for pattern in werkzeug_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.95, "Debug mode: Werkzeug interactive debugger exposed (RCE risk)"
|
||||
|
||||
# Laravel debug
|
||||
laravel_patterns = [
|
||||
r"Whoops!.*Laravel",
|
||||
r"Ignition\s",
|
||||
r"vendor/laravel",
|
||||
r"Laravel.*Exception",
|
||||
r"app/Http/Controllers",
|
||||
]
|
||||
for pattern in laravel_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, "Debug mode: Laravel debug page exposed"
|
||||
|
||||
# Django debug
|
||||
django_patterns = [
|
||||
r"You\'re seeing this error because you have <code>DEBUG = True</code>",
|
||||
r"Django Version:",
|
||||
r"Traceback.*django",
|
||||
r"INSTALLED_APPS",
|
||||
r"settings\.py",
|
||||
]
|
||||
for pattern in django_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, "Debug mode: Django debug page exposed"
|
||||
|
||||
# Generic stack traces with source paths
|
||||
stack_trace_patterns = [
|
||||
r"(?:File|at)\s+[\"']?(?:/[a-z]+/|C:\\)[^\s\"']+\.(?:py|php|rb|js|java|go)\b",
|
||||
r"Traceback \(most recent call last\)",
|
||||
r"Stack trace:.*(?:\.php|\.py|\.rb|\.java)",
|
||||
r"(?:Error|Exception)\s+in\s+(?:/[a-z]+/|C:\\)[^\s]+:\d+",
|
||||
]
|
||||
for pattern in stack_trace_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
|
||||
return True, 0.8, "Debug mode: Stack trace with source file paths exposed"
|
||||
|
||||
# ASP.NET detailed errors
|
||||
if re.search(r"Server Error in '/' Application", response_body):
|
||||
return True, 0.85, "Debug mode: ASP.NET detailed error page exposed"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ExposedAdminPanelTester(BaseTester):
|
||||
"""Tester for Publicly Accessible Admin Panel"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "exposed_admin_panel"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for admin login pages accessible publicly"""
|
||||
if response_status == 200:
|
||||
admin_panel_patterns = [
|
||||
(r"(?:admin|administrator)\s+(?:login|panel|dashboard|console)", "admin login page"),
|
||||
(r"<title>[^<]*(?:admin|dashboard|control\s*panel|cms)[^<]*</title>", "admin title"),
|
||||
(r"wp-login\.php", "WordPress login"),
|
||||
(r"wp-admin", "WordPress admin"),
|
||||
(r"/admin/login", "admin login endpoint"),
|
||||
(r"phpmyadmin", "phpMyAdmin"),
|
||||
(r"adminer\.php", "Adminer"),
|
||||
(r"cPanel", "cPanel"),
|
||||
(r"Webmin", "Webmin"),
|
||||
(r"Plesk", "Plesk"),
|
||||
(r"joomla.*administrator", "Joomla admin"),
|
||||
(r"drupal.*user/login", "Drupal admin login"),
|
||||
]
|
||||
|
||||
for pattern, panel_name in admin_panel_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.75, f"Exposed admin panel: {panel_name} accessible publicly"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ExposedApiDocsTester(BaseTester):
|
||||
"""Tester for Publicly Accessible API Documentation"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "exposed_api_docs"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for Swagger/OpenAPI documentation pages"""
|
||||
if response_status == 200:
|
||||
api_docs_patterns = [
|
||||
(r"swagger-ui", "Swagger UI"),
|
||||
(r'"swagger"\s*:\s*"[0-9.]+"', "Swagger spec"),
|
||||
(r'"openapi"\s*:\s*"[0-9.]+"', "OpenAPI spec"),
|
||||
(r"swagger-ui-bundle\.js", "Swagger UI bundle"),
|
||||
(r"<title>Swagger UI</title>", "Swagger UI page"),
|
||||
(r"redoc", "ReDoc API docs"),
|
||||
(r"api-docs", "API documentation"),
|
||||
(r"graphiql", "GraphiQL interface"),
|
||||
(r"GraphQL Playground", "GraphQL Playground"),
|
||||
(r'"paths"\s*:\s*\{', "OpenAPI paths object"),
|
||||
(r'"info"\s*:\s*\{.*"title"\s*:', "OpenAPI info object"),
|
||||
]
|
||||
|
||||
for pattern, doc_type in api_docs_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.8, f"Exposed API docs: {doc_type} publicly accessible"
|
||||
|
||||
# Check content type for JSON API specs
|
||||
content_type = response_headers.get("Content-Type", "")
|
||||
if "json" in content_type.lower():
|
||||
if re.search(r'"paths"\s*:\s*\{.*"(?:get|post|put|delete)"', response_body, re.DOTALL):
|
||||
return True, 0.85, "Exposed API docs: OpenAPI/Swagger JSON specification exposed"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class InsecureCookieFlagsTester(BaseTester):
|
||||
"""Tester for Missing Secure/HttpOnly/SameSite Cookie Flags"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "insecure_cookie_flags"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check Set-Cookie headers for missing security flags"""
|
||||
# Collect all Set-Cookie headers
|
||||
set_cookie_values = []
|
||||
for key, value in response_headers.items():
|
||||
if key.lower() == "set-cookie":
|
||||
if isinstance(value, list):
|
||||
set_cookie_values.extend(value)
|
||||
else:
|
||||
set_cookie_values.append(value)
|
||||
|
||||
if not set_cookie_values:
|
||||
return False, 0.0, None
|
||||
|
||||
issues = []
|
||||
for cookie in set_cookie_values:
|
||||
cookie_lower = cookie.lower()
|
||||
cookie_name = cookie.split("=")[0].strip()
|
||||
|
||||
# Session cookies are more critical
|
||||
is_session = any(
|
||||
s in cookie_name.lower()
|
||||
for s in ["session", "sess", "sid", "token", "auth", "jwt", "csrf"]
|
||||
)
|
||||
|
||||
missing_flags = []
|
||||
if "secure" not in cookie_lower:
|
||||
missing_flags.append("Secure")
|
||||
if "httponly" not in cookie_lower:
|
||||
missing_flags.append("HttpOnly")
|
||||
if "samesite" not in cookie_lower:
|
||||
missing_flags.append("SameSite")
|
||||
|
||||
if missing_flags:
|
||||
severity = "session cookie" if is_session else "cookie"
|
||||
issues.append(f"{cookie_name} ({severity}): missing {', '.join(missing_flags)}")
|
||||
|
||||
if issues:
|
||||
confidence = 0.8 if any("session cookie" in i for i in issues) else 0.6
|
||||
return True, confidence, f"Insecure cookie flags: {'; '.join(issues[:3])}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HttpSmugglingTester(BaseTester):
|
||||
"""Tester for HTTP Request Smuggling (CL/TE discrepancy)"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "http_smuggling"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for CL/TE discrepancy indicators"""
|
||||
# Check for request smuggling indicators
|
||||
smuggling_indicators = [
|
||||
# Different response than expected
|
||||
(r"400 Bad Request.*(?:Content-Length|Transfer-Encoding)", "CL/TE parsing error"),
|
||||
(r"(?:invalid|malformed)\s+(?:chunk|transfer.encoding)", "chunked encoding error"),
|
||||
]
|
||||
for pattern, desc in smuggling_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
|
||||
return True, 0.7, f"HTTP smuggling indicator: {desc}"
|
||||
|
||||
# Check for dual Transfer-Encoding handling
|
||||
te_header = response_headers.get("Transfer-Encoding", "")
|
||||
cl_header = response_headers.get("Content-Length", "")
|
||||
|
||||
if te_header and cl_header:
|
||||
return True, 0.75, "HTTP smuggling: Both Transfer-Encoding and Content-Length in response"
|
||||
|
||||
# Check for timeout-based detection (context)
|
||||
if context.get("response_time_ms", 0) > 10000:
|
||||
if "transfer-encoding" in payload.lower() or "content-length" in payload.lower():
|
||||
return True, 0.6, "HTTP smuggling: Abnormal response delay with CL/TE payload"
|
||||
|
||||
# Check for response desync indicators
|
||||
if response_status == 0 or context.get("connection_reset"):
|
||||
return True, 0.65, "HTTP smuggling: Connection reset/timeout with smuggling payload"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CachePoisoningTester(BaseTester):
|
||||
"""Tester for Web Cache Poisoning"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "cache_poisoning"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for cached response with injected unkeyed input"""
|
||||
if response_status == 200:
|
||||
# Check for cache headers indicating response was cached
|
||||
cache_indicators = {
|
||||
"X-Cache": response_headers.get("X-Cache", ""),
|
||||
"CF-Cache-Status": response_headers.get("CF-Cache-Status", ""),
|
||||
"Age": response_headers.get("Age", ""),
|
||||
"X-Varnish": response_headers.get("X-Varnish", ""),
|
||||
}
|
||||
|
||||
is_cached = False
|
||||
for header, value in cache_indicators.items():
|
||||
if value:
|
||||
if any(hit in value.upper() for hit in ["HIT", "STALE"]):
|
||||
is_cached = True
|
||||
break
|
||||
if header == "Age" and int(value or 0) > 0:
|
||||
is_cached = True
|
||||
break
|
||||
|
||||
# Check if our unkeyed input is reflected in the cached response
|
||||
if is_cached or response_headers.get("Cache-Control", ""):
|
||||
# Common unkeyed headers that might be reflected
|
||||
unkeyed_indicators = [
|
||||
r"X-Forwarded-Host", r"X-Forwarded-Scheme",
|
||||
r"X-Original-URL", r"X-Rewrite-URL",
|
||||
]
|
||||
|
||||
if payload in response_body:
|
||||
if is_cached:
|
||||
return True, 0.9, "Cache poisoning: Injected unkeyed input reflected in cached response"
|
||||
else:
|
||||
return True, 0.7, "Cache poisoning: Unkeyed input reflected - verify caching"
|
||||
|
||||
# Check for Vary header missing expected values
|
||||
vary = response_headers.get("Vary", "")
|
||||
cache_control = response_headers.get("Cache-Control", "")
|
||||
if "no-store" not in cache_control and "private" not in cache_control:
|
||||
if payload in response_body:
|
||||
return True, 0.6, "Cache poisoning potential: Input reflected in cacheable response"
|
||||
|
||||
return False, 0.0, None
|
||||
+443
@@ -0,0 +1,443 @@
|
||||
"""
|
||||
NeuroSploit v3 - Injection Vulnerability Testers
|
||||
|
||||
Testers for XSS, SQL Injection, Command Injection, SSTI, etc.
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class XSSReflectedTester(BaseTester):
|
||||
"""Tester for Reflected XSS vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "xss_reflected"
|
||||
# Unique markers for detection
|
||||
self.markers = [
|
||||
"neurosploit",
|
||||
"xsstest123",
|
||||
"alert(1)"
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check if XSS payload is reflected in response"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check if payload is reflected
|
||||
if payload in response_body:
|
||||
# Use context-aware analysis to determine execution position
|
||||
from backend.core.xss_context_analyzer import analyze_xss_execution_context
|
||||
ctx = analyze_xss_execution_context(response_body, payload)
|
||||
if ctx["executable"]:
|
||||
return True, 0.95, f"XSS payload in auto-executing context: {ctx['detail']}"
|
||||
elif ctx["interactive"]:
|
||||
return True, 0.85, f"XSS payload in interactive context: {ctx['detail']}"
|
||||
# Reflected but not in executable position
|
||||
return True, 0.5, f"XSS payload reflected but {ctx['context']}: {ctx['detail']}"
|
||||
|
||||
# Check for partial reflection (script tags, etc.)
|
||||
for marker in self.markers:
|
||||
if marker in payload and marker in response_body:
|
||||
return True, 0.6, f"XSS marker '{marker}' found in response"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class XSSStoredTester(BaseTester):
|
||||
"""Tester for Stored XSS vulnerabilities.
|
||||
|
||||
Supports two-phase verification:
|
||||
Phase 1: analyze_response() - Check if submission succeeded (data stored)
|
||||
Phase 2: analyze_display_response() - Check if payload executes on display page
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "xss_stored"
|
||||
self.storage_indicators = [
|
||||
"success", "created", "saved", "posted", "submitted",
|
||||
"thank", "comment", "added", "published", "updated",
|
||||
"your comment", "your post", "your message",
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Phase 1: Check if payload was likely stored.
|
||||
|
||||
Returns confidence 0.3-0.5 for storage-only confirmation.
|
||||
Full confirmation requires Phase 2 (analyze_display_response).
|
||||
"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Redirect after POST is a common form submission pattern
|
||||
if response_status in [301, 302, 303]:
|
||||
return True, 0.4, "Redirect after submission - payload likely stored"
|
||||
|
||||
if response_status in [200, 201]:
|
||||
# Check for storage success indicators
|
||||
for indicator in self.storage_indicators:
|
||||
if indicator in body_lower:
|
||||
return True, 0.4, f"Storage indicator found: '{indicator}'"
|
||||
|
||||
# Check if payload is reflected in the same response (immediate display)
|
||||
if payload in response_body:
|
||||
dangerous = [
|
||||
"<script", "onerror=", "onload=", "onclick=", "onfocus=",
|
||||
"onmouseover=", "<svg", "<img", "<iframe", "javascript:"
|
||||
]
|
||||
payload_lower = payload.lower()
|
||||
for ctx in dangerous:
|
||||
if ctx in payload_lower:
|
||||
return True, 0.8, f"Stored XSS: payload reflected in dangerous context ({ctx})"
|
||||
return True, 0.6, "Payload reflected in submission response"
|
||||
|
||||
# POST returning 200 often means submission accepted
|
||||
if response_status == 200 and context.get("method") == "POST":
|
||||
return True, 0.3, "POST returned 200 - submission possibly accepted"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
def analyze_display_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Phase 2: Verify payload executes on the display page.
|
||||
|
||||
Called after navigating to the page where stored content is rendered.
|
||||
"""
|
||||
if response_status >= 400:
|
||||
return False, 0.0, None
|
||||
|
||||
# Check if payload exists unescaped in display page
|
||||
if payload in response_body:
|
||||
# Use context-aware analysis to determine execution position
|
||||
from backend.core.xss_context_analyzer import analyze_xss_execution_context
|
||||
ctx = analyze_xss_execution_context(response_body, payload)
|
||||
if ctx["executable"]:
|
||||
return True, 0.95, f"Stored XSS confirmed: {ctx['detail']}"
|
||||
elif ctx["interactive"]:
|
||||
return True, 0.90, f"Stored XSS (interaction required): {ctx['detail']}"
|
||||
# Payload present but not executable
|
||||
return True, 0.5, f"Stored payload on display page but {ctx['context']}: {ctx['detail']}"
|
||||
|
||||
# Check for core execution markers even if full payload is modified
|
||||
core_markers = [
|
||||
"alert(1)", "alert(document.domain)", "onerror=alert",
|
||||
"onload=alert", "onfocus=alert", "ontoggle=alert",
|
||||
]
|
||||
body_lower = response_body.lower()
|
||||
for marker in core_markers:
|
||||
if marker in payload.lower() and marker in body_lower:
|
||||
return True, 0.85, f"Stored XSS: execution marker '{marker}' found on display page"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class XSSDomTester(BaseTester):
|
||||
"""Tester for DOM-based XSS vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "xss_dom"
|
||||
self.dom_sinks = [
|
||||
"innerHTML", "outerHTML", "document.write", "document.writeln",
|
||||
"eval(", "setTimeout(", "setInterval(", "location.href",
|
||||
"location.assign", "location.replace"
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for DOM XSS indicators"""
|
||||
# Look for dangerous DOM sinks in JavaScript
|
||||
for sink in self.dom_sinks:
|
||||
pattern = rf'{sink}[^;]*(?:location|document\.URL|document\.referrer|window\.name)'
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.7, f"Potential DOM XSS sink found: {sink}"
|
||||
|
||||
# Check if URL parameters are used in JavaScript
|
||||
if re.search(r'(?:location\.search|location\.hash|document\.URL)', response_body):
|
||||
if any(sink in response_body for sink in self.dom_sinks):
|
||||
return True, 0.6, "URL input flows to DOM sink"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SQLiErrorTester(BaseTester):
|
||||
"""Tester for Error-based SQL Injection"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "sqli_error"
|
||||
self.error_patterns = [
|
||||
# MySQL
|
||||
r"SQL syntax.*MySQL", r"Warning.*mysql_", r"MySQLSyntaxErrorException",
|
||||
r"valid MySQL result", r"check the manual that corresponds to your MySQL",
|
||||
# PostgreSQL
|
||||
r"PostgreSQL.*ERROR", r"Warning.*pg_", r"valid PostgreSQL result",
|
||||
r"Npgsql\.", r"PG::SyntaxError",
|
||||
# SQL Server
|
||||
r"Driver.*SQL[\-\_\ ]*Server", r"OLE DB.*SQL Server",
|
||||
r"(\W|\A)SQL Server.*Driver", r"Warning.*mssql_",
|
||||
r"(\W|\A)SQL Server.*[0-9a-fA-F]{8}", r"Microsoft SQL Native Client error",
|
||||
# Oracle
|
||||
r"\bORA-[0-9][0-9][0-9][0-9]", r"Oracle error", r"Oracle.*Driver",
|
||||
r"Warning.*oci_", r"Warning.*ora_",
|
||||
# SQLite
|
||||
r"SQLite/JDBCDriver", r"SQLite\.Exception", r"System\.Data\.SQLite\.SQLiteException",
|
||||
r"Warning.*sqlite_", r"Warning.*SQLite3::",
|
||||
# Generic
|
||||
r"SQL syntax.*", r"syntax error.*SQL", r"unclosed quotation mark",
|
||||
r"quoted string not properly terminated"
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for SQL error messages in response"""
|
||||
for pattern in self.error_patterns:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
return True, 0.9, f"SQL error detected: {match.group(0)[:100]}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SQLiUnionTester(BaseTester):
|
||||
"""Tester for Union-based SQL Injection"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "sqli_union"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for union-based SQLi indicators"""
|
||||
# Look for injected data markers
|
||||
union_markers = ["neurosploit", "uniontest", "concat(", "version()"]
|
||||
|
||||
for marker in union_markers:
|
||||
if marker in payload.lower() and marker in response_body.lower():
|
||||
return True, 0.8, f"Union injection marker '{marker}' found in response"
|
||||
|
||||
# Check for database version strings
|
||||
version_patterns = [
|
||||
r"MySQL.*\d+\.\d+", r"PostgreSQL.*\d+\.\d+",
|
||||
r"Microsoft SQL Server.*\d+", r"Oracle.*\d+",
|
||||
r"\d+\.\d+\.\d+-MariaDB"
|
||||
]
|
||||
for pattern in version_patterns:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.7, "Database version string found - possible union SQLi"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SQLiBlindTester(BaseTester):
|
||||
"""Tester for Boolean-based Blind SQL Injection"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "sqli_blind"
|
||||
self.baseline_length = None
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for boolean-based blind SQLi"""
|
||||
# This requires comparing responses - simplified check
|
||||
response_length = len(response_body)
|
||||
|
||||
# Check for significant difference in response
|
||||
if "baseline_length" in context:
|
||||
diff = abs(response_length - context["baseline_length"])
|
||||
if diff > 100: # Significant difference
|
||||
return True, 0.6, f"Response length differs by {diff} bytes - possible blind SQLi"
|
||||
|
||||
# Check for conditional responses
|
||||
if "1=1" in payload and response_status == 200:
|
||||
return True, 0.5, "True condition returned 200 - possible blind SQLi"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SQLiTimeTester(BaseTester):
|
||||
"""Tester for Time-based Blind SQL Injection"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "sqli_time"
|
||||
|
||||
def check_timeout_vulnerability(self, vuln_type: str) -> bool:
|
||||
"""Time-based SQLi is indicated by timeout"""
|
||||
return True
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Time-based detection relies on timeout"""
|
||||
# Response time analysis would be done in the engine
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CommandInjectionTester(BaseTester):
|
||||
"""Tester for OS Command Injection"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "command_injection"
|
||||
self.command_outputs = [
|
||||
# Linux
|
||||
r"root:.*:0:0:", r"bin:.*:1:1:", # /etc/passwd
|
||||
r"uid=\d+.*gid=\d+", # id command
|
||||
r"Linux.*\d+\.\d+\.\d+", # uname
|
||||
r"total \d+.*drwx", # ls -la
|
||||
# Windows
|
||||
r"Volume Serial Number",
|
||||
r"Directory of [A-Z]:\\",
|
||||
r"Windows.*\[Version",
|
||||
r"Microsoft Windows"
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for command execution evidence"""
|
||||
for pattern in self.command_outputs:
|
||||
match = re.search(pattern, response_body, re.IGNORECASE)
|
||||
if match:
|
||||
return True, 0.95, f"Command output detected: {match.group(0)[:100]}"
|
||||
|
||||
# Check for our marker
|
||||
if "neurosploit" in payload and "neurosploit" in response_body:
|
||||
return True, 0.8, "Command injection marker echoed"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class SSTITester(BaseTester):
|
||||
"""Tester for Server-Side Template Injection"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "ssti"
|
||||
# Mathematical expressions that prove code execution
|
||||
self.math_results = {
|
||||
"{{7*7}}": "49",
|
||||
"${7*7}": "49",
|
||||
"#{7*7}": "49",
|
||||
"<%= 7*7 %>": "49",
|
||||
"{{7*'7'}}": "7777777", # Jinja2
|
||||
}
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for SSTI indicators"""
|
||||
# Check mathematical results
|
||||
for expr, result in self.math_results.items():
|
||||
if expr in payload and result in response_body:
|
||||
return True, 0.95, f"SSTI confirmed: {expr} = {result}"
|
||||
|
||||
# Check for template errors
|
||||
template_errors = [
|
||||
r"TemplateSyntaxError", r"Jinja2", r"Twig_Error",
|
||||
r"freemarker\.core\.", r"velocity\.exception",
|
||||
r"org\.apache\.velocity", r"Smarty"
|
||||
]
|
||||
for pattern in template_errors:
|
||||
if re.search(pattern, response_body):
|
||||
return True, 0.7, f"Template engine error: {pattern}"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class NoSQLInjectionTester(BaseTester):
|
||||
"""Tester for NoSQL Injection"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "nosql_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for NoSQL injection indicators"""
|
||||
# MongoDB errors
|
||||
nosql_errors = [
|
||||
r"MongoError", r"MongoDB", r"bson",
|
||||
r"\$where", r"\$gt", r"\$ne",
|
||||
r"SyntaxError.*JSON"
|
||||
]
|
||||
|
||||
for pattern in nosql_errors:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.7, f"NoSQL error indicator: {pattern}"
|
||||
|
||||
# Check for authentication bypass
|
||||
if "$ne" in payload or "$gt" in payload:
|
||||
if response_status == 200 and "success" in response_body.lower():
|
||||
return True, 0.6, "Possible NoSQL authentication bypass"
|
||||
|
||||
return False, 0.0, None
|
||||
Executable
+457
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
NeuroSploit v3 - Logic and Protocol Vulnerability Testers
|
||||
|
||||
Testers for race conditions, business logic, rate limiting, parameter pollution,
|
||||
type juggling, timing attacks, host header injection, HTTP smuggling, cache poisoning.
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class RaceConditionTester(BaseTester):
|
||||
"""Tester for Race Condition vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "race_condition"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for duplicate operation success indicators"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Multiple success responses from concurrent requests
|
||||
if context.get("concurrent_successes", 0) > 1:
|
||||
return True, 0.85, f"Race condition: {context['concurrent_successes']} concurrent requests succeeded"
|
||||
|
||||
# Double-spend / duplicate operation indicators
|
||||
duplicate_indicators = [
|
||||
"already processed", "duplicate", "already exists",
|
||||
"already applied", "already redeemed",
|
||||
]
|
||||
# If we got a success despite expected duplicate check
|
||||
if response_status in [200, 201]:
|
||||
success_words = ["success", "created", "processed", "applied", "completed", "confirmed"]
|
||||
if any(w in body_lower for w in success_words):
|
||||
if context.get("request_count", 0) > 1:
|
||||
return True, 0.7, "Race condition: operation succeeded multiple times"
|
||||
|
||||
# Check for resource count discrepancy
|
||||
if "balance" in body_lower or "quantity" in body_lower or "count" in body_lower:
|
||||
numbers = re.findall(r'"(?:balance|quantity|count|amount)"\s*:\s*(-?\d+\.?\d*)', response_body)
|
||||
if numbers and context.get("expected_value") is not None:
|
||||
try:
|
||||
actual = float(numbers[0])
|
||||
expected = float(context["expected_value"])
|
||||
if actual != expected:
|
||||
return True, 0.75, f"Race condition: value mismatch (expected {expected}, got {actual})"
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class BusinessLogicTester(BaseTester):
|
||||
"""Tester for Business Logic vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "business_logic"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for business logic bypass indicators"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Negative value acceptance
|
||||
if re.search(r"-\d+", payload):
|
||||
if response_status == 200:
|
||||
if any(w in body_lower for w in ["success", "accepted", "processed", "approved"]):
|
||||
return True, 0.8, "Business logic: negative value accepted"
|
||||
# Check for negative pricing
|
||||
if re.search(r'"(?:total|price|amount)"\s*:\s*-\d+', response_body):
|
||||
return True, 0.9, "Business logic: negative price/amount in response"
|
||||
|
||||
# Zero-value bypass
|
||||
if payload.strip() in ["0", "0.00", "0.0"]:
|
||||
if response_status == 200 and "success" in body_lower:
|
||||
return True, 0.75, "Business logic: zero value accepted for transaction"
|
||||
|
||||
# Workflow step skip
|
||||
if context.get("skipped_step"):
|
||||
if response_status == 200:
|
||||
return True, 0.7, f"Business logic: step '{context['skipped_step']}' was skippable"
|
||||
|
||||
# Discount/coupon abuse
|
||||
if "coupon" in payload.lower() or "discount" in payload.lower():
|
||||
if re.search(r'"discount"\s*:\s*(?:100|[1-9]\d{2,})', response_body):
|
||||
return True, 0.8, "Business logic: excessive discount applied"
|
||||
|
||||
# Role/privilege escalation via parameter
|
||||
if any(w in payload.lower() for w in ["admin", "role=admin", "is_admin=true", "privilege"]):
|
||||
if response_status == 200 and "admin" in body_lower:
|
||||
return True, 0.6, "Business logic: privilege escalation parameter accepted"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class RateLimitBypassTester(BaseTester):
|
||||
"""Tester for Rate Limit Bypass vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "rate_limit_bypass"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for continued success after many requests (bypass)"""
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
|
||||
# After many requests, still getting 200
|
||||
request_count = context.get("request_count", 0)
|
||||
if request_count > 50 and response_status == 200:
|
||||
# Check rate limit headers
|
||||
remaining = headers_lower.get("x-ratelimit-remaining",
|
||||
headers_lower.get("x-rate-limit-remaining",
|
||||
headers_lower.get("ratelimit-remaining")))
|
||||
if remaining is not None:
|
||||
try:
|
||||
if int(remaining) > 0:
|
||||
return True, 0.6, f"Rate limit not enforced after {request_count} requests (remaining: {remaining})"
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
# No rate limit headers at all
|
||||
return True, 0.7, f"No rate limiting detected after {request_count} requests"
|
||||
|
||||
# Rate limit bypass via header manipulation
|
||||
bypass_headers = ["x-forwarded-for", "x-real-ip", "x-originating-ip", "x-client-ip"]
|
||||
if any(h in payload.lower() for h in bypass_headers):
|
||||
if response_status == 200 and context.get("was_rate_limited"):
|
||||
return True, 0.85, "Rate limit bypassed via IP spoofing header"
|
||||
|
||||
# Check if 429 was expected but got 200
|
||||
if context.get("expected_429") and response_status == 200:
|
||||
return True, 0.8, "Expected 429 (rate limited) but received 200"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class ParameterPollutionTester(BaseTester):
|
||||
"""Tester for HTTP Parameter Pollution vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "parameter_pollution"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for different behavior with duplicate parameters"""
|
||||
# Compare response with baseline (single param)
|
||||
if "baseline_body" in context and "baseline_status" in context:
|
||||
baseline_len = len(context["baseline_body"])
|
||||
current_len = len(response_body)
|
||||
diff = abs(current_len - baseline_len)
|
||||
|
||||
# Significant response difference
|
||||
if diff > 200:
|
||||
return True, 0.7, f"Parameter pollution: response differs by {diff} bytes from baseline"
|
||||
|
||||
# Status code difference
|
||||
if response_status != context["baseline_status"]:
|
||||
return True, 0.75, f"Parameter pollution: status changed from {context['baseline_status']} to {response_status}"
|
||||
|
||||
# Check if attacker-controlled value was used
|
||||
if "neurosploit" in payload and "neurosploit" in response_body:
|
||||
if context.get("original_value") and context["original_value"] not in response_body:
|
||||
return True, 0.8, "Parameter pollution: attacker value used instead of original"
|
||||
|
||||
# WAF bypass via duplicate params
|
||||
if context.get("waf_blocked_original") and response_status == 200:
|
||||
return True, 0.8, "Parameter pollution: WAF bypass - blocked payload succeeded with duplicate params"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class TypeJugglingTester(BaseTester):
|
||||
"""Tester for Type Juggling / Type Coercion vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "type_juggling"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for auth bypass with type coercion"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Type juggling payloads
|
||||
juggling_values = ["0", "true", "false", "null", "[]", "{}", "0e123", "0e999"]
|
||||
|
||||
if payload.strip() in juggling_values or payload.strip().startswith("0e"):
|
||||
# Auth bypass
|
||||
if response_status == 200:
|
||||
auth_success = [
|
||||
"authenticated", "logged in", "welcome", "dashboard",
|
||||
"token", "session", "success",
|
||||
]
|
||||
for indicator in auth_success:
|
||||
if indicator in body_lower:
|
||||
return True, 0.8, f"Type juggling: auth bypass with '{payload.strip()}' - '{indicator}' in response"
|
||||
|
||||
# JWT/token accepted
|
||||
if "jwt" in body_lower or "bearer" in body_lower:
|
||||
if response_status == 200:
|
||||
return True, 0.7, f"Type juggling: token accepted with value '{payload.strip()}'"
|
||||
|
||||
# Magic hash comparison bypass (0e strings)
|
||||
if re.match(r"0e\d+", payload.strip()):
|
||||
if response_status == 200 and any(w in body_lower for w in ["match", "equal", "valid", "correct"]):
|
||||
return True, 0.85, f"Type juggling: magic hash bypass with '{payload.strip()}'"
|
||||
|
||||
# Array vs string comparison
|
||||
if payload.strip() in ["[]", "Array"]:
|
||||
if response_status == 200 and "success" in body_lower:
|
||||
return True, 0.7, "Type juggling: array comparison bypass"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class TimingAttackTester(BaseTester):
|
||||
"""Tester for Timing Attack vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "timing_attack"
|
||||
|
||||
def check_timeout_vulnerability(self, vuln_type: str) -> bool:
|
||||
"""Timing attacks are detected via response time differences"""
|
||||
return True
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check response time differences for timing side channels"""
|
||||
# Primary detection is via response timing (handled by engine)
|
||||
response_time = context.get("response_time_ms", 0)
|
||||
baseline_time = context.get("baseline_time_ms", 0)
|
||||
|
||||
if response_time > 0 and baseline_time > 0:
|
||||
diff = response_time - baseline_time
|
||||
# Significant timing difference (> 100ms)
|
||||
if diff > 100:
|
||||
return True, 0.7, f"Timing attack: {diff}ms difference (baseline: {baseline_time}ms, actual: {response_time}ms)"
|
||||
# Very significant (> 500ms)
|
||||
if diff > 500:
|
||||
return True, 0.9, f"Timing attack: {diff}ms difference strongly indicates character-by-character comparison"
|
||||
|
||||
# Check for timing via multiple measurements
|
||||
if "timing_samples" in context:
|
||||
samples = context["timing_samples"]
|
||||
if len(samples) >= 2:
|
||||
max_diff = max(samples) - min(samples)
|
||||
if max_diff > 200:
|
||||
return True, 0.65, f"Timing attack: {max_diff}ms variance across samples"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HostHeaderInjectionTester(BaseTester):
|
||||
"""Tester for Host Header Injection vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "host_header_injection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for Host value reflected in response links/URLs"""
|
||||
body_lower = response_body.lower()
|
||||
|
||||
# Injected host appearing in response
|
||||
evil_hosts = ["evil.com", "attacker.com", "neurosploit.test", "canary.host"]
|
||||
for host in evil_hosts:
|
||||
if host in payload.lower() and host in body_lower:
|
||||
# High confidence if in URL context
|
||||
url_contexts = [
|
||||
rf'https?://{re.escape(host)}',
|
||||
rf'href\s*=\s*["\'][^"\']*{re.escape(host)}',
|
||||
rf'action\s*=\s*["\'][^"\']*{re.escape(host)}',
|
||||
rf'redirect.*{re.escape(host)}',
|
||||
]
|
||||
for pattern in url_contexts:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.9, f"Host header injection: '{host}' reflected in URL context"
|
||||
|
||||
return True, 0.7, f"Host header injection: '{host}' reflected in response body"
|
||||
|
||||
# X-Forwarded-Host injection
|
||||
if "x-forwarded-host" in payload.lower():
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
location = headers_lower.get("location", "")
|
||||
if any(h in location.lower() for h in evil_hosts):
|
||||
return True, 0.9, "Host header injection: X-Forwarded-Host reflected in redirect"
|
||||
|
||||
# Password reset link poisoning
|
||||
if context.get("is_password_reset") and response_status == 200:
|
||||
for host in evil_hosts:
|
||||
if host in body_lower:
|
||||
return True, 0.95, f"Host header injection in password reset: link points to '{host}'"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class HttpSmugglingTester(BaseTester):
|
||||
"""Tester for HTTP Request Smuggling vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "http_smuggling"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for HTTP smuggling indicators"""
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
|
||||
# Response splitting - two HTTP responses in one
|
||||
if re.search(r"HTTP/\d\.\d\s+\d{3}", response_body):
|
||||
return True, 0.85, "HTTP smuggling: embedded HTTP response in body (response splitting)"
|
||||
|
||||
# Conflicting Content-Length and Transfer-Encoding
|
||||
has_cl = "content-length" in headers_lower
|
||||
has_te = "transfer-encoding" in headers_lower
|
||||
if has_cl and has_te:
|
||||
return True, 0.7, "HTTP smuggling: both Content-Length and Transfer-Encoding present"
|
||||
|
||||
# CL.TE or TE.CL desync indicators
|
||||
if context.get("desync_detected"):
|
||||
return True, 0.9, "HTTP smuggling: request desync confirmed"
|
||||
|
||||
# Unexpected response to smuggled second request
|
||||
if "smuggle_marker" in context:
|
||||
marker = context["smuggle_marker"]
|
||||
if marker in response_body:
|
||||
return True, 0.85, f"HTTP smuggling: smuggled request marker '{marker}' in response"
|
||||
|
||||
# Different status than expected (frontend vs backend disagreement)
|
||||
if context.get("expected_status") and response_status != context["expected_status"]:
|
||||
if response_status in [400, 403] and context["expected_status"] == 200:
|
||||
return True, 0.5, f"HTTP smuggling: status mismatch (expected {context['expected_status']}, got {response_status})"
|
||||
|
||||
# Timeout on second request (queued/poisoned)
|
||||
if context.get("second_request_timeout"):
|
||||
return True, 0.7, "HTTP smuggling: second request timed out (possible queue poisoning)"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
def check_timeout_vulnerability(self, vuln_type: str) -> bool:
|
||||
"""Smuggling can cause timeouts on subsequent requests"""
|
||||
return True
|
||||
|
||||
|
||||
class CachePoisoningTester(BaseTester):
|
||||
"""Tester for Web Cache Poisoning vulnerabilities"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "cache_poisoning"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for cache poisoning - injected content served from cache"""
|
||||
headers_lower = {k.lower(): v for k, v in response_headers.items()}
|
||||
|
||||
# Check for cache hit with injected content
|
||||
cache_hit = False
|
||||
cache_headers = ["x-cache", "cf-cache-status", "x-varnish", "x-drupal-cache",
|
||||
"x-proxy-cache", "x-cdn-cache"]
|
||||
for header in cache_headers:
|
||||
value = headers_lower.get(header, "").lower()
|
||||
if "hit" in value:
|
||||
cache_hit = True
|
||||
break
|
||||
|
||||
# Age header indicates cached response
|
||||
age = headers_lower.get("age")
|
||||
if age and age != "0":
|
||||
cache_hit = True
|
||||
|
||||
if cache_hit:
|
||||
# Check if our injected content is in the cached response
|
||||
injection_markers = ["neurosploit", "xss", "evil.com", "attacker"]
|
||||
for marker in injection_markers:
|
||||
if marker in payload.lower() and marker in response_body.lower():
|
||||
return True, 0.9, f"Cache poisoning: injected content '{marker}' served from cache"
|
||||
|
||||
# Unkeyed header reflected in response (potential cache poison vector)
|
||||
unkeyed_headers = ["x-forwarded-host", "x-forwarded-scheme", "x-original-url",
|
||||
"x-rewrite-url", "x-forwarded-prefix"]
|
||||
for header in unkeyed_headers:
|
||||
if header in payload.lower():
|
||||
# Check if the value appears in response
|
||||
for marker in ["evil.com", "neurosploit", "attacker"]:
|
||||
if marker in payload.lower() and marker in response_body.lower():
|
||||
cache_status = "cached" if cache_hit else "uncached"
|
||||
confidence = 0.85 if cache_hit else 0.5
|
||||
return True, confidence, f"Cache poisoning: unkeyed header '{header}' reflected ({cache_status})"
|
||||
|
||||
# Cache deception check
|
||||
if context.get("is_cache_deception_test"):
|
||||
if cache_hit and ("token" in response_body.lower() or "session" in response_body.lower()):
|
||||
return True, 0.8, "Cache deception: sensitive data cached via path confusion"
|
||||
|
||||
return False, 0.0, None
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
NeuroSploit v3 - Request Forgery Vulnerability Testers
|
||||
|
||||
Testers for SSRF and CSRF
|
||||
"""
|
||||
import re
|
||||
from typing import Tuple, Dict, Optional
|
||||
from backend.core.vuln_engine.testers.base_tester import BaseTester
|
||||
|
||||
|
||||
class SSRFTester(BaseTester):
|
||||
"""Tester for Server-Side Request Forgery"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "ssrf"
|
||||
# Cloud metadata indicators
|
||||
self.cloud_indicators = [
|
||||
r"ami-[a-z0-9]+", # AWS AMI ID
|
||||
r"instance-id",
|
||||
r"iam/security-credentials",
|
||||
r"compute/v1", # GCP
|
||||
r"metadata/instance",
|
||||
r"169\.254\.169\.254"
|
||||
]
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for SSRF indicators"""
|
||||
# Check for cloud metadata
|
||||
for pattern in self.cloud_indicators:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.95, f"SSRF to cloud metadata: {pattern}"
|
||||
|
||||
# Check for internal service indicators
|
||||
internal_indicators = [
|
||||
r"localhost",
|
||||
r"127\.0\.0\.1",
|
||||
r"192\.168\.\d+\.\d+",
|
||||
r"10\.\d+\.\d+\.\d+",
|
||||
r"172\.(?:1[6-9]|2\d|3[01])\.\d+\.\d+"
|
||||
]
|
||||
for pattern in internal_indicators:
|
||||
if pattern in payload and re.search(pattern, response_body):
|
||||
return True, 0.8, f"SSRF accessing internal resource: {pattern}"
|
||||
|
||||
# Check for different response when internal URL requested
|
||||
if response_status == 200 and len(response_body) > 100:
|
||||
if "169.254" in payload or "localhost" in payload or "127.0.0.1" in payload:
|
||||
return True, 0.6, "Response received from internal URL - possible SSRF"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class CSRFTester(BaseTester):
|
||||
"""Tester for Cross-Site Request Forgery"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "csrf"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for CSRF vulnerability indicators"""
|
||||
# Check for missing CSRF protections
|
||||
csrf_protections = [
|
||||
r'name=["\']?csrf',
|
||||
r'name=["\']?_token',
|
||||
r'name=["\']?authenticity_token',
|
||||
r'X-CSRF-TOKEN',
|
||||
r'X-XSRF-TOKEN'
|
||||
]
|
||||
|
||||
has_protection = any(
|
||||
re.search(pattern, response_body, re.IGNORECASE)
|
||||
for pattern in csrf_protections
|
||||
)
|
||||
|
||||
# Check SameSite cookie
|
||||
has_samesite = "samesite" in str(response_headers).lower()
|
||||
|
||||
# State-changing request without protection
|
||||
if not has_protection and not has_samesite:
|
||||
if response_status in [200, 302]:
|
||||
return True, 0.7, "No CSRF token found in form - possible CSRF"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class GraphqlIntrospectionTester(BaseTester):
|
||||
"""Tester for GraphQL Introspection exposure"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "graphql_introspection"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for __schema data in response indicating introspection is enabled"""
|
||||
if response_status == 200:
|
||||
# Direct __schema indicators
|
||||
schema_patterns = [
|
||||
r'"__schema"\s*:\s*\{',
|
||||
r'"__type"\s*:\s*\{',
|
||||
r'"queryType"\s*:\s*\{',
|
||||
r'"mutationType"\s*:\s*\{',
|
||||
r'"subscriptionType"\s*:',
|
||||
r'"types"\s*:\s*\[.*"name"\s*:\s*"__',
|
||||
r'"directives"\s*:\s*\[.*"name"\s*:',
|
||||
]
|
||||
|
||||
for pattern in schema_patterns:
|
||||
if re.search(pattern, response_body, re.IGNORECASE | re.DOTALL):
|
||||
return True, 0.9, "GraphQL introspection: Full schema exposed via __schema query"
|
||||
|
||||
# Check for type listings
|
||||
type_listing_patterns = [
|
||||
r'"kind"\s*:\s*"(?:OBJECT|SCALAR|ENUM|INPUT_OBJECT|INTERFACE|UNION)"',
|
||||
r'"fields"\s*:\s*\[.*"name"\s*:.*"type"\s*:',
|
||||
r'"inputFields"\s*:\s*\[',
|
||||
r'"enumValues"\s*:\s*\[',
|
||||
]
|
||||
|
||||
type_match_count = sum(
|
||||
1 for p in type_listing_patterns
|
||||
if re.search(p, response_body, re.IGNORECASE | re.DOTALL)
|
||||
)
|
||||
if type_match_count >= 2:
|
||||
return True, 0.85, "GraphQL introspection: Type schema data exposed"
|
||||
|
||||
# Check for field suggestions (partial introspection)
|
||||
if re.search(r'"(?:message|errors)".*"Did you mean.*"', response_body):
|
||||
return True, 0.6, "GraphQL introspection: Field suggestions leak schema information"
|
||||
|
||||
return False, 0.0, None
|
||||
|
||||
|
||||
class GraphqlDosTester(BaseTester):
|
||||
"""Tester for GraphQL Denial of Service via deeply nested queries"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "graphql_dos"
|
||||
|
||||
def analyze_response(
|
||||
self,
|
||||
payload: str,
|
||||
response_status: int,
|
||||
response_headers: Dict,
|
||||
response_body: str,
|
||||
context: Dict
|
||||
) -> Tuple[bool, float, Optional[str]]:
|
||||
"""Check for slow response with deeply nested queries indicating DoS potential"""
|
||||
# Check response time for nested query DoS
|
||||
response_time_ms = context.get("response_time_ms", 0)
|
||||
|
||||
# Nested query indicators in payload
|
||||
nesting_indicators = [
|
||||
payload.count("{") > 5,
|
||||
"__typename" in payload and payload.count("__typename") > 3,
|
||||
"fragment" in payload.lower() and "..." in payload,
|
||||
]
|
||||
is_nested_payload = any(nesting_indicators)
|
||||
|
||||
if is_nested_payload:
|
||||
# Very slow response indicates resource exhaustion
|
||||
if response_time_ms > 10000: # > 10 seconds
|
||||
return True, 0.85, f"GraphQL DoS: Deeply nested query caused {response_time_ms}ms response time"
|
||||
|
||||
if response_time_ms > 5000: # > 5 seconds
|
||||
return True, 0.7, f"GraphQL DoS: Nested query caused slow response ({response_time_ms}ms)"
|
||||
|
||||
# Check for timeout/error responses
|
||||
if response_status in [408, 504, 502]:
|
||||
if is_nested_payload:
|
||||
return True, 0.8, "GraphQL DoS: Server timeout on deeply nested query"
|
||||
|
||||
# Check for resource limit errors (server has some protection but confirms issue)
|
||||
resource_errors = [
|
||||
r"query.*(?:too complex|too deep|exceeds.*(?:depth|complexity))",
|
||||
r"max.*(?:depth|complexity).*(?:exceeded|reached)",
|
||||
r"(?:depth|complexity)\s+limit",
|
||||
r"query.*(?:cost|weight).*exceeded",
|
||||
]
|
||||
for pattern in resource_errors:
|
||||
if re.search(pattern, response_body, re.IGNORECASE):
|
||||
return True, 0.5, "GraphQL DoS: Depth/complexity limits exist but confirm nested queries are processed"
|
||||
|
||||
# Server error on complex query
|
||||
if response_status == 500 and is_nested_payload:
|
||||
return True, 0.65, "GraphQL DoS: Server error on deeply nested query"
|
||||
|
||||
return False, 0.0, None
|
||||
Reference in New Issue
Block a user