detecti-cli 2.0.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.
Files changed (64) hide show
  1. detecti/__init__.py +0 -0
  2. detecti/cli.py +649 -0
  3. detecti/config.py +188 -0
  4. detecti/core/__init__.py +1 -0
  5. detecti/core/database/__init__.py +5 -0
  6. detecti/core/database/config_db.py +73 -0
  7. detecti/core/database/schema.py +136 -0
  8. detecti/core/database/storage.py +1388 -0
  9. detecti/core/engine.py +1032 -0
  10. detecti/core/models.py +278 -0
  11. detecti/data/config.sqlite +0 -0
  12. detecti/data/dbs/.gitkeep +2 -0
  13. detecti/data/dbs/example.com.sqlite +0 -0
  14. detecti/modules/__init__.py +29 -0
  15. detecti/modules/base.py +57 -0
  16. detecti/modules/censys.py +813 -0
  17. detecti/modules/crtsh.py +98 -0
  18. detecti/modules/exploitdb.py +138 -0
  19. detecti/modules/masscan.py +561 -0
  20. detecti/modules/nuclei.py +449 -0
  21. detecti/modules/nvd.py +300 -0
  22. detecti/modules/reverse_whois.py +225 -0
  23. detecti/modules/shodan.py +412 -0
  24. detecti/reporters/__init__.py +7 -0
  25. detecti/reporters/csv_reporter.py +74 -0
  26. detecti/reporters/html_reporter.py +356 -0
  27. detecti/reporters/json_reporter.py +26 -0
  28. detecti/reporters/markdown_reporter.py +203 -0
  29. detecti/utils/__init__.py +1 -0
  30. detecti/utils/http.py +294 -0
  31. detecti/utils/logger.py +378 -0
  32. detecti/utils/setup.py +453 -0
  33. detecti/web/__init__.py +6 -0
  34. detecti/web/api/__init__.py +1 -0
  35. detecti/web/api/auth.py +109 -0
  36. detecti/web/api/graph_builder.py +901 -0
  37. detecti/web/api/routes.py +1602 -0
  38. detecti/web/process_manager.py +283 -0
  39. detecti/web/server.py +183 -0
  40. detecti/web/static/android-chrome-192x192.png +0 -0
  41. detecti/web/static/android-chrome-512x512.png +0 -0
  42. detecti/web/static/apple-touch-icon.png +0 -0
  43. detecti/web/static/css/__init__.py +1 -0
  44. detecti/web/static/css/dashboard.css +3802 -0
  45. detecti/web/static/favicon-16x16.png +0 -0
  46. detecti/web/static/favicon-32x32.png +0 -0
  47. detecti/web/static/favicon.ico +0 -0
  48. detecti/web/static/img/DetecTI_Security_Logo.png +0 -0
  49. detecti/web/static/img/detecti-ico.png +0 -0
  50. detecti/web/static/index.html +677 -0
  51. detecti/web/static/js/__init__.py +1 -0
  52. detecti/web/static/js/api.js +177 -0
  53. detecti/web/static/js/cytoscape-cose-bilkent.js +458 -0
  54. detecti/web/static/js/cytoscape-dagre.js +397 -0
  55. detecti/web/static/js/cytoscape.min.js +31 -0
  56. detecti/web/static/js/dagre.min.js +3809 -0
  57. detecti/web/static/js/graph.js +7439 -0
  58. detecti/web/static/js/lucide.min.js +12 -0
  59. detecti/web/static/login.html +290 -0
  60. detecti/web/static/site.webmanifest +1 -0
  61. detecti_cli-2.0.0.dist-info/METADATA +554 -0
  62. detecti_cli-2.0.0.dist-info/RECORD +64 -0
  63. detecti_cli-2.0.0.dist-info/WHEEL +4 -0
  64. detecti_cli-2.0.0.dist-info/entry_points.txt +3 -0
