evilspider 2.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- _version.py +5 -0
- analyzer/__init__.py +8 -0
- analyzer/api_parser.py +188 -0
- analyzer/secrets.py +363 -0
- analyzer/security.py +186 -0
- analyzer/tech.py +216 -0
- config.py +373 -0
- crawler.py +640 -0
- evilspider-2.5.0.dist-info/METADATA +273 -0
- evilspider-2.5.0.dist-info/RECORD +26 -0
- evilspider-2.5.0.dist-info/WHEEL +5 -0
- evilspider-2.5.0.dist-info/entry_points.txt +2 -0
- evilspider-2.5.0.dist-info/top_level.txt +8 -0
- extractors/__init__.py +8 -0
- extractors/form_extractor.py +164 -0
- extractors/html_extractor.py +147 -0
- extractors/js_extractor.py +129 -0
- extractors/sourcemap.py +53 -0
- main.py +392 -0
- output/__init__.py +6 -0
- output/formatter.py +182 -0
- output/reporter.py +226 -0
- output/templates/report_template.html +327 -0
- prober/__init__.py +6 -0
- prober/probe.py +183 -0
- prober/wordlists.py +161 -0
_version.py
ADDED
analyzer/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Analyzer module for EvilSpider: Secrets, Tech Fingerprinting, Security Auditing, API parsing."""
|
|
2
|
+
|
|
3
|
+
from .api_parser import ApiParser
|
|
4
|
+
from .secrets import SecretHunter
|
|
5
|
+
from .security import SecurityAuditor
|
|
6
|
+
from .tech import TechDetector
|
|
7
|
+
|
|
8
|
+
__all__ = ["SecretHunter", "TechDetector", "SecurityAuditor", "ApiParser"]
|
analyzer/api_parser.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EvilSpider - OpenAPI / Swagger & GraphQL API Parser
|
|
3
|
+
Parses API documentation specifications and extracts structured routes and parameters.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiParser:
|
|
11
|
+
"""Parses OpenAPI, Swagger, and GraphQL endpoints & schemas."""
|
|
12
|
+
|
|
13
|
+
def is_api_spec(self, content: str) -> bool:
|
|
14
|
+
"""Determines if the response text looks like an OpenAPI or Swagger spec."""
|
|
15
|
+
if not content or len(content) < 20:
|
|
16
|
+
return False
|
|
17
|
+
if '"swagger"' in content or '"openapi"' in content or "swagger:" in content or "openapi:" in content:
|
|
18
|
+
return True
|
|
19
|
+
if '"__schema"' in content or '"__type"' in content:
|
|
20
|
+
return True
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
def _join_api_url(self, server_url: str, path: str) -> str:
|
|
24
|
+
"""Joins server base URL and API path properly preserving base path."""
|
|
25
|
+
s = server_url.rstrip("/")
|
|
26
|
+
p = path.lstrip("/")
|
|
27
|
+
return f"{s}/{p}"
|
|
28
|
+
|
|
29
|
+
def parse_spec(self, content: str, base_url: str = "") -> Optional[Dict[str, Any]]:
|
|
30
|
+
"""Parses OpenAPI or Swagger content into endpoints and parameters."""
|
|
31
|
+
data = None
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(content)
|
|
34
|
+
except Exception:
|
|
35
|
+
try:
|
|
36
|
+
import yaml
|
|
37
|
+
|
|
38
|
+
data = yaml.safe_load(content)
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
if not isinstance(data, dict):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
# Check for GraphQL introspection
|
|
46
|
+
if "data" in data and "__schema" in data["data"]:
|
|
47
|
+
return self._parse_graphql_introspection(data["data"]["__schema"], base_url)
|
|
48
|
+
|
|
49
|
+
is_swagger = "swagger" in data
|
|
50
|
+
is_openapi = "openapi" in data
|
|
51
|
+
|
|
52
|
+
if not (is_swagger or is_openapi):
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
version = data.get("openapi") or data.get("swagger")
|
|
56
|
+
info = data.get("info", {})
|
|
57
|
+
title = info.get("title", "API")
|
|
58
|
+
api_description = info.get("description", "")
|
|
59
|
+
|
|
60
|
+
servers = []
|
|
61
|
+
if is_openapi and "servers" in data:
|
|
62
|
+
for s in data["servers"]:
|
|
63
|
+
s_url = s.get("url", "")
|
|
64
|
+
if s_url:
|
|
65
|
+
if s_url.startswith(("http://", "https://")):
|
|
66
|
+
servers.append(s_url)
|
|
67
|
+
elif base_url:
|
|
68
|
+
servers.append(self._join_api_url(base_url, s_url))
|
|
69
|
+
elif is_swagger:
|
|
70
|
+
host = data.get("host", "")
|
|
71
|
+
base_path = data.get("basePath", "")
|
|
72
|
+
schemes = data.get("schemes", ["https"])
|
|
73
|
+
scheme = schemes[0] if schemes else "https"
|
|
74
|
+
if host:
|
|
75
|
+
servers.append(f"{scheme}://{host}{base_path}".rstrip("/"))
|
|
76
|
+
elif base_path and base_url:
|
|
77
|
+
servers.append(self._join_api_url(base_url, base_path))
|
|
78
|
+
|
|
79
|
+
if not servers and base_url:
|
|
80
|
+
servers.append(base_url.rstrip("/"))
|
|
81
|
+
|
|
82
|
+
endpoints = []
|
|
83
|
+
paths = data.get("paths", {})
|
|
84
|
+
|
|
85
|
+
for path_str, path_item in paths.items():
|
|
86
|
+
if not isinstance(path_item, dict):
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
for method in ("get", "post", "put", "delete", "patch", "options", "head"):
|
|
90
|
+
if method in path_item:
|
|
91
|
+
op = path_item[method]
|
|
92
|
+
if not isinstance(op, dict):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
summary = op.get("summary", "")
|
|
96
|
+
description = op.get("description", "")
|
|
97
|
+
operation_id = op.get("operationId", "")
|
|
98
|
+
tags = op.get("tags", [])
|
|
99
|
+
|
|
100
|
+
params = []
|
|
101
|
+
all_params = list(path_item.get("parameters", [])) + list(op.get("parameters", []))
|
|
102
|
+
for p in all_params:
|
|
103
|
+
if isinstance(p, dict) and "name" in p:
|
|
104
|
+
params.append(
|
|
105
|
+
{
|
|
106
|
+
"name": p.get("name"),
|
|
107
|
+
"in": p.get("in", "query"),
|
|
108
|
+
"required": p.get("required", False),
|
|
109
|
+
"type": p.get("type")
|
|
110
|
+
or (
|
|
111
|
+
p.get("schema", {}).get("type")
|
|
112
|
+
if isinstance(p.get("schema"), dict)
|
|
113
|
+
else "string"
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
resolved_urls = [self._join_api_url(s, path_str) for s in servers] if servers else [path_str]
|
|
119
|
+
|
|
120
|
+
endpoints.append(
|
|
121
|
+
{
|
|
122
|
+
"path": path_str,
|
|
123
|
+
"method": method.upper(),
|
|
124
|
+
"summary": summary,
|
|
125
|
+
"description": description,
|
|
126
|
+
"operation_id": operation_id,
|
|
127
|
+
"tags": tags,
|
|
128
|
+
"parameters": params,
|
|
129
|
+
"urls": resolved_urls,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
"type": "OpenAPI" if is_openapi else "Swagger",
|
|
135
|
+
"version": str(version),
|
|
136
|
+
"title": title,
|
|
137
|
+
"description": api_description,
|
|
138
|
+
"servers": servers,
|
|
139
|
+
"endpoint_count": len(endpoints),
|
|
140
|
+
"endpoints": endpoints,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def _parse_graphql_introspection(self, schema: Dict[str, Any], base_url: str) -> Dict[str, Any]:
|
|
144
|
+
types = schema.get("types", [])
|
|
145
|
+
query_type_name = schema.get("queryType", {}).get("name", "Query") if schema.get("queryType") else "Query"
|
|
146
|
+
mutation_type_name = (
|
|
147
|
+
schema.get("mutationType", {}).get("name", "Mutation") if schema.get("mutationType") else "Mutation"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
queries = []
|
|
151
|
+
mutations = []
|
|
152
|
+
custom_types = []
|
|
153
|
+
|
|
154
|
+
for t in types:
|
|
155
|
+
name = t.get("name", "")
|
|
156
|
+
if name.startswith("__"):
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
fields = t.get("fields") or []
|
|
160
|
+
if name == query_type_name:
|
|
161
|
+
for f in fields:
|
|
162
|
+
queries.append(
|
|
163
|
+
{
|
|
164
|
+
"name": f.get("name"),
|
|
165
|
+
"description": f.get("description", ""),
|
|
166
|
+
"args": [a.get("name") for a in (f.get("args") or [])],
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
elif name == mutation_type_name:
|
|
170
|
+
for f in fields:
|
|
171
|
+
mutations.append(
|
|
172
|
+
{
|
|
173
|
+
"name": f.get("name"),
|
|
174
|
+
"description": f.get("description", ""),
|
|
175
|
+
"args": [a.get("name") for a in (f.get("args") or [])],
|
|
176
|
+
}
|
|
177
|
+
)
|
|
178
|
+
else:
|
|
179
|
+
custom_types.append(name)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"type": "GraphQL Schema",
|
|
183
|
+
"url": base_url,
|
|
184
|
+
"queries": queries,
|
|
185
|
+
"mutations": mutations,
|
|
186
|
+
"types_count": len(custom_types),
|
|
187
|
+
"types": custom_types[:50],
|
|
188
|
+
}
|
analyzer/secrets.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EvilSpider - Sensitive Data & Secret Hunter
|
|
3
|
+
Extracts credentials, API keys, tokens, private keys, and sensitive info from responses.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SecretRule:
|
|
12
|
+
def __init__(
|
|
13
|
+
self, name: str, severity: str, pattern: str, description: str, category: str = "Credentials", validator=None
|
|
14
|
+
):
|
|
15
|
+
self.name = name
|
|
16
|
+
self.severity = severity # CRITICAL, HIGH, MEDIUM, LOW, INFO
|
|
17
|
+
self.pattern = re.compile(pattern, re.IGNORECASE if "PRIVATE KEY" not in pattern else 0)
|
|
18
|
+
self.description = description
|
|
19
|
+
self.category = category
|
|
20
|
+
self.validator = validator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _shannon_entropy(data: str) -> float:
|
|
24
|
+
"""Calculate Shannon entropy to filter low-entropy false positives."""
|
|
25
|
+
if not data:
|
|
26
|
+
return 0.0
|
|
27
|
+
entropy = 0.0
|
|
28
|
+
for x in set(data):
|
|
29
|
+
p_x = float(data.count(x)) / len(data)
|
|
30
|
+
if p_x > 0:
|
|
31
|
+
entropy += -p_x * math.log2(p_x)
|
|
32
|
+
return entropy
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_dummy_value(val: str) -> bool:
|
|
36
|
+
"""Filter out common placeholder dummy strings."""
|
|
37
|
+
low = val.lower()
|
|
38
|
+
dummy_indicators = [
|
|
39
|
+
"example",
|
|
40
|
+
"placeholder",
|
|
41
|
+
"your_key",
|
|
42
|
+
"your_token",
|
|
43
|
+
"your_api",
|
|
44
|
+
"xxxxxx",
|
|
45
|
+
"123456",
|
|
46
|
+
"abcdef",
|
|
47
|
+
"sample",
|
|
48
|
+
"dummy",
|
|
49
|
+
"test_key",
|
|
50
|
+
"mysecret",
|
|
51
|
+
"change_me",
|
|
52
|
+
"insert_",
|
|
53
|
+
"<api_key>",
|
|
54
|
+
"{api_key}",
|
|
55
|
+
"undefined",
|
|
56
|
+
"null",
|
|
57
|
+
"none",
|
|
58
|
+
]
|
|
59
|
+
return any(d in low for d in dummy_indicators)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
SECRET_RULES: List[SecretRule] = [
|
|
63
|
+
# AWS Keys
|
|
64
|
+
SecretRule(
|
|
65
|
+
name="AWS Access Key ID",
|
|
66
|
+
severity="HIGH",
|
|
67
|
+
pattern=r"\b((?:AKIA|ABIA|ACCA|ASIA)[0-9A-Z]{16})\b",
|
|
68
|
+
description="Amazon Web Services Access Key ID",
|
|
69
|
+
category="Cloud",
|
|
70
|
+
validator=lambda m: not _is_dummy_value(m),
|
|
71
|
+
),
|
|
72
|
+
SecretRule(
|
|
73
|
+
name="AWS Secret Access Key",
|
|
74
|
+
severity="CRITICAL",
|
|
75
|
+
pattern=r"(?i)aws_?(?:secret)?_?(?:access)?_?key(?:_id)?\s*[:=]\s*['\"]([A-Za-z0-9/+=]{40})['\"]",
|
|
76
|
+
description="Amazon Web Services Secret Access Key",
|
|
77
|
+
category="Cloud",
|
|
78
|
+
validator=lambda m: _shannon_entropy(m) > 3.0 and not _is_dummy_value(m),
|
|
79
|
+
),
|
|
80
|
+
# Google Cloud
|
|
81
|
+
SecretRule(
|
|
82
|
+
name="Google Cloud API Key",
|
|
83
|
+
severity="HIGH",
|
|
84
|
+
pattern=r"\b(AIza[0-9A-Za-z_\-]{35})\b",
|
|
85
|
+
description="Google Cloud / Firebase API Key",
|
|
86
|
+
category="Cloud",
|
|
87
|
+
validator=lambda m: not _is_dummy_value(m),
|
|
88
|
+
),
|
|
89
|
+
SecretRule(
|
|
90
|
+
name="Google OAuth Client Secret",
|
|
91
|
+
severity="HIGH",
|
|
92
|
+
pattern=r"(?i)client_secret\s*[:=]\s*['\"]([a-zA-Z0-9_\-]{24,36})['\"]",
|
|
93
|
+
description="Google OAuth Client Secret",
|
|
94
|
+
category="OAuth",
|
|
95
|
+
validator=lambda m: _shannon_entropy(m) > 3.2 and not _is_dummy_value(m),
|
|
96
|
+
),
|
|
97
|
+
# GitHub
|
|
98
|
+
SecretRule(
|
|
99
|
+
name="GitHub Personal Access Token (Classic)",
|
|
100
|
+
severity="CRITICAL",
|
|
101
|
+
pattern=r"\b(ghp_[0-9a-zA-Z]{36})\b",
|
|
102
|
+
description="GitHub Classic Personal Access Token",
|
|
103
|
+
category="VCS",
|
|
104
|
+
),
|
|
105
|
+
SecretRule(
|
|
106
|
+
name="GitHub Fine-Grained Token",
|
|
107
|
+
severity="CRITICAL",
|
|
108
|
+
pattern=r"\b(github_pat_[0-9a-zA-Z_]{82})\b",
|
|
109
|
+
description="GitHub Fine-Grained Personal Access Token",
|
|
110
|
+
category="VCS",
|
|
111
|
+
),
|
|
112
|
+
SecretRule(
|
|
113
|
+
name="GitHub OAuth / App Token",
|
|
114
|
+
severity="CRITICAL",
|
|
115
|
+
pattern=r"\b((?:gho|ghu|ghs|ghr)_[0-9a-zA-Z]{36})\b",
|
|
116
|
+
description="GitHub OAuth / App Access Token",
|
|
117
|
+
category="VCS",
|
|
118
|
+
),
|
|
119
|
+
# GitLab
|
|
120
|
+
SecretRule(
|
|
121
|
+
name="GitLab Personal Access Token",
|
|
122
|
+
severity="CRITICAL",
|
|
123
|
+
pattern=r"\b(glpat-[0-9a-zA-Z_\-]{20,24})\b",
|
|
124
|
+
description="GitLab Personal Access Token",
|
|
125
|
+
category="VCS",
|
|
126
|
+
),
|
|
127
|
+
# Slack
|
|
128
|
+
SecretRule(
|
|
129
|
+
name="Slack Webhook URL",
|
|
130
|
+
severity="HIGH",
|
|
131
|
+
pattern=r"https://hooks\.slack\.com/services/T[a-zA-Z0-9_]+/B[a-zA-Z0-9_]+/[a-zA-Z0-9_]+",
|
|
132
|
+
description="Slack Incoming Webhook URL",
|
|
133
|
+
category="Messaging",
|
|
134
|
+
),
|
|
135
|
+
SecretRule(
|
|
136
|
+
name="Slack API Token",
|
|
137
|
+
severity="CRITICAL",
|
|
138
|
+
pattern=r"\b(xox[baprs]-[0-9a-zA-Z\-]{10,72})\b",
|
|
139
|
+
description="Slack API / Bot / User Token",
|
|
140
|
+
category="Messaging",
|
|
141
|
+
),
|
|
142
|
+
# Stripe
|
|
143
|
+
SecretRule(
|
|
144
|
+
name="Stripe Live Secret Key",
|
|
145
|
+
severity="CRITICAL",
|
|
146
|
+
pattern=r"\b(sk_live_[0-9a-zA-Z]{24,99})\b",
|
|
147
|
+
description="Stripe Live Secret Key",
|
|
148
|
+
category="Payment",
|
|
149
|
+
),
|
|
150
|
+
SecretRule(
|
|
151
|
+
name="Stripe Live Restricted Key",
|
|
152
|
+
severity="HIGH",
|
|
153
|
+
pattern=r"\b(rk_live_[0-9a-zA-Z]{24,99})\b",
|
|
154
|
+
description="Stripe Live Restricted Key",
|
|
155
|
+
category="Payment",
|
|
156
|
+
),
|
|
157
|
+
SecretRule(
|
|
158
|
+
name="Stripe Publishable Key",
|
|
159
|
+
severity="INFO",
|
|
160
|
+
pattern=r"\b(pk_live_[0-9a-zA-Z]{24,99})\b",
|
|
161
|
+
description="Stripe Live Publishable Key",
|
|
162
|
+
category="Payment",
|
|
163
|
+
),
|
|
164
|
+
# JWT Token
|
|
165
|
+
SecretRule(
|
|
166
|
+
name="JSON Web Token (JWT)",
|
|
167
|
+
severity="MEDIUM",
|
|
168
|
+
pattern=r"\b(eyJ[a-zA-Z0-9_\-]{10,}\.eyJ[a-zA-Z0-9_\-]{10,}\.[a-zA-Z0-9_\-]{10,})\b",
|
|
169
|
+
description="JSON Web Token authentication bearer",
|
|
170
|
+
category="Authentication",
|
|
171
|
+
),
|
|
172
|
+
# Private Keys
|
|
173
|
+
SecretRule(
|
|
174
|
+
name="RSA Private Key",
|
|
175
|
+
severity="CRITICAL",
|
|
176
|
+
pattern=r"-----BEGIN (?:RSA )?PRIVATE KEY-----[^-]+-----END (?:RSA )?PRIVATE KEY-----",
|
|
177
|
+
description="RSA Private Key Block",
|
|
178
|
+
category="Cryptography",
|
|
179
|
+
),
|
|
180
|
+
SecretRule(
|
|
181
|
+
name="OpenSSH / EC Private Key",
|
|
182
|
+
severity="CRITICAL",
|
|
183
|
+
pattern=r"-----BEGIN (?:OPENSSH|EC|DSA) PRIVATE KEY-----[^-]+-----END (?:OPENSSH|EC|DSA) PRIVATE KEY-----",
|
|
184
|
+
description="OpenSSH or EC/DSA Private Key Block",
|
|
185
|
+
category="Cryptography",
|
|
186
|
+
),
|
|
187
|
+
SecretRule(
|
|
188
|
+
name="PGP Private Key",
|
|
189
|
+
severity="CRITICAL",
|
|
190
|
+
pattern=r"-----BEGIN PGP PRIVATE KEY BLOCK-----[^-]+-----END PGP PRIVATE KEY BLOCK-----",
|
|
191
|
+
description="PGP Private Key Block",
|
|
192
|
+
category="Cryptography",
|
|
193
|
+
),
|
|
194
|
+
# SendGrid, Twilio, Mailgun
|
|
195
|
+
SecretRule(
|
|
196
|
+
name="SendGrid API Key",
|
|
197
|
+
severity="HIGH",
|
|
198
|
+
pattern=r"\b(SG\.[a-zA-Z0-9_\-]{22}\.[a-zA-Z0-9_\-]{43})\b",
|
|
199
|
+
description="SendGrid API Key",
|
|
200
|
+
category="Email",
|
|
201
|
+
),
|
|
202
|
+
SecretRule(
|
|
203
|
+
name="Twilio Account SID",
|
|
204
|
+
severity="MEDIUM",
|
|
205
|
+
pattern=r"\b(AC[a-f0-9]{32})\b",
|
|
206
|
+
description="Twilio Account SID",
|
|
207
|
+
category="Messaging",
|
|
208
|
+
),
|
|
209
|
+
SecretRule(
|
|
210
|
+
name="Twilio API Secret",
|
|
211
|
+
severity="HIGH",
|
|
212
|
+
pattern=r"\b(SK[a-f0-9]{32})\b",
|
|
213
|
+
description="Twilio API Secret Key",
|
|
214
|
+
category="Messaging",
|
|
215
|
+
),
|
|
216
|
+
SecretRule(
|
|
217
|
+
name="Mailgun API Key",
|
|
218
|
+
severity="HIGH",
|
|
219
|
+
pattern=r"\b(key-[0-9a-zA-Z]{32})\b",
|
|
220
|
+
description="Mailgun API Key",
|
|
221
|
+
category="Email",
|
|
222
|
+
),
|
|
223
|
+
# Square & Shopify
|
|
224
|
+
SecretRule(
|
|
225
|
+
name="Square Access Token",
|
|
226
|
+
severity="HIGH",
|
|
227
|
+
pattern=r"\b(sq0atp-[0-9A-Za-z_\-]{22})\b",
|
|
228
|
+
description="Square OAuth Access Token",
|
|
229
|
+
category="Payment",
|
|
230
|
+
),
|
|
231
|
+
SecretRule(
|
|
232
|
+
name="Shopify Access Token",
|
|
233
|
+
severity="HIGH",
|
|
234
|
+
pattern=r"\b(shpat_[a-fA-F0-9]{32})\b",
|
|
235
|
+
description="Shopify Admin API Access Token",
|
|
236
|
+
category="E-commerce",
|
|
237
|
+
),
|
|
238
|
+
# Telegram & Discord
|
|
239
|
+
SecretRule(
|
|
240
|
+
name="Telegram Bot Token",
|
|
241
|
+
severity="HIGH",
|
|
242
|
+
pattern=r"\b([0-9]{9,10}:[a-zA-Z0-9_\-]{35})\b",
|
|
243
|
+
description="Telegram Bot API Token",
|
|
244
|
+
category="Messaging",
|
|
245
|
+
),
|
|
246
|
+
SecretRule(
|
|
247
|
+
name="Discord Webhook URL",
|
|
248
|
+
severity="HIGH",
|
|
249
|
+
pattern=r"https://(?:canary\.|ptb\.)?discord(?:app)?\.com/api/webhooks/[0-9]+/[a-zA-Z0-9_\-]+",
|
|
250
|
+
description="Discord Incoming Webhook URL",
|
|
251
|
+
category="Messaging",
|
|
252
|
+
),
|
|
253
|
+
SecretRule(
|
|
254
|
+
name="Discord Bot Token",
|
|
255
|
+
severity="CRITICAL",
|
|
256
|
+
pattern=r"\b([MN][A-Za-z\d]{23,26}\.[A-Za-z\d_\-]{6}\.[A-Za-z\d_\-]{27,38})\b",
|
|
257
|
+
description="Discord Bot Token",
|
|
258
|
+
category="Messaging",
|
|
259
|
+
),
|
|
260
|
+
# Database Connection Strings
|
|
261
|
+
SecretRule(
|
|
262
|
+
name="Database Connection URI",
|
|
263
|
+
severity="CRITICAL",
|
|
264
|
+
pattern=r"(?:mysql|postgres|postgresql|mongodb|redis|amqp|couchdb)://[a-zA-Z0-9_.\-]+:[a-zA-Z0-9_.\-~%!$&'()*+,;=]+@[a-zA-Z0-9_.\-]+(?::\d+)?(?:/[a-zA-Z0-9_.\-]*)?",
|
|
265
|
+
description="Database connection URI with credentials",
|
|
266
|
+
category="Database",
|
|
267
|
+
validator=lambda m: not _is_dummy_value(m) and "@localhost" not in m.lower() and "@127.0.0.1" not in m,
|
|
268
|
+
),
|
|
269
|
+
# Hardcoded Passwords & Auth Headers in JS/HTML
|
|
270
|
+
SecretRule(
|
|
271
|
+
name="Hardcoded Secret / Password Assignment",
|
|
272
|
+
severity="MEDIUM",
|
|
273
|
+
pattern=r"(?i)(?:password|passwd|api_secret|client_secret|auth_token|access_token|secret_key)\s*[:=]\s*['\"]([a-zA-Z0-9!@#$%^&*()_+={}\[\]|;:<>,.?/~`_\-]{8,64})['\"]",
|
|
274
|
+
description="Hardcoded credential or token assignment",
|
|
275
|
+
category="Credentials",
|
|
276
|
+
validator=lambda m: _shannon_entropy(m) > 2.8 and not _is_dummy_value(m),
|
|
277
|
+
),
|
|
278
|
+
SecretRule(
|
|
279
|
+
name="Authorization Bearer Token Header",
|
|
280
|
+
severity="HIGH",
|
|
281
|
+
pattern=r"(?i)bearer\s+([a-zA-Z0-9_\-\.]{20,128})",
|
|
282
|
+
description="Authorization Bearer Token leaked in response",
|
|
283
|
+
category="Authentication",
|
|
284
|
+
validator=lambda m: not _is_dummy_value(m) and _shannon_entropy(m) > 3.0,
|
|
285
|
+
),
|
|
286
|
+
# Internal IPs and Leaked Infrastructure
|
|
287
|
+
SecretRule(
|
|
288
|
+
name="Private IPv4 Address",
|
|
289
|
+
severity="INFO",
|
|
290
|
+
pattern=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",
|
|
291
|
+
description="Internal RFC 1918 Private IP Address",
|
|
292
|
+
category="Infrastructure",
|
|
293
|
+
validator=lambda m: (
|
|
294
|
+
not m.startswith("10.0.0.1") and not m.startswith("192.168.1.1") and not m.startswith("127.0.0.1")
|
|
295
|
+
),
|
|
296
|
+
),
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class SecretHunter:
|
|
301
|
+
"""Scans response bodies (HTML, JS, JSON, XML) for secrets, tokens, and credentials."""
|
|
302
|
+
|
|
303
|
+
def __init__(self, custom_rules: Optional[List[SecretRule]] = None):
|
|
304
|
+
self.rules = list(SECRET_RULES)
|
|
305
|
+
if custom_rules:
|
|
306
|
+
self.rules.extend(custom_rules)
|
|
307
|
+
|
|
308
|
+
def mask_secret(self, secret: str) -> str:
|
|
309
|
+
"""Mask sensitive value for safe reporting (e.g., AKIA1234...ABCD)."""
|
|
310
|
+
if len(secret) <= 8:
|
|
311
|
+
return "***"
|
|
312
|
+
prefix_len = min(4, len(secret) // 4)
|
|
313
|
+
suffix_len = min(4, len(secret) // 4)
|
|
314
|
+
return f"{secret[:prefix_len]}...{secret[-suffix_len:]}"
|
|
315
|
+
|
|
316
|
+
def scan(self, content: str, source_url: str = "") -> List[Dict[str, Any]]:
|
|
317
|
+
"""Scans content and returns structured findings."""
|
|
318
|
+
findings = []
|
|
319
|
+
if not content:
|
|
320
|
+
return findings
|
|
321
|
+
|
|
322
|
+
lines = content.splitlines()
|
|
323
|
+
|
|
324
|
+
for rule in self.rules:
|
|
325
|
+
for match in rule.pattern.finditer(content):
|
|
326
|
+
matched_val = match.group(1) if match.groups() else match.group(0)
|
|
327
|
+
matched_val = matched_val.strip()
|
|
328
|
+
|
|
329
|
+
if rule.validator and not rule.validator(matched_val):
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
start_pos = match.start()
|
|
333
|
+
line_idx = content.count("\n", 0, start_pos)
|
|
334
|
+
line_num = line_idx + 1
|
|
335
|
+
|
|
336
|
+
context_start = max(0, line_idx - 1)
|
|
337
|
+
context_end = min(len(lines), line_idx + 2)
|
|
338
|
+
context_lines = lines[context_start:context_end]
|
|
339
|
+
context_snippet = "\n".join(context_lines)
|
|
340
|
+
|
|
341
|
+
findings.append(
|
|
342
|
+
{
|
|
343
|
+
"rule": rule.name,
|
|
344
|
+
"severity": rule.severity,
|
|
345
|
+
"category": rule.category,
|
|
346
|
+
"description": rule.description,
|
|
347
|
+
"matched": matched_val,
|
|
348
|
+
"masked": self.mask_secret(matched_val),
|
|
349
|
+
"line_number": line_num,
|
|
350
|
+
"context_snippet": context_snippet[:250],
|
|
351
|
+
"source_url": source_url,
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
seen = set()
|
|
356
|
+
deduped = []
|
|
357
|
+
for f in findings:
|
|
358
|
+
key = (f["rule"], f["matched"])
|
|
359
|
+
if key not in seen:
|
|
360
|
+
seen.add(key)
|
|
361
|
+
deduped.append(f)
|
|
362
|
+
|
|
363
|
+
return deduped
|