HTTP Debug Information Leak Scanner
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from kittysploit import *
from lib.protocols.http.http_client import Http_client
from lib.scanner.http.module_result import finalize_http_scanner_run, target_base_url
import re
import urllib.parse
class Module(Auxiliary, Http_client):
__info__ = {
'name': 'HTTP Debug Information Leak Scanner',
'description': 'Scans for debug information leaks in HTTP responses including stack traces, error messages, version information, and sensitive data exposure',
'author': 'KittySploit Team',
'tags': ['web', 'scanner', 'debug', 'information-disclosure'],
'references': [
'https://owasp.org/www-community/vulnerabilities/Information_exposure_through_debug_information',
'https://portswigger.net/web-security/information-disclosure'
],
'agent': {
'risk': 'active',
'effects': ['network_probe'],
'expected_requests': 2,
'reversible': True,
'approval_required': False,
'produces': ['tech_hints', 'risk_signals', 'endpoints', 'params'],
'cost': 1.0,
'noise': 0.5,
'value': 1.0,
'requires': {'min_endpoints': 0,
'min_params': 0,
'tech_hints_any': [],
'tech_hints_all': [],
'specializations_any': [],
'risk_signals_any': [],
'auth_session': False,
'capabilities_any': [],
'capabilities_all': [],
'confidence_min': {},
'confidence_min_any': {},
'endpoint_pattern_any': [],
'param_any': [],
'api_surface_ready': False},
'chain': {'produces_capabilities': [{'capability': 'endpoints', 'from_detail': ''}],
'consumes_capabilities': [],
'option_bindings': {},
'suggested_followups': []},
},
}
# Options du module
paths = OptString("", "Comma-separated list of additional paths to test (e.g., /debug,/test,/api)", required=False)
depth = OptInteger(2, "Maximum depth for path traversal (default: 2)", required=False)
timeout = OptInteger(10, "Request timeout in seconds", required=False)
strict_body_scan = OptBool(
False,
"Apply aggressive body patterns on HTML pages (may flag normal client-side JavaScript)",
required=False,
)
output_max_length = OptInteger(
0,
"Truncate displayed leak lines longer than N characters (0 = show full value)",
required=False,
)
# Known third-party / CMS client-side snippets — not server debug leaks.
_BENIGN_JS_MARKERS = re.compile(
r"gtag|googletagmanager|googleTranslate|revslider|jquery|wp-content|"
r"analytics|fbq\(|dataLayer|elementor|woocommerce|cloudflare|recaptcha",
re.IGNORECASE,
)
# CDN / reverse-proxy Server values — not actionable version disclosure.
_BENIGN_SERVER_VALUES = frozenset({
"cloudflare",
"cloudfront",
"akamaighost",
"nginx",
"openresty",
"varnish",
"awselb",
"gws",
"github.com",
"sucuri",
"fastly",
})
_SPA_META_CONTEXT = re.compile(
r'rel=["\']canonical["\']|property=["\']og:|name=["\']twitter:',
re.IGNORECASE,
)
_CONFIG_BODY_MARKERS = re.compile(
r"(?:^|[\s;])(?:DB_|APP_|AWS_|SECRET|PASSWORD|DATABASE_URL|MYSQL_)\s*[=:]",
re.IGNORECASE | re.MULTILINE,
)
# Patterns de détection pour les fuites d'informations
DEBUG_PATTERNS = {
'stack_trace': [
r'Stack\s+Trace',
r'Traceback\s+\(most\s+recent\s+call\s+last\)',
r'at\s+\w+\.\w+\([^)]+\)',
r'Exception\s+in\s+thread',
r'java\.lang\.',
r'python\.exceptions\.',
r'\.py",\s+line\s+\d+',
r'File\s+"[^"]+",\s+line\s+\d+',
r'Caused\s+by:',
r'RuntimeException',
],
'error_messages': [
r'Fatal\s+error',
r'Warning:',
r'Notice:',
r'Parse\s+error',
r'Syntax\s+error',
r'Internal\s+Server\s+Error',
r'Error\s+\d+',
r'Exception\s+occurred',
],
'file_paths': [
r'(?:/var/www|/home/[\w.-]+|/usr/(?:share|local)|/opt/)[^\s<>"\']{3,}',
r'[A-Z]:\\(?:Users|Windows|inetpub|Program Files|xampp)[^\s<>"\']{3,}',
],
'version_info': [
r'PHP\s+\d+\.\d+\.\d+',
r'Python\s+\d+\.\d+\.\d+',
r'Apache/\d+\.\d+\.\d+',
r'nginx/\d+\.\d+\.\d+',
r'Server:\s+[^\r\n]+',
r'X-Powered-By:\s+[^\r\n]+',
r'Framework:\s+[^\r\n]+',
r'Django/\d+\.\d+',
r'Flask/\d+\.\d+',
r'Express/\d+\.\d+',
r'Laravel\s+\d+\.\d+',
r'Rails\s+\d+\.\d+',
],
'database_info': [
r'mysql://[^\s]+',
r'postgresql://[^\s]+',
r'mongodb://[^\s]+',
r'jdbc:[^\s]+',
r'Database\s+connection\s+failed',
r'SQLSTATE\[[^\]]+\]',
r'Access\s+denied\s+for\s+user',
r'Unknown\s+database',
],
'api_keys': [
r'api[_-]?key["\s:=]+([A-Za-z0-9_-]{20,})',
r'apikey["\s:=]+([A-Za-z0-9_-]{20,})',
r'secret[_-]?key["\s:=]+([A-Za-z0-9_-]{20,})',
r'access[_-]?token["\s:=]+([A-Za-z0-9_-]{20,})',
r'aws[_-]?access[_-]?key[_-]?id["\s:=]+([A-Z0-9]{20})',
r'aws[_-]?secret[_-]?access[_-]?key["\s:=]+([A-Za-z0-9/+=]{40})',
],
'source_code': [
r'<\?php\s+[^\?]+',
r'(?m)^\s*def\s+\w+\([^)]*\):',
r'(?m)^\s*class\s+\w+\s+extends\s+',
r'(?m)Traceback\s+\(most\s+recent\s+call\s+last\)',
r'require(?:_once)?\s*\(\s*[\'"][^\'"]+[\'"]\s*\)',
r'include(?:_once)?\s*\(\s*[\'"][^\'"]+[\'"]\s*\)',
],
'environment_vars': [
r'(?:^|[\s;])(?:PATH|HOME|USER|JAVA_HOME|DOCUMENT_ROOT)\s*=\s*[^\r\n<]{3,}',
r'%[A-Z_][A-Z0-9_]*%',
],
'debug_mode': [
r'DEBUG\s*=\s*True',
r'debug\s*=\s*true',
r'debug\s*mode\s*enabled',
r'development\s+mode',
r'APP_DEBUG\s*=\s*true',
r'APP_ENV\s*=\s*local',
],
'config_files': [
r'config\.php',
r'\.env',
r'web\.config',
r'\.htaccess',
r'wp-config\.php',
r'settings\.py',
r'application\.properties',
],
}
# Chemins communs à tester pour les fuites de debug
COMMON_DEBUG_PATHS = [
'/debug',
'/test',
'/api/debug',
'/api/test',
'/admin/debug',
'/dev',
'/development',
'/error',
'/errors',
'/exception',
'/trace',
'/stacktrace',
'/phpinfo.php',
'/info.php',
'/test.php',
'/debug.php',
'/.env',
'/config.php',
'/web.config',
'/.git/config',
'/.svn/entries',
'/.DS_Store',
'/Thumbs.db',
'/robots.txt',
'/sitemap.xml',
'/.well-known/security.txt',
]
def check(self):
"""
Vérifie si la cible est accessible
"""
try:
response = self.http_request(method="GET", path="/")
if response and response.status_code in [200, 301, 302, 403, 404, 500]:
return True
return False
except Exception as e:
print_error(f"Error checking target: {str(e)}")
return False
def analyze_response(self, response, path="/"):
"""
Analyse une réponse HTTP pour détecter des fuites d'informations
Args:
response: Objet requests.Response
path: Chemin testé
Returns:
dict: Résultats de l'analyse avec les fuites détectées
"""
if not response:
return None
leaks = []
content = response.text if hasattr(response, 'text') else str(response.content)
headers = response.headers if hasattr(response, 'headers') else {}
# Avoid false positives when tested paths are transparently redirected to a login wall.
# Typical case: /phpinfo.php -> /login.php (200) with generic Server header disclosure.
if self._is_login_wall_response(response, path, content):
return {
'path': path,
'status_code': response.status_code,
'leaks': [],
'has_leaks': False
}
if self._is_soft_404_response(response, path):
return {
'path': path,
'status_code': response.status_code,
'leaks': [],
'has_leaks': False
}
# Analyser le contenu avec les patterns
seen_matches = set()
is_html = self._is_html_page(content, headers)
conservative_body = (
is_html
and not self._opt_bool(self.strict_body_scan)
and self._is_benign_surface_path(path)
)
for leak_type, patterns in self.DEBUG_PATTERNS.items():
if conservative_body and leak_type in self._HTML_CONSERVATIVE_SKIP:
continue
for pattern in patterns:
matches = re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE)
for match in matches:
match_text = match.group(0)
dedupe_key = (leak_type, match_text[:160], path)
if dedupe_key in seen_matches:
continue
if not self._is_actionable_leak(
leak_type,
match_text,
content,
path,
response,
is_html=is_html,
match_offset=match.start(),
):
continue
seen_matches.add(dedupe_key)
start = max(0, match.start() - 50)
end = min(len(content), match.end() + 50)
context = content[start:end].replace('\n', ' ').replace('\r', ' ')
leaks.append({
'type': leak_type,
'pattern': pattern,
'match': match_text,
'context': context.strip(),
'path': path,
'status_code': response.status_code,
'severity': self._get_severity(leak_type),
'source': 'response_body',
})
# Analyser les en-têtes HTTP
sensitive_headers = [
'X-Powered-By',
'Server',
'X-AspNet-Version',
'X-AspNetMvc-Version',
'X-Debug-Cached',
'X-Runtime',
'X-Version',
]
for header in sensitive_headers:
if header in headers:
header_value = headers[header]
if self._is_benign_version_header(header, header_value):
continue
leaks.append({
'type': 'version_info',
'header': header,
'header_value': header_value,
'match': f'{header}: {header_value}',
'path': path,
'status_code': response.status_code,
'severity': self._get_severity('version_info'),
'source': 'response_header',
})
# Vérifier les codes d'erreur qui peuvent révéler des informations
if response.status_code == 500:
if any(pattern in content.lower() for pattern in ['exception', 'error', 'traceback', 'stack']):
leaks.append({
'type': 'error_messages',
'pattern': 'HTTP 500 Error',
'match': 'Internal Server Error with details',
'path': path,
'status_code': response.status_code,
'severity': 'high',
'source': 'http_status',
})
return {
'path': path,
'status_code': response.status_code,
'leaks': leaks,
'has_leaks': len(leaks) > 0
}
def _normalize_path(self, value):
raw = (value or "").strip()
if not raw:
return "/"
parsed = urllib.parse.urlparse(raw)
normalized = parsed.path or raw
if not normalized.startswith("/"):
normalized = f"/{normalized}"
return normalized
def _is_login_like_path(self, value):
low = self._normalize_path(value).lower()
return any(token in low for token in (
"/login",
"signin",
"/auth",
"/session",
"/account/login",
"/wp-login.php",
))
def _looks_like_login_page(self, content):
text = (content or "").lower()
return (
('type="password"' in text or "type='password'" in text)
and any(token in text for token in (
"login",
"sign in",
"connexion",
"username",
"user",
"email",
"mot de passe",
"password",
))
)
def _seed_soft404_baselines(self):
"""Register index + canary fingerprints so SPA catch-alls are filtered."""
try:
from lib.scanner.http.soft_404 import (
make_canary_path,
origin_key_from_url,
register_baseline_from_response,
register_canary_from_response,
remember_canary_path,
)
root = self.http_request(method="GET", path="/")
origin = getattr(self, "_ks_http_origin", "") or ""
if not origin and root is not None:
origin = origin_key_from_url(getattr(root, "url", "") or "")
if root is not None and origin:
register_baseline_from_response(origin, root)
canary = make_canary_path()
canary_resp = self.http_request(method="GET", path=canary)
if canary_resp is not None and origin:
register_canary_from_response(origin, canary_resp, path=canary)
remember_canary_path(origin, canary)
except Exception:
pass
def _is_soft_404_response(self, response, path):
if bool(getattr(response, "ks_soft_404", False) or getattr(response, "ks_same_as_index", False)):
return True
try:
if hasattr(self, "is_same_as_index") and self.is_same_as_index(response, path=path):
return True
except Exception:
pass
return False
def _is_benign_version_header(self, header, value):
name = str(header or "").strip().lower()
text = str(value or "").strip().lower()
if not text:
return True
if name == "server":
token = text.split("/", 1)[0].strip()
if token in self._BENIGN_SERVER_VALUES:
return True
return False
def _looks_like_real_config_exposure(self, content, path):
sample = str(content or "")
lowered = sample.lower()
if self._CONFIG_BODY_MARKERS.search(sample):
return True
if "<?php" in lowered or "<configuration" in lowered:
return True
if re.search(r"^\s*\{", sample.strip()) and re.search(
r'"(?:api[_-]?key|secret|password|database)"\s*:', sample, re.I
):
return True
path_lower = str(path or "").lower()
if path_lower.endswith(".env") and re.search(r"^[A-Z0-9_]+=", sample, re.M):
return True
return False
def _is_login_wall_response(self, response, tested_path, content):
# If we are explicitly testing a login path, keep analysis enabled.
if self._is_login_like_path(tested_path):
return False
final_path = self._normalize_path(getattr(response, "url", "") or "")
if self._is_login_like_path(final_path):
return True
# Defensive fallback: some clients keep final URL unchanged but return login body.
if self._looks_like_login_page(content):
return True
return False
# On public HTML pages, skip categories that routinely match bundled client JS/CSS.
_HTML_CONSERVATIVE_SKIP = frozenset({
"source_code",
"file_paths",
"environment_vars",
})
def _is_benign_surface_path(self, path):
"""Paths that are usually public HTML surfaces, not debug endpoints."""
normalized = self._normalize_path(path)
return normalized in (
"/",
"/robots.txt",
"/sitemap.xml",
"/.well-known/security.txt",
)
def _opt_bool(self, value):
if hasattr(value, "value"):
value = value.value
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return bool(value)
def _opt_int(self, value, default=0):
if hasattr(value, "value"):
value = value.value
try:
return int(value)
except (TypeError, ValueError):
return default
def _format_output_text(self, text):
"""Format leak text for CLI output without hiding short values."""
cleaned = (text or "").replace("\n", " ").replace("\r", " ").strip()
max_len = max(0, self._opt_int(self.output_max_length, 0))
if max_len <= 0 or len(cleaned) <= max_len:
return cleaned
return f"{cleaned[:max_len]}… (+{len(cleaned) - max_len} chars)"
def _leak_display_fields(self, leak):
"""Return ordered (label, value) pairs with concrete evidence only."""
fields = []
header = leak.get("header")
header_value = leak.get("header_value")
if header:
fields.append(("Header", str(header)))
if header_value is not None:
fields.append(("Value", str(header_value)))
else:
match = leak.get("match", "")
if match:
fields.append(("Evidence", match))
surrounding = (leak.get("context") or "").strip()
match_text = (leak.get("match") or "").strip()
if surrounding and surrounding != match_text:
fields.append(("Surrounding", surrounding))
status_code = leak.get("status_code")
if status_code is not None:
fields.append(("HTTP status", str(status_code)))
source = leak.get("source")
if source:
fields.append(("Source", str(source)))
return fields
def _print_leak_block(self, leak, indent=" "):
leak_type = leak.get("type", "unknown")
severity = str(leak.get("severity", "medium")).upper()
path = leak.get("path", "/")
title = f"{indent}[{severity}] {leak_type} @ {path}"
severity_low = str(leak.get("severity", "medium")).lower()
if severity_low in ("critical", "high"):
print_info(color_red(title))
elif severity_low == "medium":
print_info(color_yellow(title))
else:
print_info(color_blue(title))
for label, value in self._leak_display_fields(leak):
if value:
print_info(f"{indent} {label}: {self._format_output_text(value)}")
def _is_html_page(self, content, headers):
content_type = str((headers or {}).get("Content-Type") or "").lower()
if "text/html" in content_type:
return True
sample = (content or "")[:8192].lower()
return "<html" in sample or "<!doctype html" in sample
def _offset_inside_script_block(self, content, offset):
lower = (content or "").lower()
script_open = lower.rfind("<script", 0, offset)
if script_open < 0:
return False
tag_end = lower.find(">", script_open)
if tag_end < 0 or tag_end > offset:
return False
script_close = lower.find("</script>", offset)
return script_close >= 0
def _is_json_escaped_url_fragment(self, match_text):
text = match_text or ""
if "\\/" in text or ":\\/" in text:
return True
if re.fullmatch(r"[a-z]:\\/?", text, flags=re.IGNORECASE):
return True
return False
def _is_actionable_leak(self, leak_type, match_text, content, path, response, *, is_html, match_offset=0):
text = match_text or ""
if self._is_json_escaped_url_fragment(text):
return False
if leak_type == "source_code":
if re.match(r"function\s+\w+\s*\(", text, re.IGNORECASE):
return False
if self._BENIGN_JS_MARKERS.search(text):
return False
if is_html and self._offset_inside_script_block(content, match_offset):
return False
if leak_type == "file_paths":
if self._is_json_escaped_url_fragment(text):
return False
if is_html and path in ("", "/"):
if not re.search(
r"(?:/var/|/home/|/usr/|/opt/|"
r"[A-Z]:\\(?:Users|Windows|inetpub|Program Files|xampp))",
text,
re.IGNORECASE,
):
return False
if leak_type == "environment_vars":
if "=" not in text and not text.startswith("%"):
return False
if text.startswith("${") or text.startswith("$"):
return False
if leak_type == "version_info" and is_html:
if self._offset_inside_script_block(content, match_offset):
return False
if leak_type == "config_files":
start = max(0, int(match_offset or 0) - 120)
end = min(len(content), int(match_offset or 0) + len(text) + 120)
context = content[start:end]
if self._SPA_META_CONTEXT.search(context):
return False
if is_html and not self._looks_like_real_config_exposure(content, path):
return False
if leak_type == "error_messages" and is_html and response.status_code == 200:
if path in ("", "/") and not any(
token in text.lower()
for token in ("fatal error", "parse error", "syntax error", "stack trace", "traceback")
):
return False
return True
def _get_severity(self, leak_type):
"""
Détermine la sévérité d'un type de fuite
Args:
leak_type: Type de fuite détectée
Returns:
str: Niveau de sévérité (low, medium, high, critical)
"""
severity_map = {
'stack_trace': 'high',
'file_paths': 'medium',
'database_info': 'critical',
'api_keys': 'critical',
'source_code': 'high',
'environment_vars': 'high',
'debug_mode': 'medium',
'config_files': 'high',
'version_info': 'low',
'error_messages': 'medium',
}
return severity_map.get(leak_type, 'medium')
def test_path(self, path):
"""
Teste un chemin spécifique pour des fuites d'informations
Args:
path: Chemin à tester
Returns:
dict: Résultats de l'analyse
"""
try:
response = self.http_request(method="GET", path=path)
return self.analyze_response(response, path)
except Exception as e:
print_debug(f"Error testing path {path}: {str(e)}")
return None
def run(self):
"""
Exécute le scan de fuites d'informations de debug
"""
self.all_leaks = []
self.vulnerable_paths = []
print_status("Starting HTTP Debug Information Leak Scan...")
print_info(f"Target: {self.target}")
print_info("")
self._seed_soft404_baselines()
# Construire la liste des chemins à tester
paths_to_test = list(self.COMMON_DEBUG_PATHS)
# Ajouter les chemins personnalisés
if self.paths:
custom_paths = [p.strip() for p in self.paths.split(',') if p.strip()]
paths_to_test.extend(custom_paths)
# Tester le chemin racine
print_status("Testing root path...")
root_result = self.test_path("/")
if root_result and root_result.get('has_leaks'):
self.vulnerable_paths.append(root_result)
self.all_leaks.extend(root_result['leaks'])
print_success("\n[!] INFORMATION LEAK DETECTED: /")
print_info(f" HTTP status: {root_result.get('status_code')}")
print_info(f" Leaks: {len(root_result.get('leaks', []))}")
for leak in root_result.get('leaks', []):
self._print_leak_block(leak)
print_info("")
# Tester les chemins communs
print_status(f"Testing {len(paths_to_test)} common debug paths...")
print_info("")
tested = 0
vulnerable_count = 0
for path in paths_to_test:
tested += 1
print_info(f"[{tested}/{len(paths_to_test)}] Testing: {path}")
result = self.test_path(path)
if result:
if result.get('has_leaks'):
vulnerable_count += 1
leaks = result.get('leaks', [])
print_success(f"\n[!] INFORMATION LEAK DETECTED: {path}")
print_info(f" HTTP status: {result.get('status_code')}")
print_info(f" Leaks: {len(leaks)}")
for leak in leaks:
self._print_leak_block(leak)
print_info("")
self.vulnerable_paths.append(result)
self.all_leaks.extend(leaks)
# Résumé
print_info("")
print_status("=" * 60)
print_status("Debug Information Leak Scan Summary")
print_status("=" * 60)
print_info(f"Total paths tested: {tested + 1}") # +1 pour le chemin racine
print_info(f"Vulnerable paths found: {vulnerable_count + (1 if root_result and root_result.get('has_leaks') else 0)}")
print_info(f"Total information leaks detected: {len(self.all_leaks)}")
print_status("=" * 60)
if self.all_leaks:
print_success("\nInformation Leaks Detected:")
print_info("")
table_data = []
for leak in self.all_leaks:
evidence = leak.get("header_value") or leak.get("match") or ""
if leak.get("header"):
evidence = f"{leak['header']}: {leak.get('header_value', '')}"
table_data.append([
str(leak.get("path", "/")),
str(leak.get("type", "unknown")),
str(leak.get("severity", "medium")).upper(),
self._format_output_text(evidence),
str(leak.get("status_code", "")),
])
self._print_leak_block(leak, indent=" ")
print_info("")
print_status("Findings table:")
if table_data:
print_table(
["Path", "Type", "Severity", "Evidence", "HTTP status"],
table_data,
)
print_info("")
print_status("Summary by Leak Type:")
leak_type_counts = {}
for leak in self.all_leaks:
leak_type = leak.get('type', 'unknown')
leak_type_counts[leak_type] = leak_type_counts.get(leak_type, 0) + 1
table_data = []
for leak_type, count in sorted(leak_type_counts.items(), key=lambda x: x[1], reverse=True):
severity = self._get_severity(leak_type)
table_data.append([
leak_type,
str(count),
severity.upper()
])
if table_data:
print_table(['Leak Type', 'Count', 'Severity'], table_data)
return finalize_http_scanner_run(
self,
self.all_leaks,
title="Debug Information Leak",
severity="high",
category="information-disclosure",
findings_key="debug_leaks",
dedupe_keys=("path", "type"),
hit_mapper=lambda leak: {
"path": leak.get("path"),
"method": "GET",
"request_url": target_base_url(self, path=str(leak.get("path") or "/")),
"status_code": leak.get("status_code"),
"type": leak.get("type"),
"header": leak.get("header"),
"header_value": leak.get("header_value"),
"evidence_snippet": leak.get("header_value") or leak.get("match"),
"severity": leak.get("severity"),
"source": leak.get("source"),
},
)
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
08 Aug 2026 21:48Current
5.8Medium risk
Vulners AI Score5.8