detecti/modules/nvd.py ADDED
@@ -0,0 +1,300 @@
1
+ """NVD (National Vulnerability Database) + EPSS + CISA KEV Intelligence Module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from typing import Any, Dict, List, Optional, Set
8
+ from detecti.config import settings
9
+ from detecti.core.models import (
10
+ CISAKEVData,
11
+ EPSSData,
12
+ Finding,
13
+ FindingType,
14
+ SeverityLevel,
15
+ VulnerabilityData,
16
+ )
17
+ from detecti.modules.base import BaseModule
18
+
19
+ logger = logging.getLogger("detecti.nvd")
20
+
21
+ CWE_NAMES: Dict[str, str] = {
22
+ "CWE-20": "Improper Input Validation",
23
+ "CWE-22": "Path Traversal",
24
+ "CWE-74": "Injection",
25
+ "CWE-77": "Command Injection",
26
+ "CWE-78": "OS Command Injection",
27
+ "CWE-79": "Cross-site Scripting (XSS)",
28
+ "CWE-89": "SQL Injection",
29
+ "CWE-94": "Code Injection",
30
+ "CWE-119": "Memory Buffer Restriction Flaw",
31
+ "CWE-120": "Classic Buffer Overflow",
32
+ "CWE-121": "Stack-based Buffer Overflow",
33
+ "CWE-122": "Heap-based Buffer Overflow",
34
+ "CWE-125": "Out-of-bounds Read",
35
+ "CWE-134": "Use of Externally-Controlled Format String",
36
+ "CWE-190": "Integer Overflow or Wraparound",
37
+ "CWE-200": "Exposure of Sensitive Information",
38
+ "CWE-209": "Error Message Information Leak",
39
+ "CWE-250": "Execution with Unnecessary Privileges",
40
+ "CWE-264": "Permissions, Privileges, and Access Controls",
41
+ "CWE-269": "Improper Privilege Management",
42
+ "CWE-276": "Incorrect Default Permissions",
43
+ "CWE-284": "Improper Access Control",
44
+ "CWE-287": "Improper Authentication",
45
+ "CWE-290": "Authentication Bypass by Spoofing",
46
+ "CWE-295": "Improper Certificate Validation",
47
+ "CWE-306": "Missing Authentication for Critical Function",
48
+ "CWE-307": "Improper Restriction of Excessive Auth Attempts",
49
+ "CWE-311": "Missing Encryption of Sensitive Data",
50
+ "CWE-312": "Cleartext Storage of Sensitive Information",
51
+ "CWE-319": "Cleartext Transmission of Sensitive Information",
52
+ "CWE-326": "Inadequate Encryption Strength",
53
+ "CWE-327": "Use of a Broken or Risky Cryptographic Algorithm",
54
+ "CWE-330": "Use of Insufficiently Random Values",
55
+ "CWE-352": "Cross-Site Request Forgery (CSRF)",
56
+ "CWE-362": "Race Condition",
57
+ "CWE-384": "Session Fixation",
58
+ "CWE-400": "Uncontrolled Resource Consumption (DoS)",
59
+ "CWE-415": "Double Free",
60
+ "CWE-416": "Use After Free",
61
+ "CWE-426": "Untrusted Search Path",
62
+ "CWE-434": "Unrestricted File Upload",
63
+ "CWE-476": "NULL Pointer Dereference",
64
+ "CWE-502": "Deserialization of Untrusted Data",
65
+ "CWE-521": "Weak Password Requirements",
66
+ "CWE-522": "Insufficiently Protected Credentials",
67
+ "CWE-532": "Insertion of Sensitive Information into Log File",
68
+ "CWE-601": "Open Redirect",
69
+ "CWE-611": "XML External Entity Reference (XXE)",
70
+ "CWE-613": "Insufficient Session Expiration",
71
+ "CWE-617": "Reachable Assertion",
72
+ "CWE-640": "Weak Password Recovery Mechanism",
73
+ "CWE-668": "Exposure of Resource to Wrong Sphere",
74
+ "CWE-732": "Incorrect Permission Assignment for Critical Resource",
75
+ "CWE-770": "Allocation of Resources Without Limits or Throttling",
76
+ "CWE-787": "Out-of-bounds Write",
77
+ "CWE-798": "Use of Hard-coded Credentials",
78
+ "CWE-829": "Inclusion of Functionality from Untrusted Sphere",
79
+ "CWE-862": "Missing Authorization",
80
+ "CWE-863": "Incorrect Authorization",
81
+ "CWE-917": "Expression Language Injection",
82
+ "CWE-918": "Server-Side Request Forgery (SSRF)",
83
+ "CWE-922": "Insecure Storage of Sensitive Information",
84
+ "CWE-1021": "Improper Restriction of UI Layers ('Clickjacking')",
85
+ "CWE-1188": "Insecure Default Initialization of Resource",
86
+ "CWE-1236": "Formula Elements in CSV Injection",
87
+ "CWE-1321": "Prototype Pollution",
88
+ "NVD-CWE-noinfo": "Insufficient Information",
89
+ "NVD-CWE-Other": "Other Weakness",
90
+ }
91
+
92
+
93
+ class NVDModule(BaseModule):
94
+ """Vulnerability enrichment module combining NVD 2.0, FIRST EPSS, and CISA KEV."""
95
+
96
+ name: str = "nvd"
97
+ description: str = "NVD CVSS severity, EPSS probability, and CISA KEV catalog enrichment"
98
+ category: str = "vuln"
99
+
100
+ def __init__(self, *args: Any, **kwargs: Any):
101
+ super().__init__(*args, **kwargs)
102
+ self._cisa_kev_cache: Optional[Dict[str, Dict[str, Any]]] = None
103
+ self._cisa_lock = asyncio.Lock()
104
+
105
+ def is_configured(self) -> bool:
106
+ """Check if NVD custom API key is configured (optional, public rate limit fallback)."""
107
+ from config import is_placeholder_key
108
+ return bool(settings.nvd_api_key and not is_placeholder_key(settings.nvd_api_key))
109
+
110
+ async def run(
111
+ self,
112
+ target: str,
113
+ context: Optional[Dict[str, Any]] = None,
114
+ ) -> List[Finding]:
115
+ """Enrich a CVE or list of CVEs."""
116
+ target = target.strip()
117
+ cves_to_enrich: Set[str] = set()
118
+
119
+ if target.upper().startswith("CVE-"):
120
+ cves_to_enrich.add(target.upper())
121
+
122
+ # Check context for CVEs discovered by previous modules (e.g. Shodan)
123
+ if context and "cves" in context:
124
+ for c in context["cves"]:
125
+ if isinstance(c, str) and c.upper().startswith("CVE-"):
126
+ cves_to_enrich.add(c.upper())
127
+
128
+ if not cves_to_enrich:
129
+ logger.debug("No CVEs provided to NVD module for enrichment.")
130
+ return []
131
+
132
+ # Ensure CISA KEV is loaded into cache
133
+ await self._ensure_cisa_kev_loaded()
134
+
135
+ findings: List[Finding] = []
136
+ for cve_id in sorted(cves_to_enrich):
137
+ vuln_data = await self.enrich_cve(cve_id)
138
+ if vuln_data:
139
+ findings.append(
140
+ Finding(
141
+ type=FindingType.VULNERABILITY,
142
+ target=target,
143
+ value=cve_id,
144
+ source="NVD+EPSS+CISA",
145
+ vulnerability=vuln_data,
146
+ )
147
+ )
148
+
149
+ return findings
150
+
151
+ async def _ensure_cisa_kev_loaded(self) -> None:
152
+ """Fetch and index CISA KEV catalog once into memory."""
153
+ if self._cisa_kev_cache is not None:
154
+ return
155
+
156
+ async with self._cisa_lock:
157
+ if self._cisa_kev_cache is not None:
158
+ return
159
+
160
+ self._cisa_kev_cache = {}
161
+ try:
162
+ data = await self.http_client.get_json(url=settings.cisa_kev_url, timeout=20.0)
163
+ if data and "vulnerabilities" in data:
164
+ for item in data["vulnerabilities"]:
165
+ cve = item.get("cveID", "").upper()
166
+ if cve:
167
+ self._cisa_kev_cache[cve] = item
168
+ logger.info(f"Loaded {len(self._cisa_kev_cache)} entries from CISA KEV catalog.")
169
+ except Exception as exc:
170
+ logger.warning(f"Failed to fetch CISA KEV catalog: {exc}")
171
+
172
+ async def get_epss_score(self, cve_id: str) -> Optional[EPSSData]:
173
+ """Fetch EPSS probability score and percentile from FIRST.org API."""
174
+ url = settings.epss_api_url
175
+ params = {"cve": cve_id}
176
+
177
+ try:
178
+ data = await self.http_client.get_json(url=url, params=params, timeout=10.0)
179
+ if data and "data" in data and len(data["data"]) > 0:
180
+ item = data["data"][0]
181
+ return EPSSData(
182
+ epss_score=float(item.get("epss", 0.0)),
183
+ epss_percentile=float(item.get("percentile", 0.0)),
184
+ date=item.get("date"),
185
+ )
186
+ except Exception as exc:
187
+ logger.debug(f"Error fetching EPSS for {cve_id}: {exc}")
188
+ return None
189
+
190
+ def get_cisa_kev_data(self, cve_id: str) -> Optional[CISAKEVData]:
191
+ """Cross-reference CVE ID against the loaded CISA KEV catalog."""
192
+ if not self._cisa_kev_cache or cve_id not in self._cisa_kev_cache:
193
+ return None
194
+
195
+ entry = self._cisa_kev_cache[cve_id]
196
+ return CISAKEVData(
197
+ in_cisa_kev=True,
198
+ vendor_project=entry.get("vendorProject"),
199
+ product=entry.get("product"),
200
+ vulnerability_name=entry.get("vulnerabilityName"),
201
+ date_added=entry.get("dateAdded"),
202
+ due_date=entry.get("dueDate"),
203
+ required_action=entry.get("requiredAction"),
204
+ known_ransomware_campaign_use=entry.get("knownRansomwareCampaignUse"),
205
+ notes=entry.get("notes"),
206
+ )
207
+
208
+ async def enrich_cve(self, cve_id: str) -> VulnerabilityData:
209
+ """Fetch NVD CVSS details and combine with EPSS & CISA KEV."""
210
+ # Ensure CISA KEV catalog is available in cache
211
+ await self._ensure_cisa_kev_loaded()
212
+
213
+ # Query NVD API 2.0
214
+ nvd_url = settings.nvd_api_url
215
+ headers: Dict[str, str] = {}
216
+ if settings.nvd_api_key:
217
+ headers["apiKey"] = settings.nvd_api_key
218
+
219
+ params = {"cveId": cve_id}
220
+ nvd_data = await self.http_client.get_json(url=nvd_url, headers=headers, params=params, timeout=20.0)
221
+
222
+ cvss_score: Optional[float] = None
223
+ cvss_version: Optional[str] = None
224
+ cvss_severity = SeverityLevel.UNKNOWN
225
+ description: Optional[str] = None
226
+ cwe_id: Optional[str] = None
227
+ cwe_name: Optional[str] = None
228
+ references: List[str] = []
229
+
230
+ if nvd_data and "vulnerabilities" in nvd_data and len(nvd_data["vulnerabilities"]) > 0:
231
+ cve_entry = nvd_data["vulnerabilities"][0].get("cve", {})
232
+
233
+ # Descriptions
234
+ for d in cve_entry.get("descriptions", []):
235
+ if d.get("lang") == "en":
236
+ description = d.get("value")
237
+ break
238
+
239
+ # References
240
+ for ref in cve_entry.get("references", []):
241
+ ref_url = ref.get("url")
242
+ if ref_url:
243
+ references.append(ref_url)
244
+
245
+ # Weaknesses (CWE)
246
+ cwe_ids: List[str] = []
247
+ cwe_names_list: List[str] = []
248
+ for w in cve_entry.get("weaknesses", []):
249
+ for d in w.get("description", []):
250
+ val = d.get("value")
251
+ if val and val not in cwe_ids:
252
+ cwe_ids.append(val)
253
+ val_upper = val.upper()
254
+ resolved_name = CWE_NAMES.get(val, CWE_NAMES.get(val_upper, val))
255
+ if resolved_name not in cwe_names_list:
256
+ cwe_names_list.append(resolved_name)
257
+
258
+ if cwe_ids:
259
+ cwe_id = ", ".join(cwe_ids)
260
+ if cwe_names_list:
261
+ cwe_name = ", ".join(cwe_names_list)
262
+
263
+ # CVSS Metrics (Prioritize v3.1, then v3.0, then v2.0)
264
+ metrics = cve_entry.get("metrics", {})
265
+ if "cvssMetricV31" in metrics and metrics["cvssMetricV31"]:
266
+ primary_metric = metrics["cvssMetricV31"][0].get("cvssData", {})
267
+ cvss_score = float(primary_metric.get("baseScore", 0.0))
268
+ cvss_version = "3.1"
269
+ sev_raw = primary_metric.get("baseSeverity", "UNKNOWN").upper()
270
+ cvss_severity = SeverityLevel(sev_raw) if sev_raw in SeverityLevel.__members__ else SeverityLevel.UNKNOWN
271
+ elif "cvssMetricV30" in metrics and metrics["cvssMetricV30"]:
272
+ primary_metric = metrics["cvssMetricV30"][0].get("cvssData", {})
273
+ cvss_score = float(primary_metric.get("baseScore", 0.0))
274
+ cvss_version = "3.0"
275
+ sev_raw = primary_metric.get("baseSeverity", "UNKNOWN").upper()
276
+ cvss_severity = SeverityLevel(sev_raw) if sev_raw in SeverityLevel.__members__ else SeverityLevel.UNKNOWN
277
+ elif "cvssMetricV2" in metrics and metrics["cvssMetricV2"]:
278
+ primary_metric = metrics["cvssMetricV2"][0]
279
+ cvss_score = float(primary_metric.get("cvssData", {}).get("baseScore", 0.0))
280
+ cvss_version = "2.0"
281
+ sev_raw = primary_metric.get("baseSeverity", "UNKNOWN").upper()
282
+ cvss_severity = SeverityLevel(sev_raw) if sev_raw in SeverityLevel.__members__ else SeverityLevel.UNKNOWN
283
+
284
+ # Concurrently fetch EPSS and check CISA KEV
285
+ epss_task = asyncio.create_task(self.get_epss_score(cve_id))
286
+ cisa_kev_data = self.get_cisa_kev_data(cve_id)
287
+ epss_data = await epss_task
288
+
289
+ return VulnerabilityData(
290
+ cve_id=cve_id,
291
+ cvss_score=cvss_score,
292
+ cvss_version=cvss_version,
293
+ cvss_severity=cvss_severity,
294
+ cwe_id=cwe_id,
295
+ cwe_name=cwe_name,
296
+ description=description,
297
+ epss=epss_data,
298
+ cisa_kev=cisa_kev_data,
299
+ references=references,
300
+ )
@@ -0,0 +1,225 @@
1
+ """Reverse WHOIS and Organization Correlation Module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from typing import Any, Dict, List, Optional, Set
8
+ from detecti.config import settings
9
+ from detecti.core.models import Finding, FindingType
10
+ from detecti.modules.base import BaseModule
11
+
12
+ logger = logging.getLogger("detecti.reverse_whois")
13
+
14
+
15
+ class ReverseWhoisModule(BaseModule):
16
+ """Discovers correlated domains owned by the same organization or registrant.
17
+
18
+ Employs a hybrid strategy:
19
+ - Paid/Structured API: WhoisFreaks (if WHOISFREAKS_API_KEY configured)
20
+ - Free Fallbacks: HackerTarget Reverse IP / WHOIS & RDAP queries
21
+ """
22
+
23
+ name: str = "reverse_whois"
24
+ description: str = "Reverse WHOIS & Organization domain correlation"
25
+ category: str = "osint"
26
+
27
+ EMAIL_REGEX = re.compile(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")
28
+ DOMAIN_REGEX = re.compile(
29
+ r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$"
30
+ )
31
+
32
+ def is_configured(self) -> bool:
33
+ """Check if WhoisFreaks paid API is configured (optional - free fallback available)."""
34
+ from config import is_placeholder_key
35
+ return bool(settings.whoisfreaks_api_key and not is_placeholder_key(settings.whoisfreaks_api_key))
36
+
37
+ async def run(
38
+ self,
39
+ target: str,
40
+ context: Optional[Dict[str, Any]] = None,
41
+ ) -> List[Finding]:
42
+ """Execute reverse WHOIS query based on target type (email, org, or domain/ip)."""
43
+ target = target.strip()
44
+ clean_target = target
45
+ if clean_target.startswith("http://") or clean_target.startswith("https://"):
46
+ clean_target = clean_target.split("//")[1].split("/")[0]
47
+ if ":" in clean_target and " " not in clean_target:
48
+ clean_target = clean_target.split(":")[0]
49
+ if "/" in clean_target:
50
+ clean_target = clean_target.split("/")[0]
51
+
52
+ findings: List[Finding] = []
53
+
54
+ if self.is_configured():
55
+ logger.info("Executing structured Reverse WHOIS via WhoisFreaks API...")
56
+ findings = await self._query_whoisfreaks(clean_target)
57
+ if findings:
58
+ return findings
59
+
60
+ # Free fallback execution
61
+ logger.info("Executing free Reverse WHOIS / HackerTarget discovery fallback...")
62
+ findings = await self._query_free_fallbacks(clean_target)
63
+ return findings
64
+
65
+ async def _query_whoisfreaks(self, target: str) -> List[Finding]:
66
+ """Query WhoisFreaks Reverse WHOIS API with complete multi-page pagination."""
67
+ findings: List[Finding] = []
68
+ seen_domains: Set[str] = set()
69
+ url = settings.whoisfreaks_reverse_whois_url
70
+
71
+ params: Dict[str, Any] = {
72
+ "apiKey": settings.whoisfreaks_api_key,
73
+ "format": "JSON",
74
+ }
75
+
76
+ if self.EMAIL_REGEX.match(target):
77
+ params["email"] = target
78
+ query_type = "email"
79
+ elif self.DOMAIN_REGEX.match(target):
80
+ params["domain"] = target
81
+ query_type = "domain"
82
+ else:
83
+ params["org"] = target
84
+ query_type = "org"
85
+
86
+ page = 1
87
+ while True:
88
+ params["page"] = page
89
+ try:
90
+ data = await self.http_client.get_json(url=url, params=params, timeout=20.0)
91
+ if not data or not isinstance(data, dict):
92
+ break
93
+
94
+ domain_list = data.get("whois_domains", []) or data.get("domains", [])
95
+ if not domain_list:
96
+ break
97
+
98
+ for item in domain_list:
99
+ domain_name = item.get("domain_name") if isinstance(item, dict) else str(item)
100
+ if domain_name and self.DOMAIN_REGEX.match(domain_name):
101
+ clean_dom = domain_name.lower()
102
+ if clean_dom not in seen_domains:
103
+ seen_domains.add(clean_dom)
104
+ findings.append(
105
+ Finding(
106
+ type=FindingType.ASSOCIATED_DOMAIN,
107
+ target=target,
108
+ value=clean_dom,
109
+ source="WhoisFreaks (Reverse WHOIS)",
110
+ metadata={
111
+ "query_type": query_type,
112
+ "provider": "WhoisFreaks",
113
+ },
114
+ )
115
+ )
116
+
117
+ total_pages = data.get("total_pages") or data.get("totalPages")
118
+ if not total_pages or page >= int(total_pages):
119
+ break
120
+ page += 1
121
+ except Exception as exc:
122
+ logger.warning(f"Error querying WhoisFreaks API page {page}: {exc}")
123
+ break
124
+
125
+ return findings
126
+
127
+ async def _query_free_fallbacks(self, target: str) -> List[Finding]:
128
+ """Free endpoints fallback: HackerTarget Reverse IP, WHOIS, and RDAP."""
129
+ findings: List[Finding] = []
130
+ discovered_domains: Set[str] = set()
131
+
132
+ # 1. If target is an IP or Domain: HackerTarget Reverse IP lookup
133
+ # (Skip if the target IP / domain resolves to a shared CDN/Anycast proxy like Cloudflare, Fastly, Akamai)
134
+ skip_reverse_ip = False
135
+ target_ip = None
136
+ try:
137
+ import ipaddress, socket
138
+ # If target is already an IP
139
+ try:
140
+ ipaddress.ip_address(target)
141
+ target_ip = target
142
+ except ValueError:
143
+ # If target is a domain, resolve its current IP
144
+ try:
145
+ target_ip = socket.gethostbyname(target)
146
+ except Exception:
147
+ pass
148
+
149
+ if target_ip:
150
+ ip_obj = ipaddress.ip_address(target_ip)
151
+ # Known shared CDN/Anycast IP networks
152
+ cdn_nets = [
153
+ ipaddress.ip_network("104.16.0.0/12"),
154
+ ipaddress.ip_network("172.64.0.0/13"),
155
+ ipaddress.ip_network("162.158.0.0/15"),
156
+ ipaddress.ip_network("198.41.128.0/17"),
157
+ ipaddress.ip_network("197.234.240.0/22"),
158
+ ipaddress.ip_network("188.114.96.0/20"),
159
+ ipaddress.ip_network("190.93.240.0/20"),
160
+ ipaddress.ip_network("108.162.192.0/18"),
161
+ ipaddress.ip_network("131.0.72.0/22"),
162
+ ipaddress.ip_network("141.101.64.0/18"),
163
+ ipaddress.ip_network("103.21.244.0/22"),
164
+ ipaddress.ip_network("103.22.200.0/22"),
165
+ ipaddress.ip_network("103.31.4.0/22"),
166
+ ipaddress.ip_network("173.245.48.0/20"),
167
+ ipaddress.ip_network("151.101.0.0/16"), # Fastly
168
+ ipaddress.ip_network("199.232.0.0/16"), # Fastly
169
+ ipaddress.ip_network("199.83.128.0/21"), # Imperva
170
+ ipaddress.ip_network("198.143.32.0/19"), # Imperva
171
+ ]
172
+ if any(ip_obj in net for net in cdn_nets):
173
+ skip_reverse_ip = True
174
+ logger.info(f"Target {target} ({target_ip}) resides on a shared CDN/Anycast proxy (Cloudflare/Fastly/Imperva). Skipping Reverse IP to prevent tenant noise.")
175
+ except Exception as exc:
176
+ logger.debug(f"CDN detection check error for {target}: {exc}")
177
+
178
+ if not skip_reverse_ip:
179
+ try:
180
+ url = settings.hackertarget_reverse_ip_url
181
+ params = {"q": target}
182
+ resp = await self.http_client.get(url=url, params=params, timeout=15.0, raise_for_status=False)
183
+ if resp.status_code == 200:
184
+ text = resp.text.strip()
185
+ if text and "API count exceeded" not in text and "No records" not in text and "error" not in text.lower():
186
+ for line in text.splitlines():
187
+ candidate = line.strip().lower()
188
+ if candidate and self.DOMAIN_REGEX.match(candidate):
189
+ discovered_domains.add(candidate)
190
+ except Exception as exc:
191
+ logger.debug(f"HackerTarget Reverse IP error for {target}: {exc}")
192
+
193
+ # 2. If target is a Domain: Query WHOIS to extract organization and emails for context
194
+ if self.DOMAIN_REGEX.match(target):
195
+ try:
196
+ whois_url = settings.hackertarget_whois_url
197
+ resp = await self.http_client.get(url=whois_url, params={"q": target}, timeout=15.0, raise_for_status=False)
198
+ if resp.status_code == 200:
199
+ text = resp.text
200
+ emails = set(self.EMAIL_REGEX.findall(text))
201
+ # Ignore common privacy guard emails
202
+ filtered_emails = [
203
+ e for e in emails
204
+ if not any(guard in e.lower() for guard in ["privacy", "whoisguard", "domainprotection", "contactprivacy", "superprivacy"])
205
+ ]
206
+ for em in filtered_emails:
207
+ logger.info(f"Identified registrant contact email: {em}")
208
+ except Exception as exc:
209
+ logger.debug(f"HackerTarget WHOIS error for {target}: {exc}")
210
+
211
+ for dom in sorted(discovered_domains):
212
+ findings.append(
213
+ Finding(
214
+ type=FindingType.ASSOCIATED_DOMAIN,
215
+ target=target,
216
+ value=dom,
217
+ source="HackerTarget (Reverse WHOIS/IP)",
218
+ metadata={
219
+ "target": target,
220
+ "method": "free_fallback",
221
+ },
222
+ )
223
+ )
224
+
225
+ return findings