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
@@ -0,0 +1,561 @@
1
+ """Masscan active network port scanner module and runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import shutil
10
+ import tempfile
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ logger = logging.getLogger("detecti.masscan")
15
+
16
+
17
+ DEFAULT_HTTP_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
18
+
19
+ TOP_100_PORTS = [
20
+ 80, 23, 443, 21, 22, 25, 3389, 110, 445, 139, 143, 53, 135, 3306, 8080, 1723, 111, 995, 993, 5900,
21
+ 1025, 587, 8888, 199, 1720, 465, 548, 113, 81, 6001, 10000, 514, 5060, 179, 1026, 2000, 8443, 8000,
22
+ 32768, 554, 26, 1433, 49152, 2001, 515, 8008, 49154, 1027, 5666, 646, 5000, 5631, 631, 49153, 8081,
23
+ 2049, 88, 79, 5800, 106, 2121, 6129, 625, 5009, 444, 902, 636, 49155, 2601, 7070, 512, 1080, 1028,
24
+ 5555, 5432, 19, 7, 7443, 6000, 3000, 2002, 513, 5357, 544, 49156, 3689, 5051, 500, 1900, 2602, 5190,
25
+ 3001, 49157, 8500, 1029, 903, 1755, 9100, 2604, 8082
26
+ ]
27
+
28
+
29
+ def build_port_ranges_excluding(
30
+ total_start: int = 0,
31
+ total_end: int = 65535,
32
+ excluded_ports: Optional[set[int] | list[int]] = None
33
+ ) -> str:
34
+ """Generate minimal contiguous port ranges excluding specified ports.
35
+
36
+ Example:
37
+ build_port_ranges_excluding(0, 65535, {80, 443})
38
+ -> "0-79,81-442,444-65535"
39
+ """
40
+ ex_set = {int(p) for p in (excluded_ports or set()) if total_start <= int(p) <= total_end}
41
+ if not ex_set:
42
+ return f"{total_start}-{total_end}" if total_start != total_end else str(total_start)
43
+
44
+ sorted_excluded = sorted(ex_set)
45
+ ranges = []
46
+ current_start = total_start
47
+
48
+ for p in sorted_excluded:
49
+ if p > current_start:
50
+ if p - 1 == current_start:
51
+ ranges.append(str(current_start))
52
+ else:
53
+ ranges.append(f"{current_start}-{p - 1}")
54
+ current_start = p + 1
55
+
56
+ if current_start <= total_end:
57
+ if current_start == total_end:
58
+ ranges.append(str(current_start))
59
+ else:
60
+ ranges.append(f"{current_start}-{total_end}")
61
+
62
+ return ",".join(ranges)
63
+
64
+
65
+ def parse_port_spec_to_set(ports_spec: Optional[str]) -> set[int]:
66
+ """Parse a port specification string into a discrete set of port integers."""
67
+ if not ports_spec or not ports_spec.strip():
68
+ return set(TOP_100_PORTS)
69
+
70
+ spec = ports_spec.strip()
71
+ if spec.startswith("-p"):
72
+ spec = spec[2:].strip()
73
+
74
+ if spec in ("-", "all", "0-65535", "-p0-65535"):
75
+ return set(range(0, 65536))
76
+ if spec in ("1-65535", "-p1-65535"):
77
+ return set(range(1, 65536))
78
+
79
+ if spec.startswith("--top-ports"):
80
+ parts = spec.split()
81
+ count = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 100
82
+ return set(TOP_100_PORTS[:min(count, len(TOP_100_PORTS))])
83
+
84
+ ports = set()
85
+ for chunk in spec.split(","):
86
+ chunk = chunk.strip()
87
+ if not chunk:
88
+ continue
89
+ if "-" in chunk:
90
+ try:
91
+ start_s, end_s = chunk.split("-", 1)
92
+ s = int(start_s.strip())
93
+ e = int(end_s.strip())
94
+ if s <= e:
95
+ ports.update(range(s, e + 1))
96
+ except ValueError:
97
+ continue
98
+ elif chunk.isdigit():
99
+ ports.add(int(chunk))
100
+
101
+ return ports
102
+
103
+
104
+ def filter_ports_excluding(
105
+ ports_spec: Optional[str],
106
+ excluded_ports: Optional[set[int] | list[int]] = None
107
+ ) -> tuple[Optional[str], int, int, set[int]]:
108
+ """Filter out excluded ports from any port specification.
109
+
110
+ Returns:
111
+ (filtered_ports_arg, remaining_count, excluded_count, actual_excluded_set)
112
+ """
113
+ excluded_set = {int(p) for p in (excluded_ports or set())}
114
+ if not ports_spec:
115
+ ports_spec = "--top-ports 100"
116
+
117
+ clean_spec = ports_spec.strip()
118
+ if clean_spec.startswith("-p"):
119
+ clean_spec = clean_spec[2:].strip()
120
+
121
+ is_all_ports = clean_spec in ("-", "all", "0-65535", "1-65535", "-p0-65535", "-p1-65535")
122
+
123
+ if is_all_ports:
124
+ start_p = 1 if "1-65535" in clean_spec else 0
125
+ actual_ex = {p for p in excluded_set if start_p <= p <= 65535}
126
+ filtered_arg = build_port_ranges_excluding(start_p, 65535, actual_ex)
127
+ total_p = (65535 - start_p + 1)
128
+ remaining = max(0, total_p - len(actual_ex))
129
+ return filtered_arg, remaining, len(actual_ex), actual_ex
130
+
131
+ target_ports = parse_port_spec_to_set(ports_spec)
132
+ initial_count = len(target_ports)
133
+ actual_ex = target_ports & excluded_set
134
+ remaining_ports = target_ports - excluded_set
135
+ excluded_count = len(actual_ex)
136
+
137
+ if not remaining_ports:
138
+ return None, 0, excluded_count, actual_ex
139
+
140
+ # If top-ports was asked without any exclusions
141
+ if ports_spec.strip().startswith("--top-ports") and excluded_count == 0:
142
+ return ports_spec.strip(), len(remaining_ports), 0, set()
143
+
144
+ sorted_rem = sorted(remaining_ports)
145
+ ranges = []
146
+ r_start = sorted_rem[0]
147
+ r_prev = sorted_rem[0]
148
+
149
+ for p in sorted_rem[1:]:
150
+ if p == r_prev + 1:
151
+ r_prev = p
152
+ else:
153
+ if r_start == r_prev:
154
+ ranges.append(str(r_start))
155
+ else:
156
+ ranges.append(f"{r_start}-{r_prev}")
157
+ r_start = p
158
+ r_prev = p
159
+ if r_start == r_prev:
160
+ ranges.append(str(r_start))
161
+ else:
162
+ ranges.append(f"{r_start}-{r_prev}")
163
+
164
+ return ",".join(ranges), len(remaining_ports), excluded_count, actual_ex
165
+
166
+
167
+ def calculate_dynamic_timeout(
168
+ ports_spec: Optional[str],
169
+ rate: int = 1000,
170
+ num_targets: int = 1,
171
+ min_timeout: float = 45.0,
172
+ ) -> float:
173
+ """Compute adaptive execution timeout based on total port count, target count, and scan rate.
174
+
175
+ Formula:
176
+ Total Probes = len(ports) * num_targets
177
+ Transmission Time = Total Probes / max(rate, 10)
178
+ Safety & Banner Buffer = 15.0s base + (num_targets * 2.0s)
179
+ Timeout = max(min_timeout, (Transmission Time * 1.6) + Safety Buffer)
180
+ """
181
+ clean_rate = max(10, min(rate, 25000))
182
+ targets_count = max(1, num_targets)
183
+
184
+ if not ports_spec or not ports_spec.strip():
185
+ port_count = 100
186
+ else:
187
+ spec = ports_spec.strip()
188
+ if spec.startswith("-p"):
189
+ spec = spec[2:].strip()
190
+ if spec in ("-", "all", "0-65535", "1-65535", "-p0-65535", "-p1-65535"):
191
+ port_count = 65536
192
+ elif spec.startswith("--top-ports"):
193
+ parts = spec.split()
194
+ port_count = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 100
195
+ else:
196
+ try:
197
+ port_count = len(parse_port_spec_to_set(ports_spec))
198
+ except Exception:
199
+ port_count = 100
200
+
201
+ total_probes = max(1, port_count) * targets_count
202
+ transmission_time = total_probes / clean_rate
203
+ safety_buffer = 15.0 + (targets_count * 2.0)
204
+
205
+ computed_timeout = (transmission_time * 1.6) + safety_buffer
206
+ return round(max(min_timeout, computed_timeout), 1)
207
+
208
+
209
+ class MasscanRunner:
210
+ """Async Masscan execution engine for targeted active port scanning."""
211
+
212
+ def __init__(self, binary_path: Optional[str] = None):
213
+ self.binary_path = binary_path or shutil.which("masscan") or "/usr/bin/masscan"
214
+
215
+ def is_available(self) -> bool:
216
+ """Check if masscan executable exists and is accessible."""
217
+ if not self.binary_path:
218
+ return False
219
+ p = Path(self.binary_path)
220
+ return p.exists() and os.access(str(p), os.X_OK)
221
+
222
+ def check_permissions(self) -> Dict[str, Any]:
223
+ """Verify binary availability and execution permissions."""
224
+ available = self.is_available()
225
+ is_root = os.geteuid() == 0 if hasattr(os, "geteuid") else False
226
+ return {
227
+ "available": available,
228
+ "binary_path": self.binary_path if available else None,
229
+ "is_root": is_root,
230
+ "can_run": available,
231
+ "message": (
232
+ "Masscan ready"
233
+ if available
234
+ else "Masscan binary not found on system (install with: apt-get install masscan)"
235
+ ),
236
+ }
237
+
238
+ async def scan_target(
239
+ self,
240
+ target_ip: Optional[str] = None,
241
+ ports: Optional[str] = None,
242
+ rate: int = 1000,
243
+ disable_ping: bool = True,
244
+ banners: bool = True,
245
+ custom_flags: Optional[str] = None,
246
+ timeout: Optional[float] = None,
247
+ target: Optional[str] = None,
248
+ num_targets: int = 1,
249
+ ) -> Dict[str, Any]:
250
+ """Execute masscan against a specific target IP and return parsed findings."""
251
+ target_ip = target_ip or target or ""
252
+ if not self.is_available():
253
+ return {
254
+ "success": False,
255
+ "target": target_ip,
256
+ "error": "Masscan executable not found on host system",
257
+ "ports": [],
258
+ }
259
+
260
+ target_ip = target_ip.strip()
261
+ temp_out = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
262
+ temp_out_path = temp_out.name
263
+ temp_out.close()
264
+
265
+ # Compute dynamic timeout based on workload if not explicitly passed
266
+ min_needed_timeout = calculate_dynamic_timeout(ports, rate=rate, num_targets=num_targets)
267
+ if timeout is None or timeout <= 0:
268
+ effective_timeout = min_needed_timeout
269
+ else:
270
+ effective_timeout = max(timeout, min_needed_timeout)
271
+
272
+ cmd = [self.binary_path, target_ip]
273
+
274
+ # Port specification
275
+ if ports:
276
+ p_clean = ports.strip()
277
+ if p_clean in ("-p-", "-", "all", "0-65535", "1-65535", "-p0-65535", "-p1-65535"):
278
+ cmd.extend(["-p", "0-65535"])
279
+ elif p_clean.startswith("-p"):
280
+ val = p_clean[2:].strip()
281
+ if val in ("-", "all", "0-65535", "1-65535"):
282
+ cmd.extend(["-p", "0-65535"])
283
+ else:
284
+ cmd.extend(["-p", val])
285
+ elif p_clean.startswith("--top-ports"):
286
+ parts = p_clean.split()
287
+ cmd.extend(["--top-ports", parts[1] if len(parts) > 1 else "100"])
288
+ else:
289
+ cmd.extend(["-p", p_clean])
290
+ else:
291
+ cmd.extend(["--top-ports", "100"])
292
+
293
+ # Rate control
294
+ cmd.extend(["--rate", str(max(10, min(rate, 25000)))])
295
+
296
+ # Disable ping (-Pn)
297
+ if disable_ping:
298
+ cmd.append("-Pn")
299
+
300
+ # Banner grabbing & HTTP User-Agent evasion
301
+ if banners:
302
+ cmd.append("--banners")
303
+ cmd.extend(["--http-user-agent", DEFAULT_HTTP_USER_AGENT])
304
+
305
+ # Custom flags
306
+ if custom_flags:
307
+ import shlex
308
+ try:
309
+ extra_args = shlex.split(custom_flags.strip())
310
+ cmd.extend(extra_args)
311
+ except Exception as e:
312
+ logger.warning(f"Error parsing custom masscan flags: {e}")
313
+
314
+ # JSON output to temp file
315
+ cmd.extend(["-oJ", temp_out_path])
316
+
317
+ logger.info(f"Executing masscan (timeout={effective_timeout}s): {' '.join(cmd)}")
318
+
319
+ proc = None
320
+ try:
321
+ proc = await asyncio.create_subprocess_exec(
322
+ *cmd,
323
+ stdout=asyncio.subprocess.PIPE,
324
+ stderr=asyncio.subprocess.PIPE,
325
+ )
326
+
327
+ try:
328
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
329
+ proc.communicate(), timeout=effective_timeout
330
+ )
331
+ except asyncio.TimeoutError:
332
+ logger.warning(
333
+ f"Masscan timed out after {effective_timeout}s on {target_ip}. Gracefully flushing with SIGINT..."
334
+ )
335
+ if proc:
336
+ try:
337
+ import signal
338
+ proc.send_signal(signal.SIGINT)
339
+ try:
340
+ await asyncio.wait_for(proc.wait(), timeout=3.0)
341
+ except (asyncio.TimeoutError, Exception):
342
+ proc.kill()
343
+ except Exception:
344
+ try:
345
+ proc.kill()
346
+ except Exception:
347
+ pass
348
+
349
+ # Parse all ports discovered by masscan before timeout occurred
350
+ discovered_ports = self._parse_json_file(temp_out_path, target_ip)
351
+ return {
352
+ "success": len(discovered_ports) > 0,
353
+ "target": target_ip,
354
+ "error": f"Masscan execution timed out after {effective_timeout} seconds",
355
+ "ports": discovered_ports,
356
+ "open_ports": discovered_ports,
357
+ "count": len(discovered_ports),
358
+ "timeout_seconds": effective_timeout,
359
+ }
360
+
361
+ stderr_text = stderr_bytes.decode("utf-8", errors="replace")
362
+
363
+ # Check exit code and parse findings
364
+ open_ports = self._parse_json_file(temp_out_path, target_ip)
365
+
366
+ # Check if there was a permission error
367
+ if "requires root privileges" in stderr_text or "permission denied" in stderr_text.lower():
368
+ return {
369
+ "success": False,
370
+ "target": target_ip,
371
+ "error": "Masscan requires root or CAP_NET_RAW privileges to run raw packet scans",
372
+ "ports": [],
373
+ "open_ports": [],
374
+ }
375
+
376
+ return {
377
+ "success": True,
378
+ "target": target_ip,
379
+ "ports": open_ports,
380
+ "open_ports": open_ports,
381
+ "count": len(open_ports),
382
+ "command": " ".join(cmd),
383
+ "timeout_seconds": effective_timeout,
384
+ }
385
+
386
+ except Exception as exc:
387
+ logger.error(f"Error executing masscan on {target_ip}: {exc}")
388
+ discovered_ports = self._parse_json_file(temp_out_path, target_ip)
389
+ return {
390
+ "success": len(discovered_ports) > 0,
391
+ "target": target_ip,
392
+ "error": str(exc),
393
+ "ports": discovered_ports,
394
+ "open_ports": discovered_ports,
395
+ "count": len(discovered_ports),
396
+ }
397
+ finally:
398
+ # Clean up temp file
399
+ try:
400
+ if os.path.exists(temp_out_path):
401
+ os.remove(temp_out_path)
402
+ except Exception:
403
+ pass
404
+
405
+ def _parse_json_file(self, filepath: str, default_ip: str) -> List[Dict[str, Any]]:
406
+ """Parse masscan JSON output safely, consolidating port records and extracting banners."""
407
+ if not os.path.exists(filepath):
408
+ return []
409
+
410
+ try:
411
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
412
+ content = f.read().strip()
413
+
414
+ if not content:
415
+ return []
416
+
417
+ # Handle Masscan JSON quirks (trailing commas before closing bracket, mid-stream EOF)
418
+ import re
419
+ content = re.sub(r',\s*([\]\}])', r'\1', content)
420
+ if not content.endswith("]") and not content.endswith("}"):
421
+ content += "\n]"
422
+ if not content.startswith("[") and not content.startswith("{"):
423
+ content = "[" + content
424
+
425
+ data = json.loads(content)
426
+ # Use dictionary keyed by (ip, port, proto) to merge duplicate masscan records (status + banners)
427
+ merged_ports: Dict[tuple, Dict[str, Any]] = {}
428
+
429
+ for item in data:
430
+ ip = item.get("ip", default_ip)
431
+ for p in item.get("ports", []):
432
+ port_num = p.get("port")
433
+ if port_num is None:
434
+ continue
435
+
436
+ proto = p.get("proto", "tcp").lower()
437
+ status = p.get("status", "open")
438
+ ttl = p.get("ttl")
439
+
440
+ service_info = p.get("service", {})
441
+ service_name = service_info.get("name") if isinstance(service_info, dict) else None
442
+ banner = service_info.get("banner") if isinstance(service_info, dict) else None
443
+
444
+ key = (ip, int(port_num), proto)
445
+ if key not in merged_ports:
446
+ inferred_name = service_name or self._infer_service_name(int(port_num))
447
+ merged_ports[key] = {
448
+ "ip": ip,
449
+ "port": int(port_num),
450
+ "protocol": proto,
451
+ "status": status,
452
+ "ttl": ttl,
453
+ "service_name": inferred_name,
454
+ "product": "",
455
+ "version": "",
456
+ "banner": banner or "",
457
+ "ssl": (int(port_num) == 443 or "https" in (inferred_name or "").lower() or "ssl" in (inferred_name or "").lower()),
458
+ "source": "Masscan",
459
+ }
460
+ else:
461
+ entry = merged_ports[key]
462
+ if status:
463
+ entry["status"] = status
464
+ if ttl:
465
+ entry["ttl"] = ttl
466
+ if service_name and (not entry["service_name"] or entry["service_name"].startswith("service-")):
467
+ entry["service_name"] = service_name
468
+ if banner and len(banner) > len(entry.get("banner", "")):
469
+ entry["banner"] = banner
470
+ if int(port_num) == 443 or "https" in (entry["service_name"] or "").lower() or "ssl" in (entry["service_name"] or "").lower() or (service_name and "ssl" in service_name.lower()):
471
+ entry["ssl"] = True
472
+
473
+ # Extract product and version from banner if available
474
+ results: List[Dict[str, Any]] = []
475
+ for entry in merged_ports.values():
476
+ banner_str = entry.get("banner", "")
477
+ if banner_str:
478
+ prod, ver = self._extract_product_version(banner_str, entry["port"])
479
+ entry["product"] = prod
480
+ entry["version"] = ver
481
+ results.append(entry)
482
+
483
+ # Sort by port number
484
+ results.sort(key=lambda x: x["port"])
485
+ return results
486
+
487
+ except Exception as e:
488
+ logger.warning(f"Error parsing masscan JSON output: {e}")
489
+ return []
490
+
491
+ @staticmethod
492
+ def _extract_product_version(banner: str, port: int) -> tuple[str, str]:
493
+ """Extract product name and version string from banner."""
494
+ import re
495
+ if not banner:
496
+ return "", ""
497
+
498
+ banner_clean = banner.strip()
499
+
500
+ # 1. HTTP Server Header: Server: <name>/<version>
501
+ server_m = re.search(r"Server:\s*([^\r\n]+)", banner_clean, re.IGNORECASE)
502
+ if server_m:
503
+ server_val = server_m.group(1).strip()
504
+ parts = server_val.split("/", 1)
505
+ prod = parts[0].strip()
506
+ ver = ""
507
+ if len(parts) > 1:
508
+ ver = parts[1].split()[0].strip()
509
+ return prod, ver
510
+
511
+ # 2. SSH Banner: SSH-2.0-OpenSSH_8.9p1 Ubuntu
512
+ if banner_clean.startswith("SSH-"):
513
+ parts = banner_clean.split("-", 2)
514
+ if len(parts) >= 3:
515
+ prod_ver = parts[2].split()[0].strip()
516
+ if "_" in prod_ver:
517
+ p, v = prod_ver.split("_", 1)
518
+ return p, v
519
+ return prod_ver, ""
520
+
521
+ # 3. Simple banner like 'cloudflare' or 'nginx'
522
+ first_line = banner_clean.splitlines()[0].strip() if banner_clean else ""
523
+ if len(first_line) < 40 and not first_line.startswith("HTTP/"):
524
+ parts = first_line.split("/", 1)
525
+ if len(parts) == 2:
526
+ return parts[0].strip(), parts[1].split()[0].strip()
527
+ return first_line, ""
528
+
529
+ return "", ""
530
+
531
+ @staticmethod
532
+ def _infer_service_name(port: int) -> str:
533
+ """Infer common service name from port number."""
534
+ common = {
535
+ 21: "ftp",
536
+ 22: "ssh",
537
+ 23: "telnet",
538
+ 25: "smtp",
539
+ 53: "dns",
540
+ 80: "http",
541
+ 110: "pop3",
542
+ 143: "imap",
543
+ 443: "https",
544
+ 445: "microsoft-ds",
545
+ 993: "imaps",
546
+ 995: "pop3s",
547
+ 1433: "mssql",
548
+ 1521: "oracle",
549
+ 3306: "mysql",
550
+ 3389: "rdp",
551
+ 5432: "postgresql",
552
+ 5900: "vnc",
553
+ 6379: "redis",
554
+ 8000: "http-alt",
555
+ 8080: "http-proxy",
556
+ 8443: "https-alt",
557
+ 8888: "http-alt",
558
+ 9200: "elasticsearch",
559
+ 27017: "mongodb",
560
+ }
561
+ return common.get(port, f"service-{port}")