larxius commited on
Commit
945e32f
·
verified ·
1 Parent(s): ec8e322

Update backend/scanners/subdomain_scanner.py

Browse files
Files changed (1) hide show
  1. backend/scanners/subdomain_scanner.py +389 -390
backend/scanners/subdomain_scanner.py CHANGED
@@ -1,390 +1,389 @@
1
- """
2
- subdomain_scanner.py — Discovers subdomains via crt.sh and hunts for Takeovers.
3
- """
4
- import requests
5
- import json
6
- import dns.resolver
7
- import subprocess
8
- import os
9
- import shutil
10
- from scanners.base_scanner import BaseScanner
11
-
12
- TAKEOVER_SIGNATURES = {
13
- "github.io": "There isn't a GitHub Pages site here.",
14
- "s3.amazonaws.com": "NoSuchBucket",
15
- "s3-website": "NoSuchBucket",
16
- "herokuapp.com": "No such app",
17
- "myshopify.com": "Sorry, this shop is currently unavailable.",
18
- "wpengine.com": "The site you were looking for couldn't be found.",
19
- "pantheonsite.io": "The edge router is unable to route the requested request.",
20
- "zendesk.com": "Help Center Closed",
21
- "azurewebsites.net": "Error 404",
22
- "cloudapp.net": "No such app",
23
- "appspot.com": "Error 404",
24
- "herokudns.com": "No such app",
25
- "herokussl.com": "No such app",
26
- "netlify.app": "Page not found",
27
- "vercel.app": "Page not found",
28
- "now.sh": "Page not found",
29
- "bitbucket.io": "Repository not found",
30
- "gitlab.io": "Project not found",
31
- "surge.sh": "Project not found",
32
- "pages.cloudflare.com": "Error 1004",
33
- "workers.dev": "404 Not Found",
34
- "deno.dev": "Not Found",
35
- "fly.dev": "Not Found",
36
- "railway.app": "Not Found",
37
- "render.com": "Not Found",
38
- "elasticbeanstalk.com": "404 Not Found",
39
- "cloudfront.net": "404 Not Found",
40
- "fastly.com": "Fastly error",
41
- "cloudfunctions.net": "404 Not Found",
42
- "firebaseapp.com": "Site not found",
43
- "firebaseio.com": "null",
44
- "webflow.io": "404 Not Found",
45
- "wixsite.com": "404 Not Found",
46
- "squarespace.com": "404 Not Found",
47
- "wordpress.com": "Do you want to register",
48
- "blogspot.com": "Blog not found",
49
- "tumblr.com": "Nothing here",
50
- "ghost.io": "404 Not Found",
51
- "hubspot.com": "Page not found",
52
- "unbouncepages.com": "Page not found",
53
- "instapage.com": "Page not found",
54
- "leadpages.net": "Page not found",
55
- "kajabi.com": "Page not found",
56
- "teachable.com": "Page not found",
57
- "thinkific.com": "Page not found",
58
- "podia.com": "Page not found",
59
- "uservoice.com": "Page not found",
60
- "freshdesk.com": "Page not found",
61
- "intercom.com": "Page not found",
62
- "drift.com": "Page not found",
63
- "crisp.chat": "Page not found",
64
- "tawk.to": "Page not found",
65
- "typeform.com": "Page not found",
66
- "paperform.co": "Page not found",
67
- "jotform.com": "Page not found",
68
- "formstack.com": "Page not found",
69
- "wufoo.com": "Page not found",
70
- "cognitoforms.com": "Page not found",
71
- "formsite.com": "Page not found",
72
- "surveygizmo.com": "Page not found",
73
- "qualtrics.com": "Page not found",
74
- "survey monkey.com": "Page not found",
75
- }
76
-
77
- COMMON_SUBDOMAINS = [
78
- "www", "mail", "email", "ftp", "sftp", "ssh", "vpn", "remote", "api", "dev", "staging", "test", "uat",
79
- "prod", "production", "live", "admin", "administrator", "dashboard", "panel", "portal", "console",
80
- "blog", "news", "media", "static", "assets", "cdn", "img", "images", "video", "videos", "audio",
81
- "docs", "documentation", "wiki", "help", "support", "kb", "knowledgebase", "faq", "forum",
82
- "shop", "store", "cart", "checkout", "payment", "billing", "account", "login", "signin", "signup", "register",
83
- "secure", "auth", "oauth", "sso", "identity", "token", "session", "cookie", "cache",
84
- "db", "database", "mysql", "postgres", "mongodb", "redis", "elasticsearch", "solr", "search",
85
- "jenkins", "ci", "cd", "build", "deploy", "git", "svn", "repo", "repository", "code",
86
- "monitor", "metrics", "logs", "log", "analytics", "stats", "statistics", "report", "reporting",
87
- "ns1", "ns2", "ns3", "ns4", "dns", "mx", "smtp", "pop", "pop3", "imap",
88
- "m", "mobile", "app", "application", "web", "webmail", "webdisk", "cpanel", "whm",
89
- "beta", "alpha", "demo", "sandbox", "lab", "labs", "experimental", "poc", "proof",
90
- "internal", "private", "public", "external", "partner", "vendor", "client", "customer",
91
- "hr", "finance", "legal", "marketing", "sales", "support", "it", "ops", "devops",
92
- "staging1", "staging2", "dev1", "dev2", "test1", "test2", "prod1", "prod2",
93
- "us-east", "us-west", "eu-west", "eu-central", "ap-south", "ap-northeast",
94
- "lb", "loadbalancer", "proxy", "gateway", "firewall", "ids", "ips",
95
- "backup", "archive", "old", "legacy", "v1", "v2", "v3", "version1", "version2",
96
- "status", "health", "ping", "heartbeat", "uptime", "monitoring",
97
- ]
98
-
99
-
100
- class SubdomainScanner(BaseScanner):
101
- SCANNER_NAME = "Subdomain Enumeration & Takeover Hunter"
102
- _SCANNER_KEY = "subdomain"
103
-
104
- def brute_force_subdomains(self):
105
- self.log("INFO", "[Subdomains] Starting subdomain brute-forcing...")
106
- found_subdomains = set()
107
-
108
- for sub in COMMON_SUBDOMAINS[:50]:
109
- full_domain = f"{sub}.{self.domain}"
110
- try:
111
- dns.resolver.resolve(full_domain, 'A')
112
- found_subdomains.add(full_domain)
113
- self.log("INFO", f"[Subdomains] Found via brute-force: {full_domain}")
114
- except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.resolver.Timeout):
115
- pass
116
- except Exception as e:
117
- self.log("ERROR", f"[Subdomains] DNS resolve error: {e}")
118
-
119
- return found_subdomains
120
-
121
- def check_dns_records(self, subdomain):
122
- try:
123
- try:
124
- ns_answers = dns.resolver.resolve(subdomain, 'NS')
125
- ns_servers = [rdata.target.to_text() for rdata in ns_answers]
126
- if len(ns_servers) > 0:
127
- self.log("INFO", f"[Subdomains] {subdomain} NS: {', '.join(ns_servers[:3])}")
128
- except Exception as e:
129
- self.log("ERROR", f"[Subdomains] NS record check error: {e}")
130
-
131
- try:
132
- mx_answers = dns.resolver.resolve(subdomain, 'MX')
133
- mx_servers = [rdata.exchange.to_text() for rdata in mx_answers]
134
- if len(mx_servers) > 0:
135
- self.log("INFO", f"[Subdomains] {subdomain} MX: {', '.join(mx_servers[:3])}")
136
- except Exception as e:
137
- self.log("ERROR", f"[Subdomains] MX record check error: {e}")
138
-
139
- try:
140
- txt_answers = dns.resolver.resolve(subdomain, 'TXT')
141
- txt_records = [rdata.to_text() for rdata in txt_answers]
142
- if len(txt_records) > 0:
143
- for txt in txt_records:
144
- if "v=spf1" in txt:
145
- self.log("INFO", f"[Subdomains] {subdomain} SPF: {txt[:80]}")
146
- elif "v=dkim" in txt:
147
- self.log("INFO", f"[Subdomains] {subdomain} DKIM: {txt[:80]}")
148
- except Exception as e:
149
- self.log("ERROR", f"[Subdomains] TXT record check error: {e}")
150
-
151
- except Exception as e:
152
- self.log("ERROR", f"[Subdomains] DNS records check error: {e}")
153
-
154
- def check_takeover(self, subdomain):
155
- try:
156
- answers = dns.resolver.resolve(subdomain, 'CNAME')
157
- for rdata in answers:
158
- cname = rdata.target.to_text().lower().rstrip('.')
159
-
160
- for service, error_signature in TAKEOVER_SIGNATURES.items():
161
- if service in cname:
162
- try:
163
- body, status, _ = self._make_request(
164
- f"http://{subdomain}",
165
- timeout=5,
166
- return_response_obj=True,
167
- )
168
- if body and error_signature in body:
169
- self.log("CRITICAL", f"[Subdomains] TAKEOVER FOUND: {subdomain} points to unclaimed {service}")
170
- self.add_vuln(
171
- title=f"Subdomain Takeover ({service})",
172
- severity="Critical", category="Configuration", cvss_score=9.1,
173
- description=f"The subdomain `{subdomain}` has a CNAME record pointing to `{cname}`, but the service at that destination is unclaimed. An attacker can register this service and completely hijack the subdomain.",
174
- remediation=f"Immediately remove the dangling CNAME record for `{subdomain}` from your DNS zone file, or claim the resource at `{service}`.",
175
- confidence="Confirmed",
176
- evidence=f"CNAME: {cname}, Response contains: {error_signature}",
177
- )
178
- except Exception as e:
179
- self.log("ERROR", f"[Subdomains] Takeover HTTP check error: {e}")
180
- except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.resolver.Timeout):
181
- pass
182
- except Exception as e:
183
- self.log("ERROR", f"[Subdomains] Takeover check error: {e}")
184
-
185
- def _check_http_subdomains(self, subdomains):
186
- self.log("INFO", "[Subdomains] Probing discovered subdomains via HTTP...")
187
- requests_list = []
188
- for sub in subdomains:
189
- for scheme in ("http", "https"):
190
- url = f"{scheme}://{sub}"
191
- requests_list.append({"url": url, "timeout": 5, "_subdomain": sub, "_scheme": scheme})
192
-
193
- results = self._make_async_requests(requests_list, max_workers=15)
194
-
195
- live_subs = set()
196
- for req, body, status in results:
197
- sub = req.get("_subdomain", "")
198
- scheme = req.get("_scheme", "")
199
- if status != 0:
200
- live_subs.add(sub)
201
- self.log("SUCCESS", f"[Subdomains] Live HTTP: {scheme}://{sub} (HTTP {status})")
202
-
203
- if live_subs:
204
- self.log("SUCCESS", f"[Subdomains] {len(live_subs)} subdomains are live via HTTP/HTTPS.")
205
- return live_subs
206
-
207
- def _run_subfinder(self):
208
- self.log("INFO", "[Subdomains] Running Subfinder v2 for passive discovery...")
209
- found_subdomains = set()
210
-
211
- project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
212
- local_subfinder = os.path.join(project_root, "Tools", "subfinder.exe")
213
- subfinder_path = local_subfinder if os.path.exists(local_subfinder) else shutil.which("subfinder") or shutil.which("subfinder.exe")
214
-
215
- if not subfinder_path:
216
- self.log("WARNING", "[Subfinder] 'subfinder' binary not found. Skipping Subfinder recon.")
217
- return found_subdomains
218
-
219
- try:
220
- cmd = [
221
- subfinder_path,
222
- "-d", self.domain,
223
- "-silent", # only output subdomains
224
- "-timeout", "30", # per-source timeout in seconds
225
- ]
226
- self.log("INFO", f"[Subfinder] Command: subfinder -d {self.domain} -silent")
227
-
228
- process = subprocess.run(
229
- cmd,
230
- capture_output=True,
231
- text=True,
232
- timeout=150, # 2.5 min overall timeout
233
- encoding="utf-8",
234
- errors="replace",
235
- )
236
-
237
- for line in process.stdout.splitlines():
238
- sub = line.strip().lower()
239
- # Only accept valid subdomain lines (ends with target domain, no spaces)
240
- if sub.endswith(self.domain) and " " not in sub and len(sub) > len(self.domain):
241
- found_subdomains.add(sub)
242
-
243
- if found_subdomains:
244
- self.log("SUCCESS", f"[Subfinder] Discovered {len(found_subdomains)} subdomains.")
245
- else:
246
- self.log("INFO", "[Subfinder] No subdomains discovered.")
247
-
248
- except subprocess.TimeoutExpired:
249
- self.log("WARNING", "[Subfinder] Scan timed out after 2.5 minutes.")
250
- except Exception as e:
251
- self.log("ERROR", f"[Subfinder] Execution failed: {e}")
252
-
253
- return found_subdomains
254
-
255
-
256
- def _run_amass(self):
257
- self.log("INFO", "[Subdomains] Running Amass v5 for passive subdomain discovery...")
258
- found_subdomains = set()
259
-
260
- project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
261
- local_amass = os.path.join(project_root, "Tools", "amass_windows_amd64", "amass.exe")
262
- amass_path = local_amass if os.path.exists(local_amass) else shutil.which("amass") or shutil.which("amass.exe")
263
-
264
- if not amass_path:
265
- self.log("WARNING", "[Amass] 'amass' binary not found. Skipping Amass recon.")
266
- return found_subdomains
267
-
268
- try:
269
- # Amass v5 uses 'subs' subcommand (v4 used 'enum -passive')
270
- cmd = [
271
- amass_path,
272
- "subs",
273
- "-d", self.domain,
274
- "-silent", # suppress banner/progress
275
- ]
276
- self.log("INFO", f"[Amass] Command: amass subs -d {self.domain} -silent")
277
-
278
- process = subprocess.run(
279
- cmd,
280
- capture_output=True,
281
- text=True,
282
- timeout=240, # 4 min max for passive enumeration
283
- encoding="utf-8",
284
- errors="replace",
285
- )
286
-
287
- # Amass v5 writes subdomains to stdout, one per line
288
- all_output = (process.stdout or "") + (process.stderr or "")
289
- for line in all_output.splitlines():
290
- sub = line.strip().lower()
291
- # Filter: must end with target domain, no spaces, not a log/info line
292
- if (sub.endswith(self.domain)
293
- and " " not in sub
294
- and not sub.startswith("[")
295
- and len(sub) > len(self.domain)):
296
- found_subdomains.add(sub)
297
-
298
- if found_subdomains:
299
- self.log("SUCCESS", f"[Amass] Discovered {len(found_subdomains)} subdomains.")
300
- else:
301
- self.log("INFO", "[Amass] No new subdomains discovered via passive enumeration.")
302
-
303
- except subprocess.TimeoutExpired:
304
- self.log("WARNING", "[Amass] Scan timed out after 4 minutes.")
305
- except Exception as e:
306
- self.log("ERROR", f"[Amass] Execution failed: {e}")
307
-
308
- return found_subdomains
309
-
310
-
311
- def run(self):
312
- self.log("INFO", f"[Subdomains] Enumerating subdomains for {self.domain}...")
313
- all_subdomains = set()
314
-
315
- self.log("INFO", "[Subdomains] Querying Certificate Transparency logs via crt.sh...")
316
- url = f"https://crt.sh/?q=%.{self.domain}&output=json"
317
- _max_crtsh_attempts = 2
318
- for _attempt in range(_max_crtsh_attempts):
319
- try:
320
- resp = requests.get(url, timeout=10)
321
-
322
- if resp.status_code == 200:
323
- data = resp.json()
324
-
325
- for entry in data:
326
- name_value = entry.get('name_value', '').lower()
327
- if name_value:
328
- for sub in name_value.split('\n'):
329
- sub = sub.strip()
330
- if sub.endswith(self.domain) and not sub.startswith('*'):
331
- all_subdomains.add(sub)
332
-
333
- self.log("SUCCESS", f"[Subdomains] Found {len(all_subdomains)} subdomains via Certificate Transparency.")
334
- else:
335
- self.log("WARNING", f"[Subdomains] crt.sh returned HTTP {resp.status_code}.")
336
- break # Success — no retry needed
337
-
338
- except requests.RequestException as e:
339
- if _attempt < _max_crtsh_attempts - 1:
340
- self.log("WARNING", f"[Subdomains] crt.sh attempt {_attempt+1} failed ({e}), retrying in 3s...")
341
- import time as _time
342
- _time.sleep(3)
343
- else:
344
- self.log("WARNING", f"[Subdomains] Failed to query crt.sh after {_max_crtsh_attempts} attempts: {e}")
345
- except Exception as e:
346
- self.log("WARNING", f"[Subdomains] crt.sh returned invalid JSON or unexpected error: {e}")
347
- break
348
-
349
- brute_subdomains = self.brute_force_subdomains()
350
- all_subdomains.update(brute_subdomains)
351
-
352
- subfinder_subdomains = self._run_subfinder()
353
- all_subdomains.update(subfinder_subdomains)
354
-
355
- amass_subdomains = self._run_amass()
356
- all_subdomains.update(amass_subdomains)
357
-
358
- if all_subdomains:
359
- self.log("SUCCESS", f"[Subdomains] Total subdomains found: {len(all_subdomains)}")
360
-
361
- preview = list(all_subdomains)[:10]
362
- for sub in preview:
363
- self.log("INFO", f" - {sub}")
364
-
365
- if len(all_subdomains) > 10:
366
- self.log("INFO", f" ... and {len(all_subdomains) - 10} more.")
367
-
368
- self.add_vuln(
369
- title=f"Subdomain Enumeration Disclosure",
370
- severity="Low", category="Reconnaissance", cvss_score=0.0,
371
- description=f"Discovered {len(all_subdomains)} active or historical subdomains for {self.domain} via Certificate Transparency logs and brute-forcing.",
372
- remediation="Ensure all subdomains are actively maintained and patched. Remove unused subdomains from DNS.",
373
- confidence="Confirmed",
374
- )
375
-
376
- self.log("INFO", "[Subdomains] Analyzing DNS records for discovered subdomains...")
377
- for sub in list(all_subdomains)[:20]:
378
- self.check_dns_records(sub)
379
-
380
- self.log("INFO", "[Subdomains] Probing discovered subdomains via HTTP...")
381
- self._check_http_subdomains(list(all_subdomains))
382
-
383
- self.log("INFO", "[Subdomains] Checking for subdomain takeover vulnerabilities...")
384
- for sub in all_subdomains:
385
- self.check_takeover(sub)
386
-
387
- else:
388
- self.log("SUCCESS", f"[Subdomains] No subdomains found for {self.domain}.")
389
-
390
- return self.vulns
 
