vamp-easm 1.1__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.
easm/scanner.py ADDED
@@ -0,0 +1,517 @@
1
+ # © VampSecure Studios — VampSecure Labs Security Research Division
2
+ """
3
+ scanner.py — Motor de escaneo en tres capas para vamp-easm
4
+ ===========================================================
5
+ Realiza el descubrimiento de la superficie de ataque externa en tres fases:
6
+
7
+ Capa 1 · DNS/Subdominios
8
+ Consulta crt.sh (Certificate Transparency) y HackerTarget (hostsearch)
9
+ para enumerar subdominios conocidos del target. Solo usa urllib (stdlib).
10
+
11
+ Capa 2 · Puertos/Servicios
12
+ Escaneo TCP async sobre el top-100 de puertos (o lista personalizada).
13
+ Si nmap está en PATH y se activa --nmap, se usa como backend.
14
+
15
+ Capa 3 · Certificados TLS
16
+ Para cada subdominio con el puerto 443 abierto (o cualquier puerto HTTPS
17
+ detectado), inspecciona el certificado TLS: hostname, emisor, expiración,
18
+ SANs y si es autofirmado.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import ipaddress
25
+ import json
26
+ import shutil
27
+ import socket
28
+ import ssl
29
+ import subprocess
30
+ import urllib.request
31
+ import urllib.error
32
+ from dataclasses import dataclass, field
33
+ from datetime import datetime, timezone
34
+ from typing import Dict, List, Optional, Set, Tuple
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Top-100 puertos a escanear (fusión de servicios comunes + admin + web)
39
+ # ---------------------------------------------------------------------------
40
+
41
+ TOP_100_PORTS: List[int] = [
42
+ 21, 22, 23, 25, 53, 80, 88, 110, 111, 119,
43
+ 135, 139, 143, 161, 179, 389, 443, 445, 465, 500,
44
+ 514, 515, 587, 631, 636, 873, 993, 995, 1080, 1194,
45
+ 1433, 1521, 1723, 1883, 2049, 2181, 2375, 2376, 3000,
46
+ 3001, 3306, 3389, 3478, 3632, 4443, 4444, 4848, 5000,
47
+ 5001, 5432, 5601, 5672, 5900, 5985, 5986, 6379, 6443,
48
+ 7001, 7002, 7070, 7443, 7474, 7777, 8000, 8008, 8080,
49
+ 8081, 8082, 8088, 8089, 8161, 8443, 8500, 8530, 8888,
50
+ 8983, 9000, 9001, 9042, 9090, 9091, 9092, 9200, 9300,
51
+ 9418, 9443, 9999, 10000, 11211, 15672, 16379, 25565,
52
+ 27017, 27018, 28017, 50000, 50070, 61616,
53
+ ]
54
+
55
+ # Puertos que típicamente llevan TLS
56
+ _PUERTOS_TLS: Set[int] = {
57
+ 443, 465, 636, 993, 995, 4443, 5601, 5986,
58
+ 6443, 7443, 8443, 9200, 9300, 9443, 10000,
59
+ }
60
+
61
+ # Timeout de conexión TCP en segundos
62
+ _TCP_TIMEOUT = 2.0
63
+ # Timeout para llamadas HTTP a fuentes externas
64
+ _HTTP_TIMEOUT = 15
65
+
66
+ # Semáforo para limitar las conexiones simultáneas en el escaneo de puertos
67
+ _MAX_CONCURRENT = 200
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Estructuras de datos de resultado
72
+ # ---------------------------------------------------------------------------
73
+
74
+ @dataclass
75
+ class Subdominio:
76
+ """Subdominio descubierto con su resolución IP."""
77
+ nombre: str
78
+ ips: List[str] = field(default_factory=list)
79
+
80
+
81
+ @dataclass
82
+ class PuertoAbierto:
83
+ """Puerto TCP abierto con información de servicio."""
84
+ host: str
85
+ puerto: int
86
+ protocolo: str = "tcp"
87
+ servicio: Optional[str] = None
88
+
89
+
90
+ @dataclass
91
+ class CertInfo:
92
+ """Información de un certificado TLS inspeccionado."""
93
+ host: str
94
+ puerto: int
95
+ issued_to: Optional[str]
96
+ issuer: Optional[str]
97
+ not_after: Optional[str] # ISO-8601 UTC
98
+ sans: List[str] = field(default_factory=list)
99
+ fingerprint: Optional[str] = None
100
+ autofirmado: bool = False
101
+ error: Optional[str] = None
102
+
103
+
104
+ @dataclass
105
+ class ResultadoEscaneo:
106
+ """Resultado completo de un escaneo para un target."""
107
+ target: str
108
+ subdominios: List[Subdominio] = field(default_factory=list)
109
+ puertos: List[PuertoAbierto] = field(default_factory=list)
110
+ certs: List[CertInfo] = field(default_factory=list)
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Capa 1 — Enumeración de subdominios
115
+ # ---------------------------------------------------------------------------
116
+
117
+ def _http_get(url: str, timeout: int = _HTTP_TIMEOUT) -> str:
118
+ """Realiza una petición HTTP GET con stdlib y devuelve el body como texto."""
119
+ req = urllib.request.Request(url, headers={"User-Agent": "vamp-easm/1.0"})
120
+ try:
121
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
122
+ return resp.read().decode("utf-8", errors="replace")
123
+ except Exception:
124
+ return ""
125
+
126
+
127
+ def _subdominios_crtsh(dominio: str) -> Set[str]:
128
+ """
129
+ Consulta Certificate Transparency (crt.sh) para obtener subdominios
130
+ históricos del dominio. Devuelve un conjunto de nombres normalizados.
131
+ """
132
+ url = f"https://crt.sh/?q=%.{dominio}&output=json"
133
+ raw = _http_get(url)
134
+ if not raw:
135
+ return set()
136
+ try:
137
+ datos = json.loads(raw)
138
+ except json.JSONDecodeError:
139
+ return set()
140
+
141
+ resultado: Set[str] = set()
142
+ for entrada in datos:
143
+ # name_value puede contener múltiples nombres separados por \n
144
+ for nombre in str(entrada.get("name_value", "")).splitlines():
145
+ nombre = nombre.strip().lstrip("*.")
146
+ if nombre and dominio in nombre:
147
+ resultado.add(nombre.lower())
148
+ return resultado
149
+
150
+
151
+ def _subdominios_hackertarget(dominio: str) -> Set[str]:
152
+ """
153
+ Consulta HackerTarget hostsearch para obtener subdominios del dominio.
154
+ El endpoint devuelve líneas en formato 'host,ip'.
155
+ """
156
+ url = f"https://api.hackertarget.com/hostsearch/?q={dominio}"
157
+ raw = _http_get(url)
158
+ resultado: Set[str] = set()
159
+ for linea in raw.splitlines():
160
+ partes = linea.strip().split(",")
161
+ if partes:
162
+ nombre = partes[0].strip().lower()
163
+ if nombre and dominio in nombre and not nombre.startswith("API"):
164
+ resultado.add(nombre)
165
+ return resultado
166
+
167
+
168
+ def _resolver_ip(nombre: str) -> List[str]:
169
+ """Resuelve un nombre de host a sus IPs (IPv4 e IPv6). Devuelve lista vacía si falla."""
170
+ try:
171
+ info = socket.getaddrinfo(nombre, None)
172
+ return list({entry[4][0] for entry in info})
173
+ except Exception:
174
+ return []
175
+
176
+
177
+ def enumerar_subdominios(target: str) -> List[Subdominio]:
178
+ """
179
+ Enumera subdominios del target consultando crt.sh y HackerTarget.
180
+ Siempre incluye el propio target como subdominio raíz.
181
+
182
+ Parameters
183
+ ----------
184
+ target : Dominio raíz a analizar (p.ej. 'ejemplo.com')
185
+
186
+ Returns
187
+ -------
188
+ list[Subdominio] : Subdominios con sus IPs resueltas
189
+ """
190
+ nombres: Set[str] = set()
191
+ nombres.add(target) # el dominio raíz siempre
192
+ nombres.update(_subdominios_crtsh(target))
193
+ nombres.update(_subdominios_hackertarget(target))
194
+
195
+ resultado: List[Subdominio] = []
196
+ for nombre in sorted(nombres):
197
+ ips = _resolver_ip(nombre)
198
+ resultado.append(Subdominio(nombre=nombre, ips=ips))
199
+ return resultado
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Capa 2 — Escaneo de puertos (asyncio TCP connect)
204
+ # ---------------------------------------------------------------------------
205
+
206
+ async def _tcp_connect(
207
+ sem: asyncio.Semaphore,
208
+ host: str,
209
+ puerto: int,
210
+ timeout: float = _TCP_TIMEOUT,
211
+ ) -> Optional[PuertoAbierto]:
212
+ """
213
+ Intenta una conexión TCP al host:puerto bajo el semáforo dado.
214
+ Devuelve PuertoAbierto si el puerto está abierto, None si está cerrado/filtrado.
215
+ """
216
+ async with sem:
217
+ try:
218
+ conn = asyncio.open_connection(host, puerto)
219
+ reader, writer = await asyncio.wait_for(conn, timeout=timeout)
220
+ writer.close()
221
+ try:
222
+ await writer.wait_closed()
223
+ except Exception:
224
+ pass
225
+ servicio = _nombre_servicio(puerto)
226
+ return PuertoAbierto(host=host, puerto=puerto, servicio=servicio)
227
+ except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
228
+ return None
229
+
230
+
231
+ def _nombre_servicio(puerto: int) -> Optional[str]:
232
+ """Intenta obtener el nombre de servicio estándar para un puerto."""
233
+ try:
234
+ return socket.getservbyport(puerto, "tcp")
235
+ except OSError:
236
+ return None
237
+
238
+
239
+ async def _escanear_host_async(
240
+ host: str,
241
+ puertos: List[int],
242
+ sem: asyncio.Semaphore,
243
+ ) -> List[PuertoAbierto]:
244
+ """Escanea todos los puertos de un host de forma concurrente."""
245
+ tareas = [_tcp_connect(sem, host, p) for p in puertos]
246
+ resultados = await asyncio.gather(*tareas)
247
+ return [r for r in resultados if r is not None]
248
+
249
+
250
+ def escanear_puertos_stdlib(
251
+ subdominios: List[Subdominio],
252
+ puertos: List[int],
253
+ ) -> List[PuertoAbierto]:
254
+ """
255
+ Escanea los puertos especificados en todos los subdominios usando asyncio TCP.
256
+
257
+ Parameters
258
+ ----------
259
+ subdominios : Lista de subdominios a escanear
260
+ puertos : Lista de puertos a probar
261
+
262
+ Returns
263
+ -------
264
+ list[PuertoAbierto] : Puertos abiertos encontrados
265
+ """
266
+ sem = asyncio.Semaphore(_MAX_CONCURRENT)
267
+
268
+ async def _main() -> List[PuertoAbierto]:
269
+ tareas = []
270
+ for sub in subdominios:
271
+ if sub.ips: # solo si resuelve
272
+ tareas.append(_escanear_host_async(sub.nombre, puertos, sem))
273
+ if not tareas:
274
+ return []
275
+ listas = await asyncio.gather(*tareas)
276
+ return [p for lista in listas for p in lista]
277
+
278
+ return asyncio.run(_main())
279
+
280
+
281
+ def escanear_puertos_nmap(
282
+ subdominios: List[Subdominio],
283
+ puertos: List[int],
284
+ ) -> List[PuertoAbierto]:
285
+ """
286
+ Escanea puertos usando nmap como backend (requiere nmap en PATH).
287
+ Usa salida XML de nmap para parsear los puertos abiertos.
288
+
289
+ Parameters
290
+ ----------
291
+ subdominios : Lista de subdominios a escanear
292
+ puertos : Lista de puertos a probar
293
+
294
+ Returns
295
+ -------
296
+ list[PuertoAbierto] : Puertos abiertos
297
+ """
298
+ hosts_con_ip = [s.nombre for s in subdominios if s.ips]
299
+ if not hosts_con_ip:
300
+ return []
301
+
302
+ puertos_str = ",".join(str(p) for p in sorted(puertos))
303
+ cmd = [
304
+ "nmap", "-sT", "-Pn", "-T4",
305
+ f"-p{puertos_str}",
306
+ "--open",
307
+ "-oX", "-", # salida XML por stdout
308
+ ] + hosts_con_ip
309
+
310
+ try:
311
+ resultado = subprocess.run(
312
+ cmd,
313
+ capture_output=True,
314
+ text=True,
315
+ timeout=300,
316
+ )
317
+ return _parsear_nmap_xml(resultado.stdout)
318
+ except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
319
+ return []
320
+
321
+
322
+ def _parsear_nmap_xml(xml: str) -> List[PuertoAbierto]:
323
+ """Parsea la salida XML de nmap para extraer puertos abiertos."""
324
+ import xml.etree.ElementTree as ET
325
+ abiertos: List[PuertoAbierto] = []
326
+ if not xml.strip():
327
+ return abiertos
328
+ try:
329
+ root = ET.fromstring(xml)
330
+ except ET.ParseError:
331
+ return abiertos
332
+
333
+ for host in root.findall(".//host"):
334
+ hostname_el = host.find(".//hostname[@type='user']")
335
+ addr_el = host.find(".//address[@addrtype='ipv4']")
336
+ if hostname_el is not None:
337
+ host_str = hostname_el.get("name", "")
338
+ elif addr_el is not None:
339
+ host_str = addr_el.get("addr", "")
340
+ else:
341
+ continue
342
+
343
+ for port in host.findall(".//port"):
344
+ state = port.find("state")
345
+ if state is None or state.get("state") != "open":
346
+ continue
347
+ puerto_num = int(port.get("portid", 0))
348
+ servicio_el = port.find("service")
349
+ servicio = servicio_el.get("name") if servicio_el is not None else None
350
+ abiertos.append(PuertoAbierto(
351
+ host=host_str,
352
+ puerto=puerto_num,
353
+ servicio=servicio,
354
+ ))
355
+ return abiertos
356
+
357
+
358
+ # ---------------------------------------------------------------------------
359
+ # Capa 3 — Inspección de certificados TLS
360
+ # ---------------------------------------------------------------------------
361
+
362
+ def inspeccionar_cert(host: str, puerto: int = 443, timeout: float = 5.0) -> CertInfo:
363
+ """
364
+ Abre una conexión TLS al host:puerto y extrae la información del certificado.
365
+
366
+ Comprueba si el certificado es autofirmado (mismo CN en issuer y subject),
367
+ calcula la huella SHA-256 y extrae los SANs.
368
+
369
+ Parameters
370
+ ----------
371
+ host : Hostname al que conectarse (se usa también para SNI)
372
+ puerto : Puerto TLS (por defecto 443)
373
+ timeout : Segundos de timeout de conexión
374
+
375
+ Returns
376
+ -------
377
+ CertInfo : Estructura con todos los metadatos del certificado
378
+ """
379
+ ctx = ssl.create_default_context()
380
+ ctx.check_hostname = False
381
+ ctx.verify_mode = ssl.CERT_NONE # capturamos aunque sea inválido
382
+
383
+ try:
384
+ with socket.create_connection((host, puerto), timeout=timeout) as sock:
385
+ with ctx.wrap_socket(sock, server_hostname=host) as tls:
386
+ cert_der = tls.getpeercert(binary_form=True)
387
+ cert = tls.getpeercert()
388
+ except Exception as exc:
389
+ return CertInfo(host=host, puerto=puerto,
390
+ issued_to=None, issuer=None, not_after=None,
391
+ error=str(exc))
392
+
393
+ # Huella SHA-256
394
+ import hashlib
395
+ fingerprint = hashlib.sha256(cert_der).hexdigest() if cert_der else None
396
+
397
+ # Emisor y subject
398
+ subject = dict(x[0] for x in cert.get("subject", []))
399
+ issuer = dict(x[0] for x in cert.get("issuer", []))
400
+ issued_to = subject.get("commonName")
401
+ issuer_cn = issuer.get("commonName") or issuer.get("organizationName")
402
+
403
+ # Fecha de expiración → ISO-8601 UTC
404
+ not_after_raw = cert.get("notAfter", "")
405
+ not_after_iso: Optional[str] = None
406
+ if not_after_raw:
407
+ try:
408
+ dt = datetime.strptime(not_after_raw, "%b %d %H:%M:%S %Y %Z")
409
+ not_after_iso = dt.replace(tzinfo=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
410
+ except ValueError:
411
+ not_after_iso = not_after_raw
412
+
413
+ # SANs (Subject Alternative Names)
414
+ sans: List[str] = []
415
+ for tipo, valor in cert.get("subjectAltName", []):
416
+ if tipo == "DNS":
417
+ sans.append(valor)
418
+
419
+ # Autofirmado: el CN del emisor coincide con el subject
420
+ autofirmado = bool(
421
+ issued_to and issuer_cn and issued_to == issuer_cn
422
+ )
423
+
424
+ return CertInfo(
425
+ host=host,
426
+ puerto=puerto,
427
+ issued_to=issued_to,
428
+ issuer=issuer_cn,
429
+ not_after=not_after_iso,
430
+ sans=sans,
431
+ fingerprint=fingerprint,
432
+ autofirmado=autofirmado,
433
+ )
434
+
435
+
436
+ def inspeccionar_certs_batch(
437
+ subdominios: List[Subdominio],
438
+ puertos_abiertos: List[PuertoAbierto],
439
+ ) -> List[CertInfo]:
440
+ """
441
+ Inspecciona certificados TLS para todos los subdominios con puertos TLS abiertos.
442
+ Siempre intenta el puerto 443 si el subdominio resuelve, más cualquier
443
+ puerto de la lista _PUERTOS_TLS que esté abierto.
444
+
445
+ Parameters
446
+ ----------
447
+ subdominios : Subdominios descubiertos en la capa 1
448
+ puertos_abiertos : Puertos abiertos detectados en la capa 2
449
+
450
+ Returns
451
+ -------
452
+ list[CertInfo] : Certificados inspeccionados
453
+ """
454
+ # Construir conjunto de (host, puerto) a inspeccionar
455
+ candidatos: Set[Tuple[str, int]] = set()
456
+
457
+ # Intentar 443 para todos los subdominios que resuelven
458
+ for sub in subdominios:
459
+ if sub.ips:
460
+ candidatos.add((sub.nombre, 443))
461
+
462
+ # Añadir puertos TLS abiertos detectados
463
+ for pa in puertos_abiertos:
464
+ if pa.puerto in _PUERTOS_TLS:
465
+ candidatos.add((pa.host, pa.puerto))
466
+
467
+ certs: List[CertInfo] = []
468
+ for host, puerto in sorted(candidatos):
469
+ cert = inspeccionar_cert(host, puerto)
470
+ certs.append(cert)
471
+ return certs
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # Función principal de escaneo
476
+ # ---------------------------------------------------------------------------
477
+
478
+ def ejecutar_escaneo(
479
+ target: str,
480
+ puertos: Optional[List[int]] = None,
481
+ usar_nmap: bool = False,
482
+ ) -> ResultadoEscaneo:
483
+ """
484
+ Ejecuta el escaneo completo en tres capas para el target dado.
485
+
486
+ Parameters
487
+ ----------
488
+ target : Dominio raíz a escanear
489
+ puertos : Lista de puertos a probar (None → TOP_100_PORTS)
490
+ usar_nmap : Si True y nmap está en PATH, usarlo como backend de puertos
491
+
492
+ Returns
493
+ -------
494
+ ResultadoEscaneo : Estructura con subdominios, puertos abiertos y certs
495
+ """
496
+ if puertos is None:
497
+ puertos = TOP_100_PORTS
498
+
499
+ # Capa 1 — subdominios
500
+ subdominios = enumerar_subdominios(target)
501
+
502
+ # Capa 2 — puertos
503
+ nmap_disponible = usar_nmap and shutil.which("nmap") is not None
504
+ if nmap_disponible:
505
+ puertos_abiertos = escanear_puertos_nmap(subdominios, puertos)
506
+ else:
507
+ puertos_abiertos = escanear_puertos_stdlib(subdominios, puertos)
508
+
509
+ # Capa 3 — certificados TLS
510
+ certs = inspeccionar_certs_batch(subdominios, puertos_abiertos)
511
+
512
+ return ResultadoEscaneo(
513
+ target=target,
514
+ subdominios=subdominios,
515
+ puertos=puertos_abiertos,
516
+ certs=certs,
517
+ )