moriarty-project 0.1.25__py3-none-any.whl → 0.1.27__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.
@@ -0,0 +1,557 @@
1
+ """Port scanning avançado com detecção de serviços usando Nmap."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import re
7
+ import nmap3
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from typing import Dict, List, Optional, Any, Set, Union
11
+ from pathlib import Path
12
+ import structlog
13
+ from rich.console import Console
14
+ from rich.table import Table, box
15
+ from rich.live import Live
16
+ from rich.spinner import Spinner
17
+
18
+ logger = structlog.get_logger(__name__)
19
+ console = Console()
20
+
21
+ # Perfis de varredura
22
+ PROFILES = {
23
+ "quick": "21-23,25,53,80,110,111,135,139,143,389,443,445,465,587,993,995,1433,1521,2049,3306,3389,5432,5900,6379,8080,8443,9000,10000,27017",
24
+ "web": "80,443,8080,8443,8000,8888,10443,4443",
25
+ "db": "1433,1521,27017-27019,28017,3306,5000,5432,5984,6379,8081",
26
+ "full": "1-1024",
27
+ "all": "1-65535",
28
+ }
29
+
30
+ @dataclass
31
+ class ServiceInfo:
32
+ """Informações detalhadas sobre um serviço."""
33
+ name: str = "unknown"
34
+ version: Optional[str] = None
35
+ ssl: bool = False
36
+ ssl_info: Optional[Dict[str, Any]] = None
37
+ banner: Optional[str] = None
38
+ vulns: List[str] = field(default_factory=list)
39
+ cpe: Optional[str] = None
40
+ extra: Dict[str, Any] = field(default_factory=dict)
41
+ confidence: float = 0.0
42
+ last_checked: Optional[datetime] = field(default_factory=datetime.utcnow)
43
+
44
+ def to_dict(self) -> Dict[str, Any]:
45
+ """Converte o objeto para dicionário."""
46
+ return {
47
+ "name": self.name,
48
+ "version": self.version,
49
+ "ssl": self.ssl,
50
+ "ssl_info": self.ssl_info,
51
+ "banner": self.banner,
52
+ "vulns": self.vulns,
53
+ "cpe": self.cpe,
54
+ "extra": self.extra,
55
+ "confidence": self.confidence,
56
+ "last_checked": self.last_checked.isoformat() if self.last_checked else None,
57
+ }
58
+
59
+ @dataclass
60
+ class PortScanResult:
61
+ """Resultado da varredura de uma porta."""
62
+ port: int
63
+ protocol: str = "tcp"
64
+ status: str = "closed"
65
+ target: Optional[str] = None
66
+ service: Optional[ServiceInfo] = None
67
+ banner: Optional[str] = None
68
+ timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
69
+
70
+ def to_dict(self) -> Dict[str, Any]:
71
+ """Converte o resultado para dicionário."""
72
+ return {
73
+ "port": self.port,
74
+ "protocol": self.protocol,
75
+ "status": self.status,
76
+ "target": self.target,
77
+ "service": self.service.to_dict() if self.service else None,
78
+ "banner": self.banner,
79
+ "timestamp": self.timestamp,
80
+ }
81
+
82
+ def to_json(self) -> str:
83
+ """Retorna uma representação JSON do resultado."""
84
+ return json.dumps(self.to_dict(), indent=2)
85
+
86
+ class PortScanner:
87
+ """Execução de port scanning com detecção avançada de serviços usando Nmap."""
88
+
89
+ def __init__(
90
+ self,
91
+ target: str,
92
+ ports: Union[str, List[int], None] = None,
93
+ scan_type: str = "tcp", # CORREÇÃO: Padrão mudado para TCP (não requer root)
94
+ stealth_level: int = 0,
95
+ resolve_services: bool = True,
96
+ check_vulns: bool = True,
97
+ debug: bool = False, # NOVO: Opção de debug
98
+ ):
99
+ self.target = target
100
+ # CORREÇÃO: Armazena o range real, não o apelido
101
+ self.ports = self._parse_ports(ports) if ports else PROFILES["all"]
102
+ # CORREÇÃO: Default para TCP se não especificado
103
+ self.scan_type = scan_type if scan_type in ["syn", "tcp", "udp", "sS", "sT", "sU"] else "sT"
104
+ self.stealth_level = max(0, min(stealth_level, 5))
105
+ self.resolve_services = resolve_services
106
+ self.check_vulns = check_vulns
107
+ self.debug = debug # NOVO
108
+ self.stealth_level = max(0, min(stealth_level, 5))
109
+ self.resolve_services = resolve_services
110
+ self.check_vulns = check_vulns
111
+
112
+ self.nm = nmap3.Nmap()
113
+ self.scan_tech = nmap3.NmapScanTechniques()
114
+
115
+ # CORREÇÃO: Remove -sS dos args base (será adicionado pela técnica)
116
+ self.scan_arguments = self._get_scan_arguments()
117
+
118
+ def _parse_ports(self, ports: Union[str, List[int]]) -> str:
119
+ """Converte diferentes formatos de portas para o formato do Nmap."""
120
+ if isinstance(ports, list):
121
+ return ",".join(str(p) for p in ports)
122
+ if isinstance(ports, str):
123
+ # CORREÇÃO: Sempre retorna o range real, não o apelido
124
+ if ports in PROFILES:
125
+ return PROFILES[ports]
126
+ return ports
127
+ return PROFILES["all"]
128
+
129
+ def _get_scan_arguments(self) -> str:
130
+ """Gera os argumentos do Nmap baseados nas configurações."""
131
+ # CORREÇÃO: Remove -sV e -sC daqui pois já são adicionados pela técnica de scan
132
+ args = "-Pn -T4 --open"
133
+
134
+ if self.stealth_level > 0:
135
+ args += f" --max-rtt-timeout {1000 - (self.stealth_level * 100)}ms"
136
+ args += f" --scan-delay {self.stealth_level * 2}s"
137
+
138
+ return args.strip()
139
+
140
+ @staticmethod
141
+ def render_pipe_summary(results: List[PortScanResult]) -> str:
142
+ """Renderiza tabela em formato pipe (ASCII) com larguras automáticas."""
143
+ if not results:
144
+ return "Nenhuma porta aberta encontrada."
145
+
146
+ # Prepara dados
147
+ headers = [" PORTA", "STATUS", "SERVICO", "PROTOCOLO", "VERSAO", "SSL"]
148
+ rows = []
149
+
150
+ for r in sorted(results, key=lambda x: (x.protocol, x.port)):
151
+ service_name = r.service.name if r.service else "unknown"
152
+ version = (r.service.version or "").strip() if r.service else ""
153
+ ssl = "✅" if (r.service and r.service.ssl) else ""
154
+
155
+ rows.append([
156
+ str(r.port),
157
+ r.status,
158
+ service_name or "unknown",
159
+ r.protocol.upper(),
160
+ version or "-",
161
+ ssl
162
+ ])
163
+
164
+ # Calcula larguras automáticas
165
+ col_widths = [len(h) for h in headers]
166
+ for row in rows:
167
+ for i, cell in enumerate(row):
168
+ col_widths[i] = max(col_widths[i], len(str(cell)))
169
+
170
+ # Formata linhas
171
+ def fmt_line(cells):
172
+ return " | ".join(str(cell).ljust(col_widths[i]) for i, cell in enumerate(cells))
173
+
174
+ separator = "-+-".join("-" * w for w in col_widths)
175
+
176
+ output = [
177
+ fmt_line(headers),
178
+ separator
179
+ ]
180
+ output.extend(fmt_line(row) for row in rows)
181
+
182
+ return "\n".join(output)
183
+
184
+ async def scan(self) -> List[PortScanResult]:
185
+ """Executa a varredura de portas usando Nmap (python3-nmap)."""
186
+ # CORREÇÃO: Exibe o range real
187
+ logger.info(f"Iniciando varredura Nmap", target=self.target, ports=self.ports, arguments=self.scan_arguments)
188
+ console.print(f"[bold] 🔍 Iniciando varredura Nmap em {self.target}[/bold]")
189
+ console.print(f" 📊 Portas: [bold]{self.ports}[/bold]")
190
+ console.print(f" ⚙️ Argumentos: [bold]{self.scan_arguments}[/bold]")
191
+
192
+ if self.stealth_level > 0:
193
+ console.print(f" 🔒 Modo furtivo: nível {self.stealth_level}")
194
+
195
+ # Cria spinner animado
196
+ spinner = Spinner("dots", text="[cyan]Executando scan Nmap...[/cyan]", style="cyan")
197
+
198
+ try:
199
+ # CORREÇÃO: self.ports já é o range correto
200
+ ports_str = self.ports
201
+ args = f"{self.scan_arguments} -p {ports_str}"
202
+ logger.debug("Argumentos completos do Nmap", nmap_args=args)
203
+
204
+ st = self.scan_type
205
+ logger.info(f"Técnica de varredura selecionada: {st}")
206
+
207
+ # Inicia o spinner em Live context
208
+ with Live(spinner, console=console, refresh_per_second=10):
209
+ try:
210
+ # Detecta se precisa de root e ajusta automaticamente
211
+ needs_root = st in ("syn", "sS")
212
+ if needs_root:
213
+ spinner.update(text="[yellow]⚠️ Alternando para TCP Connect (-sT)...[/yellow]")
214
+ await asyncio.sleep(0.5) # Pequena pausa para o usuário ver a mensagem
215
+ st = "sT"
216
+ self.scan_type = "sT"
217
+
218
+ if self.debug:
219
+ console.print(f"[dim]🐛 Executando: nmap -{st} {args} {self.target}[/dim]")
220
+
221
+ spinner.update(text=f"[cyan]Scaneando portas {self.ports}...[/cyan]")
222
+
223
+ # Executa o scan baseado na técnica
224
+ if st in ("syn", "sS"):
225
+ logger.debug("Executando varredura SYN (sS)")
226
+ scan_results = await asyncio.to_thread(
227
+ self.scan_tech.nmap_syn_scan, self.target, args=args
228
+ )
229
+ elif st in ("tcp", "sT"):
230
+ logger.debug("Executando varredura TCP (sT)")
231
+ scan_results = await asyncio.to_thread(
232
+ self.scan_tech.nmap_tcp_scan, self.target, args=args
233
+ )
234
+ elif st in ("udp", "sU"):
235
+ if hasattr(self.scan_tech, "nmap_udp_scan"):
236
+ logger.debug("Executando varredura UDP (sU)")
237
+ scan_results = await asyncio.to_thread(
238
+ self.scan_tech.nmap_udp_scan, self.target, args=args
239
+ )
240
+ else:
241
+ logger.debug("Executando varredura UDP via fallback")
242
+ scan_results = await asyncio.to_thread(
243
+ self.nm.nmap_version_detection, self.target, args=f"-sU {args}"
244
+ )
245
+ else:
246
+ logger.debug(f"Técnica não reconhecida '{st}', usando fallback")
247
+ scan_results = await asyncio.to_thread(
248
+ self.nm.nmap_version_detection, self.target, args=args
249
+ )
250
+
251
+ spinner.update(text="[cyan]Processando resultados...[/cyan]")
252
+
253
+ if self.debug:
254
+ console.print(f"[dim]🐛 Tipo de resultado: {type(scan_results)}[/dim]")
255
+
256
+ # Detecta erro de permissão e tenta novamente com TCP
257
+ if isinstance(scan_results, dict) and scan_results.get("error") and "root" in str(scan_results.get("msg", "")).lower():
258
+ spinner.update(text="[yellow]⚠️ Erro de permissão. Tentando TCP...[/yellow]")
259
+ await asyncio.sleep(0.5)
260
+ st = "sT"
261
+ self.scan_type = "sT"
262
+ scan_results = await asyncio.to_thread(
263
+ self.scan_tech.nmap_tcp_scan, self.target, args=args
264
+ )
265
+ if self.debug:
266
+ console.print(f"[dim]🐛 Tipo de resultado (2ª tentativa): {type(scan_results)}[/dim]")
267
+
268
+ # Se ports está vazio, tenta scan com subprocess direto
269
+ if isinstance(scan_results, dict):
270
+ has_ports = False
271
+ for key, val in scan_results.items():
272
+ if isinstance(val, dict) and isinstance(val.get("ports"), list) and val.get("ports"):
273
+ has_ports = True
274
+ break
275
+
276
+ if not has_ports and self.debug:
277
+ spinner.update(text="[yellow]⚠️ Tentando método alternativo...[/yellow]")
278
+ await asyncio.sleep(0.3)
279
+ try:
280
+ clean_args = args.replace("-sV", "").replace("-sC", "").strip()
281
+ scan_results = await asyncio.to_thread(
282
+ self.scan_tech.nmap_tcp_scan,
283
+ self.target,
284
+ args=f"{clean_args} -p {ports_str}"
285
+ )
286
+ console.print(f"[dim]🐛 Tipo de resultado (scan limpo): {type(scan_results)}[/dim]")
287
+ except Exception as e:
288
+ console.print(f"[red]❌ Erro no scan alternativo: {e}[/red]")
289
+
290
+ if self.debug:
291
+ console.print(f"[dim]🐛 Resultado RAW:[/dim]")
292
+ try:
293
+ import pprint
294
+ console.print(f"[dim]{pprint.pformat(scan_results, width=120)}[/dim]")
295
+ except:
296
+ console.print(f"[dim]{str(scan_results)[:2000]}[/dim]")
297
+
298
+ logger.debug("Varredura Nmap concluída", scan_results=scan_results)
299
+
300
+ except Exception as e:
301
+ logger.error("Erro durante a execução do Nmap", error=str(e), exc_info=True)
302
+ raise
303
+
304
+ # CORREÇÃO: Parser melhorado para lidar com diferentes formatos
305
+ logger.debug("Iniciando processamento dos resultados do Nmap")
306
+
307
+ results = []
308
+
309
+ if self.debug:
310
+ if isinstance(scan_results, dict):
311
+ console.print(f"[dim]🐛 Chaves do resultado: {list(scan_results.keys())}[/dim]")
312
+
313
+ # CORREÇÃO: Trata múltiplos formatos de retorno
314
+ if isinstance(scan_results, dict):
315
+ for host_key, host_data in scan_results.items():
316
+ # Ignora chaves de metadados
317
+ if host_key in ("stats", "runtime", "task_results"):
318
+ if self.debug:
319
+ console.print(f"[dim]🐛 Ignorando chave de metadados: {host_key}[/dim]")
320
+ continue
321
+
322
+ if self.debug:
323
+ console.print(f"[dim]🐛 Processando host: {host_key}[/dim]")
324
+ console.print(f"[dim]🐛 Tipo de host_data: {type(host_data)}[/dim]")
325
+
326
+ # Formato: { "host": { "ports": [...] } }
327
+ if isinstance(host_data, dict):
328
+ if self.debug:
329
+ console.print(f"[dim]🐛 Chaves de host_data: {list(host_data.keys())}[/dim]")
330
+
331
+ ports_list = host_data.get("ports", [])
332
+
333
+ if self.debug:
334
+ console.print(f"[dim]🐛 Portas no formato 'ports': {len(ports_list)}[/dim]")
335
+
336
+ # CORREÇÃO: Também processa ports no formato alternativo
337
+ if not ports_list and "tcp" in host_data:
338
+ if self.debug:
339
+ console.print(f"[dim]🐛 Tentando formato alternativo 'tcp'...[/dim]")
340
+ tcp_ports = host_data.get("tcp", {})
341
+ if self.debug:
342
+ console.print(f"[dim]🐛 Portas TCP encontradas: {list(tcp_ports.keys())}[/dim]")
343
+
344
+ for port_num, port_data in tcp_ports.items():
345
+ ports_list.append({
346
+ "portid": str(port_num),
347
+ "protocol": "tcp",
348
+ "state": port_data,
349
+ "service": port_data.get("service", {}) if isinstance(port_data, dict) else {}
350
+ })
351
+
352
+ # CORREÇÃO: Também processa formato direto de portas
353
+ if not ports_list and self.debug:
354
+ console.print(f"[dim]🐛 Tentando outros formatos de porta...[/dim]")
355
+ for key, value in host_data.items():
356
+ if key.isdigit() or (isinstance(value, dict) and "portid" in value):
357
+ if self.debug:
358
+ console.print(f"[dim]🐛 Encontrada porta: {key}[/dim]")
359
+ ports_list.append(value if isinstance(value, dict) else {"portid": key, "state": value})
360
+
361
+ if self.debug and ports_list:
362
+ console.print(f"[dim]🐛 Total de portas a processar: {len(ports_list)}[/dim]")
363
+
364
+ for i, port_info in enumerate(ports_list):
365
+ if self.debug:
366
+ console.print(f"[dim]🐛 Processando porta {i+1}/{len(ports_list)}: {port_info}[/dim]")
367
+ result = self._process_port_info(port_info, host_key)
368
+ if result:
369
+ if self.debug:
370
+ console.print(f"[dim]✅ Porta {result.port} processada com sucesso![/dim]")
371
+ results.append(result)
372
+ elif self.debug:
373
+ console.print(f"[dim]❌ Porta não passou na validação[/dim]")
374
+
375
+ elif isinstance(scan_results, list):
376
+ if self.debug:
377
+ console.print(f"[dim]🐛 Resultado é uma lista com {len(scan_results)} itens[/dim]")
378
+ for port_info in scan_results:
379
+ result = self._process_port_info(port_info, self.target)
380
+ if result:
381
+ results.append(result)
382
+ else:
383
+ if self.debug:
384
+ console.print(f"[red]⚠️ Formato de resultado desconhecido: {type(scan_results)}[/red]")
385
+
386
+ results.sort(key=lambda x: x.port)
387
+
388
+ console.print("\n[bold green] ✅ Varredura concluída![/]")
389
+
390
+ if not results:
391
+ logger.warning(
392
+ "Nenhuma porta aberta encontrada",
393
+ target=self.target,
394
+ ports=self.ports,
395
+ arguments=args
396
+ )
397
+ console.print("[yellow] ℹ️ Nenhuma porta aberta encontrada.[/]")
398
+ else:
399
+ logger.info(
400
+ "Portas abertas encontradas",
401
+ count=len(results),
402
+ ports=[r.port for r in results]
403
+ )
404
+
405
+ # Exibe tabela formatada
406
+ console.print("\n[bold cyan] 🚪 Portas Abertas:[/bold cyan]")
407
+ table_output = self.render_pipe_summary(results)
408
+ console.print(table_output)
409
+ console.print(f"\n[bold] Total: {len(results)} porta(s) aberta(s)[/bold]")
410
+
411
+ return results
412
+
413
+ except Exception as e:
414
+ error_msg = f"Erro durante a varredura Nmap: {str(e)}"
415
+ console.print(f"[bold red]❌ {error_msg}[/]")
416
+ logger.error(
417
+ error_msg,
418
+ target=self.target,
419
+ ports=self.ports,
420
+ error_type=type(e).__name__,
421
+ exc_info=True
422
+ )
423
+ raise
424
+
425
+ def _process_port_info(self, port_info: Dict, host: str) -> Optional[PortScanResult]:
426
+ """Processa informações de uma porta individual."""
427
+ if not isinstance(port_info, dict):
428
+ if self.debug:
429
+ console.print(f"[dim]❌ port_info não é dict: {type(port_info)}[/dim]")
430
+ return None
431
+
432
+ try:
433
+ if self.debug:
434
+ console.print(f"[dim]🐛 Processando: {port_info}[/dim]")
435
+
436
+ # CORREÇÃO: Aceita diferentes formatos de estado
437
+ port_state = port_info.get("state", {})
438
+
439
+ if self.debug:
440
+ console.print(f"[dim]🐛 port_state: {port_state} (tipo: {type(port_state)})[/dim]")
441
+
442
+ # Formato 1: { "state": { "state": "open" } }
443
+ if isinstance(port_state, dict):
444
+ state_value = port_state.get("state", "unknown")
445
+ # Formato 2: { "state": "open" }
446
+ elif isinstance(port_state, str):
447
+ state_value = port_state
448
+ else:
449
+ state_value = "unknown"
450
+
451
+ if self.debug:
452
+ console.print(f"[dim]🐛 state_value: {state_value}[/dim]")
453
+
454
+ # CORREÇÃO: Aceita portas abertas E filtered (importantes)
455
+ if state_value not in ("open", "filtered"):
456
+ if self.debug:
457
+ console.print(f"[dim]❌ Estado '{state_value}' não aceito[/dim]")
458
+ return None
459
+
460
+ port_num = int(port_info.get("portid", 0))
461
+ if port_num == 0:
462
+ if self.debug:
463
+ console.print(f"[dim]❌ Porta número 0 inválida[/dim]")
464
+ return None
465
+
466
+ if self.debug:
467
+ console.print(f"[dim] ✅ Porta {port_num} válida, estado: {state_value}[/dim]")
468
+
469
+ # Extrai informações do serviço
470
+ service_data = port_info.get("service", {})
471
+ service_info = None
472
+
473
+ if isinstance(service_data, dict) and service_data:
474
+ service_info = ServiceInfo(
475
+ name=service_data.get("name", "unknown"),
476
+ version=service_data.get("version", ""),
477
+ banner=service_data.get("product", ""),
478
+ )
479
+
480
+ result = PortScanResult(
481
+ port=port_num,
482
+ protocol=port_info.get("protocol", "tcp"),
483
+ status=state_value,
484
+ target=host,
485
+ service=service_info,
486
+ banner=service_data.get("product", "") if isinstance(service_data, dict) else "",
487
+ )
488
+
489
+ logger.debug(
490
+ "Porta processada",
491
+ port=port_num,
492
+ status=state_value,
493
+ service=service_info.name if service_info else "unknown",
494
+ protocol=port_info.get("protocol")
495
+ )
496
+
497
+ return result
498
+
499
+ except (TypeError, ValueError, KeyError) as e:
500
+ if self.debug:
501
+ console.print(f"[red]❌ Erro ao processar porta: {e}[/red]")
502
+ logger.warning("Erro ao processar porta",
503
+ port_info=port_info,
504
+ error=str(e),
505
+ error_type=type(e).__name__)
506
+ return None
507
+
508
+ def format_scan_results(results: List[PortScanResult], output_format: str = "text", total_ports: Optional[int] = None) -> str:
509
+ """Formata os resultados da varredura no formato solicitado."""
510
+ if output_format == "json":
511
+ return json.dumps([r.to_dict() for r in results], indent=2)
512
+
513
+ if output_format == "pipe":
514
+ base = PortScanner.render_pipe_summary(results)
515
+ suffix = f"\n\n 🔍 Total de portas abertas: {len(results)}"
516
+ if total_ports:
517
+ suffix = f"\n\n 🔍 Resumo: {len(results)} portas abertas de {total_ports} verificadas"
518
+ return base + suffix
519
+
520
+ # Formato de texto
521
+ if not results:
522
+ return "Nenhuma porta aberta encontrada."
523
+
524
+ # Cria tabela
525
+ table = Table(show_header=True, header_style="bold magenta", box=box.ROUNDED)
526
+ table.add_column("Porta", style="cyan", width=10)
527
+ table.add_column("Protocolo", style="blue")
528
+ table.add_column("Status", style="yellow")
529
+ table.add_column("Serviço", style="green")
530
+ table.add_column("Versão", style="yellow")
531
+ table.add_column("SSL/TLS", style="magenta")
532
+
533
+ for result in results:
534
+ service = result.service.name if result.service and hasattr(result.service, 'name') else "desconhecido"
535
+ version = result.service.version if result.service and hasattr(result.service, 'version') else ""
536
+ ssl = "✅" if result.service and result.service.ssl else ""
537
+
538
+ table.add_row(
539
+ f"{result.port}",
540
+ result.protocol.upper(),
541
+ result.status,
542
+ service,
543
+ version or "-",
544
+ ssl
545
+ )
546
+
547
+ output = [str(table)]
548
+
549
+ # Adiciona resumo
550
+ if total_ports:
551
+ output.append(f"\n 🔍 [bold]Resumo:[/bold] {len(results)} portas abertas de {total_ports} verificadas")
552
+ else:
553
+ output.append(f"\n 🔍 [bold]Total de portas:[/bold] {len(results)}")
554
+
555
+ return "\n".join(output)
556
+
557
+ __all__ = ["PortScanner", "PortScanResult", "format_scan_results"]