1
+ """
2
+ subdomain_scanner.py — Discovers subdomains via crt.sh and hunts for Takeovers.
3
+ """
4
+ import requests
5
+ import json
6
+ import dns.resolver
7
+ import subprocess
8
+ import os
9
+ import shutil
10
+ from scanners.base_scanner import BaseScanner
11
+
12
+ TAKEOVER_SIGNATURES = {
13
+ "github.io": "There isn't a GitHub Pages site here.",
14
+ "s3.amazonaws.com": "NoSuchBucket",
15
+ "s3-website": "NoSuchBucket",
16
+ "herokuapp.com": "No such app",
17
+ "myshopify.com": "Sorry, this shop is currently unavailable.",
18
+ "wpengine.com": "The site you were looking for couldn't be found.",
19
+ "pantheonsite.io": "The edge router is unable to route the requested request.",
20
+ "zendesk.com": "Help Center Closed",
21
+ "azurewebsites.net": "Error 404",
22
+ "cloudapp.net": "No such app",
23
+ "appspot.com": "Error 404",
24
+ "herokudns.com": "No such app",
25
+ "herokussl.com": "No such app",
26
+ "netlify.app": "Page not found",
27
+ "vercel.app": "Page not found",
28
+ "now.sh": "Page not found",
29
+ "bitbucket.io": "Repository not found",
30
+ "gitlab.io": "Project not found",
31
+ "surge.sh": "Project not found",
32
+ "pages.cloudflare.com": "Error 1004",
33
+ "workers.dev": "404 Not Found",
34
+ "deno.dev": "Not Found",
35
+ "fly.dev": "Not Found",
36
+ "railway.app": "Not Found",
37
+ "render.com": "Not Found",
38
+ "elasticbeanstalk.com": "404 Not Found",
39
+ "cloudfront.net": "404 Not Found",
40
+ "fastly.com": "Fastly error",
41
+ "cloudfunctions.net": "404 Not Found",
42
+ "firebaseapp.com": "Site not found",
43
+ "firebaseio.com": "null",
44
+ "webflow.io": "404 Not Found",
45
+ "wixsite.com": "404 Not Found",
46
+ "squarespace.com": "404 Not Found",
47
+ "wordpress.com": "Do you want to register",
48
+ "blogspot.com": "Blog not found",
49
+ "tumblr.com": "Nothing here",
50
+ "ghost.io": "404 Not Found",
51
+ "hubspot.com": "Page not found",
52
+ "unbouncepages.com": "Page not found",
53
+ "instapage.com": "Page not found",
54
+ "leadpages.net": "Page not found",
55
+ "kajabi.com": "Page not found",
56
+ "teachable.com": "Page not found",
57
+ "thinkific.com": "Page not found",
58
+ "podia.com": "Page not found",
59
+ "uservoice.com": "Page not found",
60
+ "freshdesk.com": "Page not found",
61
+ "intercom.com": "Page not found",
62
+ "drift.com": "Page not found",
63
+ "crisp.chat": "Page not found",
64
+ "tawk.to": "Page not found",
65
+ "typeform.com": "Page not found",
66
+ "paperform.co": "Page not found",
67
+ "jotform.com": "Page not found",
68
+ "formstack.com": "Page not found",
69
+ "wufoo.com": "Page not found",
70
+ "cognitoforms.com": "Page not found",
71
+ "formsite.com": "Page not found",
72
+ "surveygizmo.com": "Page not found",
73
+ "qualtrics.com": "Page not found",
74
+ "survey monkey.com": "Page not found",
75
+ }
76
+
77
+ COMMON_SUBDOMAINS = [
78
+ "www", "mail", "email", "ftp", "sftp", "ssh", "vpn", "remote", "api", "dev", "staging", "test", "uat",
79
+ "prod", "production", "live", "admin", "administrator", "dashboard", "panel", "portal", "console",
80
+ "blog", "news", "media", "static", "assets", "cdn", "img", "images", "video", "videos", "audio",
81
+ "docs", "documentation", "wiki", "help", "support", "kb", "knowledgebase", "faq", "forum",
82
+ "shop", "store", "cart", "checkout", "payment", "billing", "account", "login", "signin", "signup", "register",
83
+ "secure", "auth", "oauth", "sso", "identity", "token", "session", "cookie", "cache",
84
+ "db", "database", "mysql", "postgres", "mongodb", "redis", "elasticsearch", "solr", "search",
85
+ "jenkins", "ci", "cd", "build", "deploy", "git", "svn", "repo", "repository", "code",
86
+ "monitor", "metrics", "logs", "log", "analytics", "stats", "statistics", "report", "reporting",
87
+ "ns1", "ns2", "ns3", "ns4", "dns", "mx", "smtp", "pop", "pop3", "imap",
88
+ "m", "mobile", "app", "application", "web", "webmail", "webdisk", "cpanel", "whm",
89
+ "beta", "alpha", "demo", "sandbox", "lab", "labs", "experimental", "poc", "proof",
90
+ "internal", "private", "public", "external", "partner", "vendor", "client", "customer",
91
+ "hr", "finance", "legal", "marketing", "sales", "support", "it", "ops", "devops",
92
+ "staging1", "staging2", "dev1", "dev2", "test1", "test2", "prod1", "prod2",
93
+ "us-east", "us-west", "eu-west", "eu-central", "ap-south", "ap-northeast",
94
+ "lb", "loadbalancer", "proxy", "gateway", "firewall", "ids", "ips",
95
+ "backup", "archive", "old", "legacy", "v1", "v2", "v3", "version1", "version2",
96
+ "status", "health", "ping", "heartbeat", "uptime", "monitoring",
97
+ ]
98
+
99
+
100
+ class SubdomainScanner(BaseScanner):
101
+ SCANNER_NAME = "Subdomain Enumeration & Takeover Hunter"
102
+ _SCANNER_KEY = "subdomain"
103
+
104
+ def brute_force_subdomains(self):
105
+ self.log("INFO", "[Subdomains] Starting subdomain brute-forcing...")
106
+ found_subdomains = set()
107
+
108
+ for sub in COMMON_SUBDOMAINS[:50]:
109
+ full_domain = f"{sub}.{self.domain}"
110
+ try:
111
+ dns.resolver.resolve(full_domain, 'A')
112
+ found_subdomains.add(full_domain)
113
+ self.log("INFO", f"[Subdomains] Found via brute-force: {full_domain}")
114
+ except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.resolver.Timeout):
115
+ pass
116
+ except Exception as e:
117
+ self.log("ERROR", f"[Subdomains] DNS resolve error: {e}")
118
+
119
+ return found_subdomains
120
+
121
+ def check_dns_records(self, subdomain):
122
+ try:
123
+ try:
124
+ ns_answers = dns.resolver.resolve(subdomain, 'NS')
125
+ ns_servers = [rdata.target.to_text() for rdata in ns_answers]
126
+ if len(ns_servers) > 0:
127
+ self.log("INFO", f"[Subdomains] {subdomain} NS: {', '.join(ns_servers[:3])}")
128
+ except Exception as e:
129
+ self.log("ERROR", f"[Subdomains] NS record check error: {e}")
130
+
131
+ try:
132
+ mx_answers = dns.resolver.resolve(subdomain, 'MX')
133
+ mx_servers = [rdata.exchange.to_text() for rdata in mx_answers]
134
+ if len(mx_servers) > 0:
135
+ self.log("INFO", f"[Subdomains] {subdomain} MX: {', '.join(mx_servers[:3])}")
136
+ except Exception as e:
137
+ self.log("ERROR", f"[Subdomains] MX record check error: {e}")
138
+
139
+ try:
140
+ txt_answers = dns.resolver.resolve(subdomain, 'TXT')
141
+ txt_records = [rdata.to_text() for rdata in txt_answers]
142
+ if len(txt_records) > 0:
143
+ for txt in txt_records:
144
+ if "v=spf1" in txt:
145
+ self.log("INFO", f"[Subdomains] {subdomain} SPF: {txt[:80]}")
146
+ elif "v=dkim" in txt:
147
+ self.log("INFO", f"[Subdomains] {subdomain} DKIM: {txt[:80]}")
148
+ except Exception as e:
149
+ self.log("ERROR", f"[Subdomains] TXT record check error: {e}")
150
+
151
+ except Exception as e:
152
+ self.log("ERROR", f"[Subdomains] DNS records check error: {e}")
153
+
154
+ def check_takeover(self, subdomain):
155
+ try:
156
+ answers = dns.resolver.resolve(subdomain, 'CNAME')
157
+ for rdata in answers:
158
+ cname = rdata.target.to_text().lower().rstrip('.')
159
+
160
+ for service, error_signature in TAKEOVER_SIGNATURES.items():
161
+ if service in cname:
162
+ try:
163
+ body, status, _ = self._make_request(
164
+ f"http://{subdomain}",
165
+ timeout=5,
166
+ return_response_obj=True,
167
+ )
168
+ if body and error_signature in body:
169
+ self.log("CRITICAL", f"[Subdomains] TAKEOVER FOUND: {subdomain} points to unclaimed {service}")
170
+ self.add_vuln(
171
+ title=f"Subdomain Takeover ({service})",
172
+ severity="Critical", category="Configuration", cvss_score=9.1,
173
+ description=f"The subdomain `{subdomain}` has a CNAME record pointing to `{cname}`, but the service at that destination is unclaimed. An attacker can register this service and completely hijack the subdomain.",
174
+ remediation=f"Immediately remove the dangling CNAME record for `{subdomain}` from your DNS zone file, or claim the resource at `{service}`.",
175
+ confidence="Confirmed",
176
+ evidence=f"CNAME: {cname}, Response contains: {error_signature}",
177
+ )
178
+ except Exception as e:
179
+ self.log("ERROR", f"[Subdomains] Takeover HTTP check error: {e}")
180
+ except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN, dns.resolver.NoNameservers, dns.resolver.Timeout):
181
+ pass
182
+ except Exception as e:
183
+ self.log("ERROR", f"[Subdomains] Takeover check error: {e}")
184
+
185
+ def _check_http_subdomains(self, subdomains):
186
+ self.log("INFO", "[Subdomains] Probing discovered subdomains via HTTP...")
187
+ requests_list = []
188
+ for sub in subdomains:
189
+ for scheme in ("http", "https"):
190
+ url = f"{scheme}://{sub}"
191
+ requests_list.append({"url": url, "timeout": 5, "_subdomain": sub, "_scheme": scheme})
192
+
193
+ results = self._make_async_requests(requests_list, max_workers=15)
194
+
195
+ live_subs = set()
196
+ for req, body, status in results:
197
+ sub = req.get("_subdomain", "")
198
+ scheme = req.get("_scheme", "")
199
+ if status != 0:
200
+ live_subs.add(sub)
201
+ self.log("SUCCESS", f"[Subdomains] Live HTTP: {scheme}://{sub} (HTTP {status})")
202
+
203
+ if live_subs:
204
+ self.log("SUCCESS", f"[Subdomains] {len(live_subs)} subdomains are live via HTTP/HTTPS.")
205
+ return live_subs
206
+
207
+ def _run_subfinder(self):
208
+ self.log("INFO", "[Subdomains] Running Subfinder v2 for passive discovery...")
209
+ found_subdomains = set()
210
+
211
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
212
+ local_subfinder = os.path.join(project_root, "Tools", "subfinder.exe")
213
+ subfinder_path = local_subfinder if os.path.exists(local_subfinder) else shutil.which("subfinder") or shutil.which("subfinder.exe")
214
+
215
+ if not subfinder_path:
216
+ self.log("WARNING", "[Subfinder] 'subfinder' binary not found. Skipping Subfinder recon.")
217
+ return found_subdomains
218
+
219
+ try:
220
+ cmd = [
221
+ subfinder_path,
222
+ "-d", self.domain,
223
+ "-silent", # only output subdomains
224
+ "-timeout", "30", # per-source timeout in seconds
225
+ ]
226
+ self.log("INFO", f"[Subfinder] Command: subfinder -d {self.domain} -silent")
227
+
228
+ process = subprocess.run(
229
+ cmd,
230
+ capture_output=True,
231
+ text=True,
232
+ timeout=60, # 1 min max (was 2.5 min too slow for pipeline)
233
+ encoding="utf-8",
234
+ errors="replace",
235
+ )
236
+
237
+ for line in process.stdout.splitlines():
238
+ sub = line.strip().lower()
239
+ # Only accept valid subdomain lines (ends with target domain, no spaces)
240
+ if sub.endswith(self.domain) and " " not in sub and len(sub) > len(self.domain):
241
+ found_subdomains.add(sub)
242
+
243
+ if found_subdomains:
244
+ self.log("SUCCESS", f"[Subfinder] Discovered {len(found_subdomains)} subdomains.")
245
+ else:
246
+ self.log("INFO", "[Subfinder] No subdomains discovered.")
247
+
248
+ except subprocess.TimeoutExpired:
249
+ self.log("WARNING", "[Subfinder] Scan timed out after 60s.")
250
+ except Exception as e:
251
+ self.log("ERROR", f"[Subfinder] Execution failed: {e}")
252
+
253
+ return found_subdomains
254
+
255
+
256
+ def _run_amass(self):
257
+ self.log("INFO", "[Subdomains] Running Amass v5 for passive subdomain discovery...")
258
+ found_subdomains = set()
259
+
260
+ project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
261
+ local_amass = os.path.join(project_root, "Tools", "amass_windows_amd64", "amass.exe")
262
+ amass_path = local_amass if os.path.exists(local_amass) else shutil.which("amass") or shutil.which("amass.exe")
263
+
264
+ if not amass_path:
265
+ self.log("WARNING", "[Amass] 'amass' binary not found. Skipping Amass recon.")
266
+ return found_subdomains
267
+
268
+ try:
269
+ # Amass v5 uses 'subs' subcommand (v4 used 'enum -passive')
270
+ cmd = [
271
+ amass_path,
272
+ "subs",
273
+ "-d", self.domain,
274
+ "-silent", # suppress banner/progress
275
+ ]
276
+ self.log("INFO", f"[Amass] Command: amass subs -d {self.domain} -silent")
277
+
278
+ process = subprocess.run(
279
+ cmd,
280
+ capture_output=True,
281
+ text=True,
282
+ timeout=60, # 1 min max (was 4 min — too slow for pipeline)
283
+ encoding="utf-8",
284
+ errors="replace",
285
+ )
286
+
287
+ # Amass v5 writes subdomains to stdout, one per line
288
+ all_output = (process.stdout or "") + (process.stderr or "")
289
+ for line in all_output.splitlines():
290
+ sub = line.strip().lower()
291
+ # Filter: must end with target domain, no spaces, not a log/info line
292
+ if (sub.endswith(self.domain)
293
+ and " " not in sub
294
+ and not sub.startswith("[")
295
+ and len(sub) > len(self.domain)):
296
+ found_subdomains.add(sub)
297
+
298
+ if found_subdomains:
299
+ self.log("SUCCESS", f"[Amass] Discovered {len(found_subdomains)} subdomains.")
300
+ else:
301
+ self.log("INFO", "[Amass] No new subdomains discovered via passive enumeration.")
302
+
303
+ except subprocess.TimeoutExpired:
304
+ self.log("WARNING", "[Amass] Scan timed out after 60s.")
305
+ except Exception as e:
306
+ self.log("ERROR", f"[Amass] Execution failed: {e}")
307
+
308
+ return found_subdomains
309
+
310
+
311
+ def run(self):
312
+ self.log("INFO", f"[Subdomains] Enumerating subdomains for {self.domain}...")
313
+ all_subdomains = set()
314
+
315
+ self.log("INFO", "[Subdomains] Querying Certificate Transparency logs via crt.sh...")
316
+ url = f"https://crt.sh/?q=%.{self.domain}&output=json"
317
+ _max_crtsh_attempts = 2
318
+ for _attempt in range(_max_crtsh_attempts):
319
+ try:
320
+ resp = requests.get(url, timeout=10)
321
+
322
+ if resp.status_code == 200:
323
+ data = resp.json()
324
+
325
+ for entry in data:
326
+ name_value = entry.get('name_value', '').lower()
327
+ if name_value:
328
+ for sub in name_value.split('\n'):
329
+ sub = sub.strip()
330
+ if sub.endswith(self.domain) and not sub.startswith('*'):
331
+ all_subdomains.add(sub)
332
+
333
+ self.log("SUCCESS", f"[Subdomains] Found {len(all_subdomains)} subdomains via Certificate Transparency.")
334
+ else:
335
+ self.log("WARNING", f"[Subdomains] crt.sh returned HTTP {resp.status_code}.")
336
+ break # Success — no retry needed
337
+
338
+ except requests.RequestException as e:
339
+ if _attempt < _max_crtsh_attempts - 1:
340
+ self.log("WARNING", f"[Subdomains] crt.sh attempt {_attempt+1} failed ({e}), retrying...")
341
+ # no sleep — don't block the scan pipeline
342
+ else:
343
+ self.log("WARNING", f"[Subdomains] Failed to query crt.sh after {_max_crtsh_attempts} attempts: {e}")
344
+ except Exception as e:
345
+ self.log("WARNING", f"[Subdomains] crt.sh returned invalid JSON or unexpected error: {e}")
346
+ break
347
+
348
+ brute_subdomains = self.brute_force_subdomains()
349
+ all_subdomains.update(brute_subdomains)
350
+
351
+ subfinder_subdomains = self._run_subfinder()
352
+ all_subdomains.update(subfinder_subdomains)
353
+
354
+ amass_subdomains = self._run_amass()
355
+ all_subdomains.update(amass_subdomains)
356
+
357
+ if all_subdomains:
358
+ self.log("SUCCESS", f"[Subdomains] Total subdomains found: {len(all_subdomains)}")
359
+
360
+ preview = list(all_subdomains)[:10]
361
+ for sub in preview:
362
+ self.log("INFO", f" - {sub}")
363
+
364
+ if len(all_subdomains) > 10:
365
+ self.log("INFO", f" ... and {len(all_subdomains) - 10} more.")
366
+
367
+ self.add_vuln(
368
+ title=f"Subdomain Enumeration Disclosure",
369
+ severity="Low", category="Reconnaissance", cvss_score=0.0,
370
+ description=f"Discovered {len(all_subdomains)} active or historical subdomains for {self.domain} via Certificate Transparency logs and brute-forcing.",
371
+ remediation="Ensure all subdomains are actively maintained and patched. Remove unused subdomains from DNS.",
372
+ confidence="Confirmed",
373
+ )
374
+
375
+ self.log("INFO", "[Subdomains] Analyzing DNS records for discovered subdomains...")
376
+ for sub in list(all_subdomains)[:20]:
377
+ self.check_dns_records(sub)
378
+
379
+ self.log("INFO", "[Subdomains] Probing discovered subdomains via HTTP...")
380
+ self._check_http_subdomains(list(all_subdomains))
381
+
382
+ self.log("INFO", "[Subdomains] Checking for subdomain takeover vulnerabilities...")
383
+ for sub in all_subdomains:
384
+ self.check_takeover(sub)
385
+
386
+ else:
387
+ self.log("SUCCESS", f"[Subdomains] No subdomains found for {self.domain}.")
388
+
389
+ return self.vulns