SentinelScan-WSS / backend /scanners /graphql_scanner.py
larxius's picture
Deploy SentinelScan WSS to HF Spaces
d543fc1 verified
Raw
History Blame Contribute Delete
27.1 kB
"""
graphql_scanner.py — GraphQL Security Scanner
==============================================
Advanced GraphQL vulnerability detection module.
This scanner:
1. Identifies GraphQL endpoints
2. Performs introspection to discover schema
3. Tests for GraphQL-specific vulnerabilities
4. Detects information disclosure via introspection
5. Tests for query depth limiting and DoS vulnerabilities
6. Checks for authorization bypass in GraphQL queries
7. Multi-stage detection: probe endpoint, then confirm vulnerabilities
"""
import urllib.request, urllib.error, urllib.parse, re, json
from scanners.base_scanner import BaseScanner
from utils.fuzzer_engine import ContextAwareFuzzer
GRAPHQL_ENDPOINTS = [
"/graphql", "/api/graphql", "/graphiql", "/api/graphiql",
"/graphql.php", "/graphql/api", "/v1/graphql", "/v2/graphql",
"/gql", "/api/gql", "/query", "/api/query",
"/console/graphql", "/graphql/console", "/playground",
"/api/v1/graphql", "/api/v2/graphql",
]
GRAPHQL_INDICATORS = [
r"graphql", r"GraphQL", r"query\s+\w+", r"mutation\s+\w+",
r"subscription\s+\w+", r"__schema", r"__type", r"__typename",
]
INTROSPECTION_QUERY = """
{
__schema {
queryType { name fields { name type { name kind } } }
mutationType { name fields { name type { name kind } } }
subscriptionType { name fields { name type { name kind } } }
types { name kind description fields { name type { name kind } } }
}
}
"""
BATCHING_QUERY = """
[
{ "query": "{ __typename }" },
{ "query": "{ __typename }" },
{ "query": "{ __typename }" },
{ "query": "{ __typename }" },
{ "query": "{ __typename }" }
]
"""
DEEP_NESTED_QUERY = """
{
__schema {
queryType { fields { name type { fields { name type { fields { name type { fields { name type { fields { name } } } } } } } } }
}
}
"""
ALIAS_QUERY = """
query {
a1: __typename
a2: __typename
a3: __typename
a4: __typename
a5: __typename
a6: __typename
a7: __typename
a8: __typename
a9: __typename
a10: __typename
}
"""
SQLI_GQL_QUERY = """
query {
__typename
search(query: "' OR '1'='1")
}
"""
TEST_QUERIES = {
"introspection": INTROSPECTION_QUERY,
"basic_query": "{ __typename }",
"nested_query": DEEP_NESTED_QUERY,
"mutation_test": "mutation { __typename }",
"batching": BATCHING_QUERY,
"alias_spam": ALIAS_QUERY,
"sqli_test": SQLI_GQL_QUERY,
}
ERROR_PATTERNS = [
r"GraphQL error", r"Cannot query field", r"Cannot return null",
r"Variable", r"Syntax Error", r"Parse error", r"Validation error",
]
SQLI_ERROR_PATTERNS = [
r"SQL syntax", r"mysql_fetch", r"ORA-[0-9]{5}", r"PostgreSQL",
r"SQLite", r"unclosed quotation mark", r"quoted string not properly terminated",
r"division by zero", r"syntax error at or near", r"Unclosed",
]
class GraphqlScanner(BaseScanner):
SCANNER_NAME = "GraphQL Security Scanner"
_SCANNER_KEY = "graphql"
def __init__(self, scan_id, target, domain, **kwargs):
super().__init__(scan_id, target, domain, **kwargs)
self._headers = {
"User-Agent": "LarShield/2.0 GraphQL Scanner",
"Content-Type": "application/json",
"Accept": "application/json",
}
if self.auth_headers:
self._headers.update(self.auth_headers)
self._tested_endpoints = 0
self._vulns_found = 0
self._fuzzer = ContextAwareFuzzer(self._gql_fuzzer_req)
def _gql_fuzzer_req(self, url, params, headers=None):
variables = {}
for key, val in params.items():
variables[key] = val
payload_dict = {"query": "query($vars: JSON!) { __typename }", "variables": variables}
encoded = json.dumps(payload_dict).encode("utf-8")
merged = {"Content-Type": "application/json"}
if headers:
merged.update(headers)
body, status = self._make_request(url, method="POST", data=encoded, headers=merged, timeout=8)
return body or "", status
def _make_gql_request(self, url, query, variables=None, timeout=8):
"""Send a GraphQL query and return the response."""
payload = {"query": query}
if variables:
payload["variables"] = variables
encoded = json.dumps(payload).encode("utf-8")
body, status = self._make_request(
url, method="POST", data=encoded,
headers=self._headers, timeout=timeout,
)
return body, status
def _detect_graphql_endpoint(self, url):
"""Check if a URL is a GraphQL endpoint."""
try:
body, status = self._make_request(url)
if body:
for indicator in GRAPHQL_INDICATORS:
if re.search(indicator, body, re.IGNORECASE):
return True, "GET"
body, status = self._make_gql_request(url, TEST_QUERIES["basic_query"])
if body:
try:
response_json = json.loads(body)
if "data" in response_json or "errors" in response_json:
return True, "POST"
except json.JSONDecodeError:
pass
for indicator in GRAPHQL_INDICATORS:
if re.search(indicator, body, re.IGNORECASE):
return True, "POST"
except Exception as e:
self.log("ERROR", f"[GraphQL] Error detecting endpoint {url}: {e}")
return False, None
def _test_introspection(self, url):
"""Test if introspection is enabled (information disclosure)."""
try:
body, status = self._make_gql_request(url, TEST_QUERIES["introspection"])
if not body:
return False
try:
response_json = json.loads(body)
if "data" in response_json:
data = response_json["data"]
if "__schema" in data or "__type" in data:
self._vulns_found += 1
self.log("CRITICAL", "[GraphQL] Introspection is ENABLED — Full schema disclosure!")
schema = data.get("__schema", {})
types = schema.get("types", [])
query_fields = schema.get("queryType", {}).get("fields", [])
mutation_fields = schema.get("mutationType", {}).get("fields", [])
self.log("INFO", f"[GraphQL] Schema contains {len(types)} types, "
f"{len(query_fields)} query fields, {len(mutation_fields)} mutation fields")
self.add_vuln(
title="GraphQL — Introspection Enabled",
severity="High",
category="Information Disclosure",
cvss_score=7.5,
description=(
f"GraphQL introspection is enabled at {url}.\n"
f"Discovered schema contains:\n"
f"- {len(types)} types\n"
f"- {len(query_fields)} query fields\n"
f"- {len(mutation_fields)} mutation fields\n\n"
f"Introspection exposes the entire GraphQL schema, including "
f"all queries, mutations, types, and their relationships. "
f"This information can be used by attackers to craft targeted attacks."
),
remediation=(
"1. DISABLE introspection in production:\n"
" - Apollo Server: introspection: false in config\n"
" - GraphQL Yoga: disableIntrospection: true\n"
" - Graphene: disable_introspection = True\n"
"2. Use environment-specific configuration\n"
"3. Implement proper authentication and authorization\n"
"4. Monitor for introspection queries in logs"
),
evidence=json.dumps({k: v for k, v in data.items() if k == "__schema"}, indent=2)[:500],
payload="Introspection query (see evidence)",
request_details=f"URL: {url}",
response_details=f"Schema: {len(types)} types, {len(query_fields)} queries, {len(mutation_fields)} mutations",
confidence="Confirmed",
)
return True
except json.JSONDecodeError:
pass
except Exception as e:
self.log("ERROR", f"[GraphQL] Error testing introspection: {e}")
return False
def _test_query_depth(self, url):
"""Test for query depth limiting (DoS prevention)."""
try:
body, status = self._make_gql_request(url, TEST_QUERIES["nested_query"], timeout=15)
if not body:
return False
try:
response_json = json.loads(body)
if "data" in response_json and response_json["data"]:
self.log("WARNING", "[GraphQL] Query depth limiting may not be configured")
self.add_vuln(
title="GraphQL — Missing Query Depth Limiting",
severity="Medium",
category="Denial of Service",
cvss_score=5.3,
description=(
f"GraphQL endpoint at {url} accepted deeply nested queries without depth limiting. "
"This can lead to denial of service attacks through complex nested queries."
),
remediation=(
"1. IMPLEMENT query depth limiting:\n"
" - Apollo Server: maxDepth or validation rules\n"
" - Set reasonable depth limits (e.g., 5-10 levels)\n"
"2. Implement query complexity analysis\n"
"3. Use query whitelisting for production"
),
evidence="Deeply nested query returned data successfully",
payload=TEST_QUERIES["nested_query"][:200],
request_details=f"URL: {url}",
response_details="Query executed without depth limit enforcement",
confidence="Confirmed",
)
return True
except json.JSONDecodeError:
pass
except Exception as e:
self.log("ERROR", f"[GraphQL] Error testing query depth: {e}")
return False
def _test_batching_attack(self, url):
"""Test for batching attack vulnerabilities (batched queries bypassing rate limits)."""
try:
body, status = self._make_gql_request(
url, TEST_QUERIES["batching"],
timeout=15,
)
if not body:
return False
try:
response_json = json.loads(body)
if isinstance(response_json, list) and len(response_json) >= 5:
self._vulns_found += 1
self.log("WARNING",
f"[GraphQL] Batching is supported — {len(response_json)} batched queries accepted at {url}")
self.add_vuln(
title="GraphQL — Batching Attack Possible",
severity="High",
category="Denial of Service",
cvss_score=7.5,
description=(
f"GraphQL endpoint at {url} supports query batching.\n"
f"Accepted {len(response_json)} batched queries in a single request.\n\n"
f"Impact: Attackers can bypass rate limiting by sending multiple "
f"operations in a single request, making brute-force attacks, "
f"enumeration, and DoS more effective."
),
remediation=(
"1. Implement rate limiting per operation, not per request.\n"
"2. Limit the number of operations allowed per batch request.\n"
"3. Use cost-based analysis to limit batch complexity.\n"
"4. Consider disabling batching if not required."
),
evidence=f"Batched query returned {len(response_json)} results",
payload="Batched query with 5 operations",
request_details=f"URL: {url}",
response_details=f"{len(response_json)} operations accepted",
confidence="Confirmed",
)
return True
except (json.JSONDecodeError, TypeError):
pass
except Exception as e:
self.log("ERROR", f"[GraphQL] Error testing batching attack: {e}")
return False
def _test_alias_dos(self, url):
"""Test for alias-based DoS attacks (many aliases consuming resources)."""
try:
body, status = self._make_gql_request(url, TEST_QUERIES["alias_spam"], timeout=15)
if not body:
return False
try:
response_json = json.loads(body)
if "data" in response_json:
data = response_json["data"]
alias_count = sum(1 for k in data if k.startswith("a"))
if alias_count >= 8:
self._vulns_found += 1
self.log("WARNING",
f"[GraphQL] Alias-based DoS possible at {url}{alias_count} aliases accepted")
self.add_vuln(
title="GraphQL — Alias-Based DoS Possible",
severity="Medium",
category="Denial of Service",
cvss_score=5.0,
description=(
f"GraphQL endpoint at {url} accepted {alias_count} aliases in a single query.\n"
f"Attackers can use many aliases to amplify resource consumption.\n\n"
f"Aliases allow the same field to be queried multiple times under different names, "
f"bypassing query depth limits while consuming significant server resources."
),
remediation=(
"1. Implement query complexity/cost analysis.\n"
"2. Limit the number of aliases allowed per query.\n"
"3. Use rate limiting based on field resolution cost.\n"
"4. Consider using persisted queries in production."
),
evidence=f"{alias_count} aliases accepted",
payload="Alias-based amplification query",
request_details=f"URL: {url}",
response_details=f"Accepted {alias_count} aliases",
confidence="Confirmed",
)
return True
except json.JSONDecodeError:
pass
except Exception as e:
self.log("ERROR", f"[GraphQL] Error testing alias DoS: {e}")
return False
def _test_graphql_sqli(self, url):
"""Test for SQL injection via GraphQL query parameters."""
try:
sqli_query = TEST_QUERIES["sqli_test"]
body, status = self._make_gql_request(url, sqli_query, timeout=15)
if not body:
return False
try:
response_json = json.loads(body)
# Check for SQL errors in response
errors = response_json.get("errors", [])
error_str = json.dumps(errors)
for pattern in SQLI_ERROR_PATTERNS:
if re.search(pattern, error_str, re.IGNORECASE):
self._vulns_found += 1
self.log("CRITICAL",
f"[GraphQL] SQL Injection via GraphQL at {url}! "
f"Pattern matched: {pattern}")
self.add_vuln(
title="GraphQL — SQL Injection via GraphQL Parameters",
severity="Critical",
category="Injection",
cvss_score=9.8,
description=(
f"A SQL injection vulnerability was detected via GraphQL at {url}.\n"
f"The GraphQL endpoint appears to pass user input directly to SQL queries.\n"
f"Pattern matched: {pattern}\n\n"
f"Impact: Attackers can extract, modify, or delete database contents, "
f"potentially leading to complete database compromise."
),
remediation=(
"1. Use parameterized queries / prepared statements.\n"
"2. Implement input validation and sanitization.\n"
"3. Use an ORM/ODM with proper injection protections.\n"
"4. Implement least-privilege database access.\n"
"5. Regularly audit and test for injection vulnerabilities."
),
evidence=f"SQL error pattern matched: {pattern}",
payload=SQLI_GQL_QUERY,
request_details=f"URL: {url}",
response_details=f"Error message: {error_str[:200]}",
confidence="Confirmed" if any(re.search(p, error_str, re.IGNORECASE) for p in SQLI_ERROR_PATTERNS) else "Medium",
)
return True
except json.JSONDecodeError:
pass
except Exception as e:
self.log("ERROR", f"[GraphQL] Error testing SQLi: {e}")
return False
def _test_graphql_params_fuzz(self, url):
try:
body, status = self._make_gql_request(url, TEST_QUERIES["introspection"])
if not body:
return
data = json.loads(body)
schema = data.get("data", {}).get("__schema", {})
query_type = schema.get("queryType", {})
fields = query_type.get("fields", []) if query_type else []
params = {}
for field in fields:
for arg in field.get("args", []):
params[arg["name"]] = "test"
mutation_type = schema.get("mutationType", {})
if mutation_type:
for field in mutation_type.get("fields", []):
for arg in field.get("args", []):
params[arg["name"]] = "test"
if not params:
params = {"query": "test", "id": "1", "filter": "test"}
self.log("INFO", f"[GraphQL] Context-aware fuzzing {len(params)} parameter(s) at {url}")
self._fuzzer.fuzz(url, params)
baseline_body, _ = self._make_gql_request(url, TEST_QUERIES["basic_query"])
baseline_length = len(baseline_body or "")
anomalies = self._fuzzer.anomalies(baseline_length)
for anom in anomalies:
self._vulns_found += 1
self.log("WARNING", f"[GraphQL] Fuzzer anomaly: {anom['param']} mutation={anom['mutation']} status={anom['status']}")
self.add_vuln(
title=f"GraphQL — Parameter Injection ({anom['param']})",
severity="High",
category="Injection",
cvss_score=7.5,
description=(
f"GraphQL parameter '{anom['param']}' (classified as '{anom['type']}') "
f"at {url} returned an anomalous response when mutated with "
f"'{anom['mutation']}' (value: {anom['value']}). "
f"HTTP {anom['status']}, response length {anom['length']}."
),
remediation="Validate and sanitize all GraphQL argument inputs. Use parameterized queries, input type enforcement, and proper output encoding.",
cwe_ids=["CWE-20"],
owasp_category="A03:2021 – Injection",
)
except Exception as e:
self.log("ERROR", f"[GraphQL] Error in param fuzzing: {e}")
def _test_authorization(self, url):
"""Test for authorization bypass in GraphQL."""
try:
test_query = """
{
__schema {
queryType {
fields { name args { name type { name } } }
}
}
}
"""
body, status = self._make_gql_request(url, test_query)
if not body:
return False
try:
response_json = json.loads(body)
if "data" in response_json:
data = response_json["data"]
if "__schema" in data:
fields = data["__schema"].get("queryType", {}).get("fields", [])
sensitive_fields = []
for field in fields:
field_name = field.get("name", "").lower()
if any(s in field_name for s in ["user", "password", "secret", "key", "token", "admin"]):
sensitive_fields.append(field.get("name"))
if sensitive_fields:
self.log("WARNING", f"[GraphQL] Discovered potentially sensitive fields: {sensitive_fields}")
self.add_vuln(
title="GraphQL — Sensitive Field Exposure",
severity="Medium",
category="Information Disclosure",
cvss_score=5.5,
description=(
f"GraphQL schema exposes potentially sensitive fields: {', '.join(sensitive_fields)}. "
"These fields may contain sensitive information that should be protected."
),
remediation=(
"1. Review and restrict field access based on user roles\n"
"2. Implement field-level authorization\n"
"3. Use custom resolvers with proper access checks\n"
"4. Audit schema for sensitive data exposure"
),
evidence=f"Sensitive fields: {', '.join(sensitive_fields)}",
payload="Schema field discovery query",
request_details=f"URL: {url}",
response_details=f"Fields found: {[f.get('name') for f in fields[:10]]}",
confidence="High",
)
return True
except json.JSONDecodeError:
pass
except Exception as e:
self.log("ERROR", f"[GraphQL] Error testing authorization: {e}")
return False
def _discover_graphql_endpoints(self):
"""Discover GraphQL endpoints."""
endpoints = []
base_url = self.target.rstrip("/")
for path in GRAPHQL_ENDPOINTS:
url = f"{base_url}{path}"
is_graphql, method = self._detect_graphql_endpoint(url)
if is_graphql:
endpoints.append((url, method))
self.log("INFO", f"[GraphQL] Discovered GraphQL endpoint: {url} ({method})")
is_graphql, method = self._detect_graphql_endpoint(self.target)
if is_graphql:
endpoints.append((self.target, method))
self.log("INFO", f"[GraphQL] Main URL is GraphQL endpoint: {self.target} ({method})")
return endpoints
def run(self):
self.log("INFO", f"[GraphQL] Starting GraphQL security scanning on {self.target}...")
try:
# Step 1: Discover GraphQL endpoints
self.log("INFO", "[GraphQL] Discovering GraphQL endpoints...")
endpoints = self._discover_graphql_endpoints()
self.log("INFO", f"[GraphQL] Found {len(endpoints)} GraphQL endpoint(s)")
if not endpoints:
self.log("INFO", "[GraphQL] No GraphQL endpoints detected")
return self.vulns
# Step 2: Test each endpoint
for url, method in endpoints:
self._tested_endpoints += 1
self.log("INFO", f"[GraphQL] Testing endpoint: {url}")
if self._test_introspection(url):
pass
self._test_query_depth(url)
self._test_authorization(url)
self._test_batching_attack(url)
self._test_alias_dos(url)
self._test_graphql_sqli(url)
self._test_graphql_params_fuzz(url)
except Exception as e:
self.log("ERROR", f"[GraphQL] Unexpected error during scan: {e}")
self.log("SUCCESS" if not self.vulns else "WARNING",
f"[GraphQL] Complete — {self._tested_endpoints} endpoint(s) tested | "
f"{self._vulns_found} vulnerability/vulnerabilities found")
return self.vulns