vamp-cloud-enum 1.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.
vamp_cloud_enum.py ADDED
@@ -0,0 +1,1614 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ vamp_cloud_enum.py — Enumerador de Buckets/Blobs Cloud Públicos
4
+ ================================================================
5
+ VampSecure Labs · VampSecure Studios
6
+ Para Uso Exclusivo en Pruebas de Penetración Autorizadas — v1.0
7
+
8
+ DESCRIPCIÓN GENERAL
9
+ -------------------
10
+ Herramienta de enumeración pasiva de almacenamiento cloud expuesto para los
11
+ principales proveedores: AWS S3, Azure Blob Storage y Google Cloud Storage.
12
+
13
+ Genera candidatos de nombre de bucket a partir de un dominio o nombre de
14
+ empresa objetivo, combinándolos con una lista de sufijos/prefijos habituales,
15
+ y verifica de forma concurrente cuáles están accesibles públicamente sin
16
+ autenticación (lectura libre o listado de contenido habilitado).
17
+
18
+ Todas las comprobaciones son PASIVAS: únicamente se realizan peticiones HEAD
19
+ y GET de sólo lectura. No se sube, modifica ni elimina ningún dato.
20
+
21
+ ARQUITECTURA DE EJECUCIÓN
22
+ --------------------------
23
+ Fase 1 — Generación de candidatos
24
+ Para cada objetivo (-d), genera combinaciones nombre+sufijos/prefijos.
25
+ También acepta lista de nombres personalizados (--wordlist).
26
+
27
+ Fase 2 — Sondeo concurrente (asyncio + aiohttp)
28
+ Hasta --concurrency peticiones simultáneas. Timeout configurable por
29
+ petición. Controla el semáforo para no saturar el proveedor.
30
+
31
+ Fase 3 — Clasificación y reporte
32
+ Asigna severidad por resultado (CRITICAL/HIGH/MEDIUM/LOW/INFO).
33
+ Muestra tabla Rich en consola, exporta JSON, HTML dark-theme y el
34
+ informe unificado VSL (formato de entrega al cliente).
35
+
36
+ PROVEEDORES SOPORTADOS
37
+ -----------------------
38
+ · AWS S3 — HEAD/GET en *.s3.amazonaws.com y s3.amazonaws.com/{bucket}
39
+ Verificación de listado (?list-type=2), website endpoint
40
+ · Azure Blob — HEAD en *.blob.core.windows.net, listado por container
41
+ (?restype=container&comp=list) en containers habituales
42
+ · GCP Storage — GET en storage.googleapis.com/{bucket} y *.storage.googleapis.com
43
+ Detección de ListBucketResult vs AccessDenied vs NoSuchBucket
44
+
45
+ SEVERIDAD
46
+ ----------
47
+ CRITICAL : Bucket con listado público habilitado o acceso total de lectura
48
+ HIGH : Bucket accesible (200) sin listado
49
+ MEDIUM : Bucket existente con nombre sensible (backup, secrets, db…)
50
+ LOW : Bucket existe pero es privado (403 → namespace expuesto)
51
+ INFO : Sin hallazgos
52
+
53
+ DEPENDENCIAS
54
+ ------------
55
+ aiohttp >= 3.9.0 — cliente HTTP asíncrono
56
+ rich >= 13.7.0 — salida de consola enriquecida, progress bar
57
+
58
+ AUTORÍA
59
+ -------
60
+ © VampSecure Studios — VampSecure Labs Security Research Division
61
+ Todos los derechos reservados. Uso exclusivo en entornos autorizados.
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import argparse
67
+ import asyncio
68
+ import json as _json_mod
69
+ import re
70
+ import sys
71
+ from dataclasses import dataclass, field
72
+ from datetime import datetime, timezone
73
+ from pathlib import Path
74
+ from typing import Dict, List, Optional, Tuple
75
+
76
+ import aiohttp
77
+ from rich.console import Console
78
+ from rich.panel import Panel
79
+ from rich.progress import (
80
+ BarColumn,
81
+ MofNCompleteColumn,
82
+ Progress,
83
+ SpinnerColumn,
84
+ TaskProgressColumn,
85
+ TextColumn,
86
+ TimeElapsedColumn,
87
+ )
88
+ from rich.table import Table
89
+ from rich.text import Text
90
+
91
+ from vampsec_report import (
92
+ Finding as VSLFinding,
93
+ VampSecReport,
94
+ add_report_args,
95
+ meta_from_args,
96
+ )
97
+
98
+ # =============================================================================
99
+ # CONSTANTES Y CONFIGURACIÓN
100
+ # =============================================================================
101
+
102
+ VERSION = "1.0"
103
+ TOOL_NAME = "vamp-cloud-enum"
104
+ AUTHOR = "© VampSecure Studios — VampSecure Labs Security Research Division"
105
+
106
+ console = Console()
107
+
108
+ BANNER = r"""
109
+ __ ___ __ __ ___ ___ ___ ___ _ _ ___ ___ _ _ ___ ___
110
+ \ \ / /_\ | \/ | _ \/ __| __/ __| | | | _ \ __| | /_\ | _ ) __|
111
+ \ V / _ \| |\/| | _/\__ \ _| (__| |_| | / _|| |__ / _ \| _ \__ \
112
+ \_/_/ \_\_| |_|_| |___/___\___|\___/|_|_\___|____/_/ \_\___/___/
113
+ by Antonio Hernandez "Belky" — VampSecure Studios
114
+ vamp-cloud-enum v1.0 · Cloud Bucket Enumerator
115
+ ────────────────────────────────────────────────────────────────────────
116
+ USO EXCLUSIVO EN AUDITORÍAS AUTORIZADAS · El uso no autorizado es ilegal
117
+ """
118
+
119
+ # Sufijos/prefijos estándar de uso habitual en nombres de bucket
120
+ BUCKET_SUFFIXES: List[str] = [
121
+ "assets", "backup", "backups", "bak", "data", "db", "dev", "docs",
122
+ "downloads", "files", "images", "img", "internal", "logs", "media",
123
+ "private", "prod", "public", "releases", "staging", "static", "storage",
124
+ "test", "uploads", "web", "www", "cdn", "config", "secrets", "archive",
125
+ "bucket", "s3", "store", "resources", "content", "api", "build", "cache",
126
+ "temp", "tmp", "dump", "export", "import", "raw", "source", "deploy",
127
+ ]
128
+
129
+ # Nombres de container Azure habituales a comprobar dentro de cada cuenta
130
+ AZURE_CONTAINERS: List[str] = [
131
+ "$web", "public", "files", "uploads", "static", "assets", "backup", "data",
132
+ ]
133
+
134
+ # Regiones S3 para el endpoint de website estático
135
+ S3_WEBSITE_REGIONS: List[str] = ["us-east-1", "eu-west-1"]
136
+
137
+ # Palabras clave que elevan la severidad de un bucket privado a MEDIUM
138
+ SENSITIVE_KEYWORDS: set = {
139
+ "backup", "backups", "bak", "db", "secret", "secrets", "config",
140
+ "private", "internal", "prod", "dump", "export", "import", "key",
141
+ "keys", "password", "passwords", "credentials", "creds",
142
+ }
143
+
144
+ # Cabeceras de interés para evidencias
145
+ HEADERS_OF_INTEREST: List[str] = [
146
+ "content-type", "x-amz-bucket-region", "x-amz-request-id",
147
+ "x-ms-request-id", "x-ms-version", "x-ms-error-code",
148
+ "x-goog-request-id", "x-goog-stored-content-length",
149
+ "last-modified", "server",
150
+ ]
151
+
152
+
153
+ # =============================================================================
154
+ # DATACLASSES
155
+ # =============================================================================
156
+
157
+ @dataclass
158
+ class BucketResult:
159
+ """
160
+ Resultado de la comprobación de un candidato de bucket/blob en un proveedor.
161
+
162
+ Attributes
163
+ ----------
164
+ name : Nombre del bucket candidato
165
+ provider : Proveedor cloud ("s3" / "azure" / "gcp")
166
+ url : URL completa que devolvió el resultado positivo
167
+ status : Estado lógico ("LISTING" / "PUBLIC" / "PRIVATE" / "NOT_FOUND")
168
+ http_code : Código HTTP obtenido
169
+ headers : Cabeceras relevantes de la respuesta
170
+ body_snippet : Primeros 500 bytes del cuerpo (si es XML de listado)
171
+ severity : Severidad asignada (CRITICAL / HIGH / MEDIUM / LOW / INFO)
172
+ finding_id : Identificador del hallazgo (CLOUD-NNN)
173
+ container : Nombre del container (sólo Azure)
174
+ """
175
+ name : str
176
+ provider : str
177
+ url : str
178
+ status : str
179
+ http_code : int
180
+ headers : Dict[str, str]
181
+ body_snippet : str
182
+ severity : str
183
+ finding_id : str
184
+ container : str = ""
185
+
186
+
187
+ @dataclass
188
+ class CloudEnumResult:
189
+ """
190
+ Resultado agregado de la enumeración de buckets para un objetivo.
191
+
192
+ Attributes
193
+ ----------
194
+ target : Nombre o dominio objetivo
195
+ buckets_checked : Número total de candidatos comprobados
196
+ buckets_found : Lista de BucketResult con acceso positivo (no NOT_FOUND)
197
+ error : Mensaje de error si la enumeración falló
198
+ """
199
+ target : str
200
+ buckets_checked : int
201
+ buckets_found : List[BucketResult] = field(default_factory=list)
202
+ error : Optional[str] = None
203
+
204
+
205
+ # =============================================================================
206
+ # GENERACIÓN DE CANDIDATOS
207
+ # =============================================================================
208
+
209
+ def _normalize_name(target: str) -> str:
210
+ """
211
+ Normaliza un dominio o nombre de empresa a un identificador válido
212
+ para nombre de bucket: convierte puntos a guiones y pasa a minúsculas.
213
+ """
214
+ name = target.lower().strip()
215
+ name = re.sub(r"[^a-z0-9\-]", "-", name)
216
+ name = re.sub(r"-+", "-", name).strip("-")
217
+ return name
218
+
219
+
220
+ def _strip_tld_variants(target: str) -> List[str]:
221
+ """
222
+ Genera variantes del objetivo eliminando el TLD y sus partes.
223
+
224
+ Ejemplo: "vampsecurestudios.com" → ["vampsecurestudios", "vampsecure"]
225
+ Aplica heurísticas simples: elimina la última parte (TLD) y parte
226
+ del dominio de segundo nivel si contiene un separador reconocible.
227
+ """
228
+ variants: List[str] = []
229
+ parts = target.split(".")
230
+ if len(parts) >= 2:
231
+ # Sin TLD
232
+ base = ".".join(parts[:-1])
233
+ variants.append(base)
234
+ # Si el dominio tiene subdivisiones con guion, tomar la primera parte
235
+ base_norm = _normalize_name(base)
236
+ sub_parts = base_norm.split("-")
237
+ if len(sub_parts) >= 2:
238
+ variants.append(sub_parts[0])
239
+ return variants
240
+
241
+
242
+ def generate_bucket_candidates(
243
+ targets: List[str],
244
+ extra_suffixes: List[str] | None = None,
245
+ ) -> List[Tuple[str, str]]:
246
+ """
247
+ Genera la lista completa de candidatos de nombre de bucket para los objetivos dados.
248
+
249
+ Para cada objetivo genera:
250
+ · {name}
251
+ · {name}-{suffix} / {name}_{suffix}
252
+ · {suffix}-{name} / {suffix}_{name}
253
+ · {name}.{suffix} (para GCP y Azure que permiten puntos)
254
+ · Variantes sin TLD con los mismos patrones
255
+
256
+ Devuelve una lista de tuplas (nombre_candidato, origen_objetivo) sin
257
+ duplicados, manteniendo el orden de generación.
258
+
259
+ Parameters
260
+ ----------
261
+ targets : Lista de dominios o nombres de empresa
262
+ extra_suffixes: Sufijos adicionales desde --wordlist
263
+ """
264
+ all_suffixes = list(BUCKET_SUFFIXES)
265
+ if extra_suffixes:
266
+ all_suffixes += [s.strip() for s in extra_suffixes if s.strip()]
267
+
268
+ seen: set = set()
269
+ candidates: List[Tuple[str, str]] = []
270
+
271
+ def _add(name: str, origin: str) -> None:
272
+ """Añade un candidato si no está ya en el conjunto."""
273
+ if name and name not in seen and len(name) >= 3:
274
+ seen.add(name)
275
+ candidates.append((name, origin))
276
+
277
+ for target in targets:
278
+ # Variantes del nombre base
279
+ names_to_try = [_normalize_name(target)]
280
+ for variant in _strip_tld_variants(target):
281
+ vn = _normalize_name(variant)
282
+ if vn not in names_to_try:
283
+ names_to_try.append(vn)
284
+
285
+ for name in names_to_try:
286
+ _add(name, target)
287
+ for sfx in all_suffixes:
288
+ _add(f"{name}-{sfx}", target)
289
+ _add(f"{name}_{sfx}", target)
290
+ _add(f"{sfx}-{name}", target)
291
+ _add(f"{sfx}_{name}", target)
292
+ _add(f"{name}.{sfx}", target)
293
+
294
+ return candidates
295
+
296
+
297
+ # =============================================================================
298
+ # CLASIFICACIÓN DE SEVERIDAD
299
+ # =============================================================================
300
+
301
+ def _bucket_severity(name: str, status: str) -> str:
302
+ """
303
+ Determina la severidad de un hallazgo en función del estado del bucket
304
+ y de si el nombre contiene palabras clave sensibles.
305
+
306
+ CRITICAL : listado público o acceso completo de lectura
307
+ HIGH : bucket accesible (200) sin listado
308
+ MEDIUM : bucket privado con nombre sensible → namespace expuesto + dato
309
+ LOW : bucket privado (403) → namespace expuesto
310
+ INFO : no encontrado o sin relevancia
311
+ """
312
+ if status == "LISTING":
313
+ return "CRITICAL"
314
+ if status == "PUBLIC":
315
+ name_lower = name.lower().replace("-", "_")
316
+ parts = set(name_lower.replace(".", "_").split("_"))
317
+ if parts & SENSITIVE_KEYWORDS:
318
+ return "CRITICAL"
319
+ return "HIGH"
320
+ if status == "PRIVATE":
321
+ name_lower = name.lower().replace("-", "_")
322
+ parts = set(name_lower.replace(".", "_").split("_"))
323
+ if parts & SENSITIVE_KEYWORDS:
324
+ return "MEDIUM"
325
+ return "LOW"
326
+ return "INFO"
327
+
328
+
329
+ # =============================================================================
330
+ # MÓDULO DE SONDEO — AWS S3
331
+ # =============================================================================
332
+
333
+ class S3Prober:
334
+ """
335
+ Módulo de sondeo pasivo para buckets AWS S3.
336
+
337
+ Comprueba acceso público mediante HEAD y listado de contenido con GET.
338
+ También verifica el endpoint de website estático en las regiones más comunes.
339
+ """
340
+
341
+ _UA = f"{TOOL_NAME}/{VERSION} VampSecureLabs"
342
+
343
+ def __init__(
344
+ self,
345
+ session: aiohttp.ClientSession,
346
+ timeout: int,
347
+ check_listing: bool,
348
+ ) -> None:
349
+ self._session = session
350
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
351
+ self._check_listing = check_listing
352
+
353
+ async def probe(self, name: str) -> Optional[BucketResult]:
354
+ """
355
+ Ejecuta todas las comprobaciones S3 sobre el nombre de bucket dado.
356
+
357
+ Devuelve el BucketResult más severo encontrado, o None si el bucket
358
+ no existe en ninguno de los endpoints probados.
359
+ """
360
+ results: List[BucketResult] = []
361
+
362
+ # 1. Endpoint virtual-hosted
363
+ r = await self._check_virtual_hosted(name)
364
+ if r:
365
+ results.append(r)
366
+
367
+ # 2. Endpoint path-style
368
+ r2 = await self._check_path_style(name)
369
+ if r2 and (not results or _sev_rank(r2.severity) < _sev_rank(results[0].severity)):
370
+ results.append(r2)
371
+
372
+ # 3. Listado de contenido
373
+ if self._check_listing and not _any_status(results, "LISTING"):
374
+ r3 = await self._check_listing_api(name)
375
+ if r3:
376
+ results.append(r3)
377
+
378
+ # 4. Website endpoint
379
+ r4 = await self._check_website(name)
380
+ if r4:
381
+ results.append(r4)
382
+
383
+ if not results:
384
+ return None
385
+
386
+ # Devolver el resultado de mayor severidad
387
+ results.sort(key=lambda x: _sev_rank(x.severity))
388
+ return results[0]
389
+
390
+ async def _check_virtual_hosted(self, name: str) -> Optional[BucketResult]:
391
+ """HEAD a https://{name}.s3.amazonaws.com/ para detectar existencia."""
392
+ url = f"https://{name}.s3.amazonaws.com/"
393
+ try:
394
+ async with self._session.head(
395
+ url, timeout=self._timeout, allow_redirects=True,
396
+ headers={"User-Agent": self._UA},
397
+ ) as resp:
398
+ if resp.status == 404:
399
+ body = await _safe_read(resp, 200)
400
+ if "NoSuchBucket" in body:
401
+ return None
402
+ # 404 sin NoSuchBucket puede indicar bucket inexistente
403
+ return None
404
+ if resp.status == 403:
405
+ return _make_result(
406
+ name, "s3", url, "PRIVATE", resp.status,
407
+ _extract_headers(resp),
408
+ "",
409
+ _bucket_severity(name, "PRIVATE"),
410
+ )
411
+ if resp.status == 200:
412
+ return _make_result(
413
+ name, "s3", url, "PUBLIC", resp.status,
414
+ _extract_headers(resp),
415
+ "",
416
+ _bucket_severity(name, "PUBLIC"),
417
+ )
418
+ except (aiohttp.ClientError, asyncio.TimeoutError):
419
+ pass
420
+ return None
421
+
422
+ async def _check_path_style(self, name: str) -> Optional[BucketResult]:
423
+ """GET a https://s3.amazonaws.com/{name}/ como endpoint path-style."""
424
+ url = f"https://s3.amazonaws.com/{name}/"
425
+ try:
426
+ async with self._session.get(
427
+ url, timeout=self._timeout, allow_redirects=True,
428
+ headers={"User-Agent": self._UA},
429
+ ) as resp:
430
+ body = await _safe_read(resp, 500)
431
+ if resp.status == 403:
432
+ if "NoSuchBucket" in body:
433
+ return None
434
+ return _make_result(
435
+ name, "s3", url, "PRIVATE", resp.status,
436
+ _extract_headers(resp), body,
437
+ _bucket_severity(name, "PRIVATE"),
438
+ )
439
+ if resp.status == 200:
440
+ if "<ListBucketResult" in body:
441
+ return _make_result(
442
+ name, "s3", url, "LISTING", resp.status,
443
+ _extract_headers(resp), body[:500],
444
+ "CRITICAL",
445
+ )
446
+ return _make_result(
447
+ name, "s3", url, "PUBLIC", resp.status,
448
+ _extract_headers(resp), body[:500],
449
+ _bucket_severity(name, "PUBLIC"),
450
+ )
451
+ except (aiohttp.ClientError, asyncio.TimeoutError):
452
+ pass
453
+ return None
454
+
455
+ async def _check_listing_api(self, name: str) -> Optional[BucketResult]:
456
+ """GET con parámetros de listado para detectar listado público habilitado."""
457
+ url = f"https://{name}.s3.amazonaws.com/?list-type=2&max-keys=5"
458
+ try:
459
+ async with self._session.get(
460
+ url, timeout=self._timeout,
461
+ headers={"User-Agent": self._UA},
462
+ ) as resp:
463
+ body = await _safe_read(resp, 500)
464
+ if resp.status == 200 and "<ListBucketResult" in body:
465
+ return _make_result(
466
+ name, "s3", url, "LISTING", resp.status,
467
+ _extract_headers(resp), body[:500],
468
+ "CRITICAL",
469
+ )
470
+ except (aiohttp.ClientError, asyncio.TimeoutError):
471
+ pass
472
+ return None
473
+
474
+ async def _check_website(self, name: str) -> Optional[BucketResult]:
475
+ """Comprueba el endpoint de website estático de S3 en regiones habituales."""
476
+ for region in S3_WEBSITE_REGIONS:
477
+ url = f"http://{name}.s3-website.{region}.amazonaws.com/"
478
+ try:
479
+ async with self._session.get(
480
+ url, timeout=self._timeout,
481
+ headers={"User-Agent": self._UA},
482
+ ) as resp:
483
+ body = await _safe_read(resp, 300)
484
+ if resp.status == 200 and "NoSuchBucket" not in body:
485
+ return _make_result(
486
+ name, "s3", url, "PUBLIC", resp.status,
487
+ _extract_headers(resp), body[:300],
488
+ _bucket_severity(name, "PUBLIC"),
489
+ )
490
+ except (aiohttp.ClientError, asyncio.TimeoutError):
491
+ pass
492
+ return None
493
+
494
+
495
+ # =============================================================================
496
+ # MÓDULO DE SONDEO — AZURE BLOB STORAGE
497
+ # =============================================================================
498
+
499
+ class AzureProber:
500
+ """
501
+ Módulo de sondeo pasivo para cuentas de almacenamiento Azure Blob Storage.
502
+
503
+ Comprueba la existencia de la cuenta y, en los containers conocidos,
504
+ si el listado público de blobs está habilitado.
505
+ """
506
+
507
+ _UA = f"{TOOL_NAME}/{VERSION} VampSecureLabs"
508
+
509
+ def __init__(
510
+ self,
511
+ session: aiohttp.ClientSession,
512
+ timeout: int,
513
+ check_listing: bool,
514
+ ) -> None:
515
+ self._session = session
516
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
517
+ self._check_listing = check_listing
518
+
519
+ async def probe(self, name: str) -> Optional[BucketResult]:
520
+ """
521
+ Ejecuta las comprobaciones Azure sobre el nombre de cuenta dado.
522
+
523
+ Primero verifica la existencia de la cuenta de almacenamiento y luego,
524
+ si existe, comprueba cada container conocido buscando acceso público.
525
+ """
526
+ # Los nombres Azure no pueden tener puntos o guiones bajos como S3/GCP
527
+ # Normalizar: sólo letras y dígitos, 3-24 caracteres
528
+ az_name = re.sub(r"[^a-z0-9]", "", name.lower())[:24]
529
+ if len(az_name) < 3:
530
+ return None
531
+
532
+ base_url = f"https://{az_name}.blob.core.windows.net/"
533
+ exists = await self._account_exists(az_name, base_url)
534
+ if not exists:
535
+ return None
536
+
537
+ # Cuenta existe: comprobar containers
538
+ best: Optional[BucketResult] = None
539
+
540
+ for container in AZURE_CONTAINERS:
541
+ r = await self._check_container(az_name, container)
542
+ if r and (best is None or _sev_rank(r.severity) < _sev_rank(best.severity)):
543
+ best = r
544
+ if best and best.status == "LISTING":
545
+ break # Ya encontramos el peor caso
546
+
547
+ if best:
548
+ return best
549
+
550
+ # Cuenta existe pero sin containers accesibles
551
+ return _make_result(
552
+ az_name, "azure", base_url, "PRIVATE", 200,
553
+ {}, "",
554
+ _bucket_severity(az_name, "PRIVATE"),
555
+ )
556
+
557
+ async def _account_exists(self, name: str, base_url: str) -> bool:
558
+ """Verifica si la cuenta de almacenamiento Azure existe mediante HEAD."""
559
+ try:
560
+ async with self._session.head(
561
+ base_url, timeout=self._timeout,
562
+ headers={"User-Agent": self._UA},
563
+ ) as resp:
564
+ # 200 o 400 (con mensaje de error de Azure) → cuenta existe
565
+ if resp.status in (200, 400, 403, 409):
566
+ return True
567
+ # 404 con StorageErrorCode indica que no existe la cuenta
568
+ if resp.status == 404:
569
+ return False
570
+ except (aiohttp.ClientError, asyncio.TimeoutError):
571
+ pass
572
+ return False
573
+
574
+ async def _check_container(
575
+ self, account: str, container: str
576
+ ) -> Optional[BucketResult]:
577
+ """
578
+ Comprueba si un container concreto es accesible públicamente.
579
+ Intenta listar blobs con restype=container&comp=list.
580
+ """
581
+ url = (
582
+ f"https://{account}.blob.core.windows.net/"
583
+ f"{container}?restype=container&comp=list&maxresults=5"
584
+ )
585
+ try:
586
+ async with self._session.get(
587
+ url, timeout=self._timeout,
588
+ headers={"User-Agent": self._UA},
589
+ ) as resp:
590
+ body = await _safe_read(resp, 500)
591
+ if resp.status == 200 and "<EnumerationResults" in body:
592
+ return _make_result(
593
+ account, "azure", url, "LISTING", resp.status,
594
+ _extract_headers(resp), body[:500],
595
+ "CRITICAL",
596
+ container=container,
597
+ )
598
+ if resp.status == 200:
599
+ return _make_result(
600
+ account, "azure", url, "PUBLIC", resp.status,
601
+ _extract_headers(resp), body[:500],
602
+ _bucket_severity(account, "PUBLIC"),
603
+ container=container,
604
+ )
605
+ except (aiohttp.ClientError, asyncio.TimeoutError):
606
+ pass
607
+ return None
608
+
609
+
610
+ # =============================================================================
611
+ # MÓDULO DE SONDEO — GCP CLOUD STORAGE
612
+ # =============================================================================
613
+
614
+ class GCPProber:
615
+ """
616
+ Módulo de sondeo pasivo para buckets Google Cloud Storage.
617
+
618
+ Comprueba acceso mediante los dos endpoints públicos GCS:
619
+ · https://storage.googleapis.com/{bucket}/
620
+ · https://{bucket}.storage.googleapis.com/
621
+ """
622
+
623
+ _UA = f"{TOOL_NAME}/{VERSION} VampSecureLabs"
624
+
625
+ def __init__(
626
+ self,
627
+ session: aiohttp.ClientSession,
628
+ timeout: int,
629
+ check_listing: bool,
630
+ ) -> None:
631
+ self._session = session
632
+ self._timeout = aiohttp.ClientTimeout(total=timeout)
633
+ self._check_listing = check_listing
634
+
635
+ async def probe(self, name: str) -> Optional[BucketResult]:
636
+ """
637
+ Ejecuta las comprobaciones GCS sobre el nombre de bucket dado.
638
+
639
+ Devuelve el BucketResult más severo encontrado, o None si el bucket
640
+ no existe en ninguno de los endpoints.
641
+ """
642
+ results: List[BucketResult] = []
643
+
644
+ r1 = await self._check_path_style(name)
645
+ if r1:
646
+ results.append(r1)
647
+
648
+ r2 = await self._check_virtual_hosted(name)
649
+ if r2 and (not results or _sev_rank(r2.severity) < _sev_rank(results[0].severity)):
650
+ results.append(r2)
651
+
652
+ if not results:
653
+ return None
654
+
655
+ results.sort(key=lambda x: _sev_rank(x.severity))
656
+ return results[0]
657
+
658
+ async def _check_path_style(self, name: str) -> Optional[BucketResult]:
659
+ """GET a https://storage.googleapis.com/{name}/"""
660
+ url = f"https://storage.googleapis.com/{name}/"
661
+ try:
662
+ async with self._session.get(
663
+ url, timeout=self._timeout,
664
+ headers={"User-Agent": self._UA},
665
+ ) as resp:
666
+ body = await _safe_read(resp, 500)
667
+ if resp.status == 404 and "NoSuchBucket" in body:
668
+ return None
669
+ if resp.status == 403 and "AccessDenied" in body:
670
+ return _make_result(
671
+ name, "gcp", url, "PRIVATE", resp.status,
672
+ _extract_headers(resp), body[:300],
673
+ _bucket_severity(name, "PRIVATE"),
674
+ )
675
+ if resp.status == 200:
676
+ if "<ListBucketResult" in body:
677
+ return _make_result(
678
+ name, "gcp", url, "LISTING", resp.status,
679
+ _extract_headers(resp), body[:500],
680
+ "CRITICAL",
681
+ )
682
+ return _make_result(
683
+ name, "gcp", url, "PUBLIC", resp.status,
684
+ _extract_headers(resp), body[:500],
685
+ _bucket_severity(name, "PUBLIC"),
686
+ )
687
+ except (aiohttp.ClientError, asyncio.TimeoutError):
688
+ pass
689
+ return None
690
+
691
+ async def _check_virtual_hosted(self, name: str) -> Optional[BucketResult]:
692
+ """GET a https://{name}.storage.googleapis.com/"""
693
+ url = f"https://{name}.storage.googleapis.com/"
694
+ try:
695
+ async with self._session.get(
696
+ url, timeout=self._timeout,
697
+ headers={"User-Agent": self._UA},
698
+ ) as resp:
699
+ body = await _safe_read(resp, 500)
700
+ if resp.status == 404 and "NoSuchBucket" in body:
701
+ return None
702
+ if resp.status == 403 and "AccessDenied" in body:
703
+ return _make_result(
704
+ name, "gcp", url, "PRIVATE", resp.status,
705
+ _extract_headers(resp), body[:300],
706
+ _bucket_severity(name, "PRIVATE"),
707
+ )
708
+ if resp.status == 200:
709
+ if "<ListBucketResult" in body:
710
+ return _make_result(
711
+ name, "gcp", url, "LISTING", resp.status,
712
+ _extract_headers(resp), body[:500],
713
+ "CRITICAL",
714
+ )
715
+ return _make_result(
716
+ name, "gcp", url, "PUBLIC", resp.status,
717
+ _extract_headers(resp), body[:500],
718
+ _bucket_severity(name, "PUBLIC"),
719
+ )
720
+ except (aiohttp.ClientError, asyncio.TimeoutError):
721
+ pass
722
+ return None
723
+
724
+
725
+ # =============================================================================
726
+ # UTILIDADES INTERNAS
727
+ # =============================================================================
728
+
729
+ def _sev_rank(severity: str) -> int:
730
+ """Devuelve el rango numérico de una severidad (menor = más severo)."""
731
+ return {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}.get(severity, 99)
732
+
733
+
734
+ def _any_status(results: List[BucketResult], status: str) -> bool:
735
+ """Comprueba si alguno de los resultados tiene el estado dado."""
736
+ return any(r.status == status for r in results)
737
+
738
+
739
+ async def _safe_read(resp: aiohttp.ClientResponse, limit: int) -> str:
740
+ """
741
+ Lee hasta `limit` bytes del cuerpo de la respuesta de forma segura.
742
+ Devuelve cadena vacía si hay error o el cuerpo está vacío.
743
+ """
744
+ try:
745
+ raw = await resp.content.read(limit)
746
+ return raw.decode("utf-8", errors="replace")
747
+ except Exception:
748
+ return ""
749
+
750
+
751
+ def _extract_headers(resp: aiohttp.ClientResponse) -> Dict[str, str]:
752
+ """
753
+ Extrae las cabeceras de interés de la respuesta aiohttp.
754
+ Devuelve un diccionario con las cabeceras encontradas en minúsculas.
755
+ """
756
+ result: Dict[str, str] = {}
757
+ for h in HEADERS_OF_INTEREST:
758
+ val = resp.headers.get(h)
759
+ if val:
760
+ result[h] = val
761
+ return result
762
+
763
+
764
+ _finding_counter = 0
765
+
766
+
767
+ def _make_result(
768
+ name: str,
769
+ provider: str,
770
+ url: str,
771
+ status: str,
772
+ http_code: int,
773
+ headers: Dict[str, str],
774
+ body_snippet: str,
775
+ severity: str,
776
+ container: str = "",
777
+ ) -> BucketResult:
778
+ """
779
+ Construye un BucketResult con un finding_id único correlativo.
780
+ """
781
+ global _finding_counter
782
+ _finding_counter += 1
783
+ return BucketResult(
784
+ name = name,
785
+ provider = provider,
786
+ url = url,
787
+ status = status,
788
+ http_code = http_code,
789
+ headers = headers,
790
+ body_snippet = body_snippet,
791
+ severity = severity,
792
+ finding_id = f"CLOUD-{_finding_counter:03d}",
793
+ container = container,
794
+ )
795
+
796
+
797
+ # =============================================================================
798
+ # MOTOR DE ENUMERACIÓN PRINCIPAL
799
+ # =============================================================================
800
+
801
+ class CloudEnumerator:
802
+ """
803
+ Orquestador de la enumeración de buckets cloud.
804
+
805
+ Coordina los módulos de sondeo S3, Azure y GCP, gestiona el semáforo
806
+ de concurrencia y actualiza el progress bar de Rich en tiempo real.
807
+ """
808
+
809
+ def __init__(
810
+ self,
811
+ providers: List[str],
812
+ concurrency: int,
813
+ timeout: int,
814
+ check_listing: bool,
815
+ ) -> None:
816
+ self._providers = providers
817
+ self._concurrency = concurrency
818
+ self._timeout = timeout
819
+ self._check_listing = check_listing
820
+
821
+ async def enumerate(
822
+ self,
823
+ candidates: List[Tuple[str, str]],
824
+ progress: Progress,
825
+ task_id,
826
+ ) -> List[BucketResult]:
827
+ """
828
+ Comprueba todos los candidatos en todos los proveedores seleccionados,
829
+ de forma concurrente con un semáforo de control.
830
+
831
+ Parameters
832
+ ----------
833
+ candidates : Lista de (nombre_bucket, origen_objetivo)
834
+ progress : Barra de progreso Rich (para actualizar avance)
835
+ task_id : ID de la tarea en el progress bar
836
+
837
+ Returns
838
+ -------
839
+ Lista de BucketResult donde status != NOT_FOUND
840
+ """
841
+ semaphore = asyncio.Semaphore(self._concurrency)
842
+ found: List[BucketResult] = []
843
+
844
+ connector = aiohttp.TCPConnector(
845
+ limit=self._concurrency,
846
+ ssl=False, # desactivar verificación SSL para robustez
847
+ enable_cleanup_closed=True,
848
+ )
849
+ async with aiohttp.ClientSession(connector=connector) as session:
850
+ s3_prober = S3Prober(session, self._timeout, self._check_listing)
851
+ azure_prober = AzureProber(session, self._timeout, self._check_listing)
852
+ gcp_prober = GCPProber(session, self._timeout, self._check_listing)
853
+
854
+ async def _probe_one(name: str) -> None:
855
+ """Comprueba un candidato en todos los proveedores configurados."""
856
+ async with semaphore:
857
+ for provider in self._providers:
858
+ try:
859
+ if provider == "s3":
860
+ result = await s3_prober.probe(name)
861
+ elif provider == "azure":
862
+ result = await azure_prober.probe(name)
863
+ elif provider == "gcp":
864
+ result = await gcp_prober.probe(name)
865
+ else:
866
+ result = None
867
+
868
+ if result and result.status != "NOT_FOUND":
869
+ found.append(result)
870
+ except Exception:
871
+ pass
872
+ progress.advance(task_id)
873
+
874
+ tasks = [asyncio.create_task(_probe_one(name)) for name, _ in candidates]
875
+ await asyncio.gather(*tasks, return_exceptions=True)
876
+
877
+ return found
878
+
879
+
880
+ # =============================================================================
881
+ # SALIDA Y REPORTE
882
+ # =============================================================================
883
+
884
+ _STATUS_STYLE: Dict[str, str] = {
885
+ "LISTING" : "bold red",
886
+ "PUBLIC" : "bold yellow",
887
+ "PRIVATE" : "dim cyan",
888
+ "NOT_FOUND": "dim",
889
+ }
890
+
891
+ _SEV_STYLE: Dict[str, str] = {
892
+ "CRITICAL": "bold red",
893
+ "HIGH" : "bold yellow",
894
+ "MEDIUM" : "bold magenta",
895
+ "LOW" : "cyan",
896
+ "INFO" : "dim",
897
+ }
898
+
899
+ _PROVIDER_LABEL: Dict[str, str] = {
900
+ "s3" : "[bold orange1]AWS S3[/]",
901
+ "azure": "[bold blue]Azure[/]",
902
+ "gcp" : "[bold green]GCP[/]",
903
+ }
904
+
905
+
906
+ def print_summary_table(results: List[BucketResult]) -> None:
907
+ """
908
+ Muestra la tabla de resumen de buckets encontrados agrupada por proveedor.
909
+ """
910
+ if not results:
911
+ console.print("\n[dim]No se encontraron buckets/blobs accesibles.[/dim]\n")
912
+ return
913
+
914
+ tbl = Table(
915
+ title=f"\n[bold]Buckets / Blobs Cloud Encontrados[/] — {len(results)} resultado(s)",
916
+ show_header=True,
917
+ header_style="bold",
918
+ border_style="dim",
919
+ expand=False,
920
+ )
921
+ tbl.add_column("ID", style="dim", width=10)
922
+ tbl.add_column("Bucket", style="bold white", min_width=28)
923
+ tbl.add_column("Proveedor", width=9)
924
+ tbl.add_column("Estado", width=10)
925
+ tbl.add_column("HTTP", width=5, justify="right")
926
+ tbl.add_column("Severidad", width=10)
927
+ tbl.add_column("URL", no_wrap=False, min_width=30)
928
+
929
+ # Ordenar: CRITICAL primero, luego por proveedor
930
+ sorted_results = sorted(results, key=lambda r: (_sev_rank(r.severity), r.provider, r.name))
931
+
932
+ for r in sorted_results:
933
+ sev_text = Text(r.severity, style=_SEV_STYLE.get(r.severity, ""))
934
+ status_text = Text(r.status, style=_STATUS_STYLE.get(r.status, ""))
935
+ tbl.add_row(
936
+ r.finding_id,
937
+ r.name + (f"\n[dim]container: {r.container}[/dim]" if r.container else ""),
938
+ _PROVIDER_LABEL.get(r.provider, r.provider),
939
+ status_text,
940
+ str(r.http_code),
941
+ sev_text,
942
+ r.url,
943
+ )
944
+
945
+ console.print(tbl)
946
+
947
+
948
+ def print_critical_panels(results: List[BucketResult]) -> None:
949
+ """
950
+ Imprime paneles detallados para cada hallazgo CRITICAL o HIGH,
951
+ con evidencia completa y recomendación de remediación.
952
+ """
953
+ top = [r for r in results if r.severity in ("CRITICAL", "HIGH")]
954
+ if not top:
955
+ return
956
+
957
+ console.print()
958
+ for r in sorted(top, key=lambda x: _sev_rank(x.severity)):
959
+ remediation = _get_remediation(r)
960
+ headers_str = "\n".join(f" {k}: {v}" for k, v in r.headers.items()) or " (sin cabeceras registradas)"
961
+ body_str = r.body_snippet[:400] if r.body_snippet else "(sin cuerpo)"
962
+
963
+ contenido = (
964
+ f"[bold]Bucket:[/] {r.name}\n"
965
+ f"[bold]Proveedor:[/] {r.provider.upper()}\n"
966
+ f"[bold]Estado:[/] {r.status}\n"
967
+ f"[bold]URL:[/] {r.url}\n"
968
+ f"[bold]HTTP:[/] {r.http_code}\n"
969
+ + (f"[bold]Container:[/] {r.container}\n" if r.container else "")
970
+ + f"\n[bold]Cabeceras de interés:[/]\n{headers_str}\n"
971
+ + f"\n[bold]Cuerpo (fragmento):[/]\n[dim]{body_str}[/dim]\n"
972
+ + f"\n[bold yellow]Remediación:[/]\n{remediation}"
973
+ )
974
+
975
+ border = "red" if r.severity == "CRITICAL" else "yellow"
976
+ console.print(Panel(
977
+ contenido,
978
+ title=f"[bold {border}]{r.finding_id} · {r.severity}[/] — {r.name}",
979
+ border_style=border,
980
+ expand=False,
981
+ ))
982
+
983
+
984
+ def _get_remediation(r: BucketResult) -> str:
985
+ """Devuelve el texto de remediación según el proveedor y el estado."""
986
+ if r.provider == "s3":
987
+ if r.status == "LISTING":
988
+ return (
989
+ "Deshabilitar el acceso de listado público en la consola de AWS S3:\n"
990
+ " · Bucket → Permissions → Block Public Access → habilitar todas las opciones\n"
991
+ " · Revisar la Bucket Policy y eliminar las cláusulas con 's3:GetObject' o "
992
+ "'s3:ListBucket' abiertas a '*' (Principal: *).\n"
993
+ " · Auditar los objetos contenidos por si hay datos sensibles expuestos."
994
+ )
995
+ if r.status == "PUBLIC":
996
+ return (
997
+ "Restringir el acceso público al bucket:\n"
998
+ " · Habilitar 'Block Public Access' en AWS S3.\n"
999
+ " · Eliminar permisos ACL 'public-read' o 'public-read-write'.\n"
1000
+ " · Revisar la Bucket Policy para accesos sin condición de autenticación."
1001
+ )
1002
+ return (
1003
+ "El bucket existe y su nombre está expuesto en el espacio de nombres de S3 (AWS).\n"
1004
+ " · Confirmar que el acceso privado es intencional.\n"
1005
+ " · Eliminar el bucket si ya no está en uso para reducir la superficie de ataque."
1006
+ )
1007
+ if r.provider == "azure":
1008
+ if r.status == "LISTING":
1009
+ return (
1010
+ "Deshabilitar el acceso público en Azure Blob Storage:\n"
1011
+ " · Storage Account → Configuration → 'Allow Blob public access' → Disabled\n"
1012
+ " · Revisar el nivel de acceso de cada container (Private / Blob / Container).\n"
1013
+ " · Utilizar SAS Tokens o Azure AD para accesos controlados."
1014
+ )
1015
+ if r.status == "PUBLIC":
1016
+ return (
1017
+ "Restringir el nivel de acceso del container de Azure Blob:\n"
1018
+ " · Cambiar el acceso del container a 'Private'.\n"
1019
+ " · Deshabilitar 'Allow Blob public access' a nivel de cuenta."
1020
+ )
1021
+ return (
1022
+ "La cuenta de almacenamiento Azure existe y su nombre está expuesto.\n"
1023
+ " · Confirmar que los containers tienen el nivel de acceso correcto.\n"
1024
+ " · Eliminar la cuenta si ya no está en uso."
1025
+ )
1026
+ # GCP
1027
+ if r.status == "LISTING":
1028
+ return (
1029
+ "Eliminar el permiso allUsers en GCS:\n"
1030
+ " · Cloud Console → Storage → Bucket → Permissions → Eliminar 'allUsers' y 'allAuthenticatedUsers'.\n"
1031
+ " · Usar Cloud IAM con cuentas de servicio para accesos controlados.\n"
1032
+ " · Habilitar 'Uniform bucket-level access' para mayor coherencia de permisos."
1033
+ )
1034
+ if r.status == "PUBLIC":
1035
+ return (
1036
+ "Restringir el acceso público al bucket GCS:\n"
1037
+ " · Eliminar ACL y permisos IAM de 'allUsers'.\n"
1038
+ " · Activar 'Uniform bucket-level access' en el bucket."
1039
+ )
1040
+ return (
1041
+ "El bucket GCS existe y su nombre está expuesto.\n"
1042
+ " · Confirmar que los permisos IAM son correctos.\n"
1043
+ " · Eliminar el bucket si ya no está en uso."
1044
+ )
1045
+
1046
+
1047
+ # =============================================================================
1048
+ # EXPORTACIÓN JSON / HTML DARK-THEME
1049
+ # =============================================================================
1050
+
1051
+ def export_json(results: List[CloudEnumResult], path: str) -> None:
1052
+ """
1053
+ Exporta los resultados en formato JSON estructurado VSL.
1054
+
1055
+ El fichero incluye metadatos de la ejecución, resumen por proveedor
1056
+ y la lista completa de hallazgos con toda la evidencia.
1057
+ """
1058
+ now = datetime.now(timezone.utc).isoformat()
1059
+ data = {
1060
+ "schema" : "vamp-cloud-enum-v1",
1061
+ "generated" : now,
1062
+ "tool" : TOOL_NAME,
1063
+ "version" : VERSION,
1064
+ "targets" : list({r.target for r in results}),
1065
+ "summary" : {
1066
+ "total_checked" : sum(r.buckets_checked for r in results),
1067
+ "total_found" : sum(len(r.buckets_found) for r in results),
1068
+ "by_severity" : _count_by(results, "severity"),
1069
+ "by_provider" : _count_by(results, "provider"),
1070
+ "by_status" : _count_by(results, "status"),
1071
+ },
1072
+ "results" : [
1073
+ {
1074
+ "target" : res.target,
1075
+ "buckets_checked" : res.buckets_checked,
1076
+ "error" : res.error,
1077
+ "buckets" : [
1078
+ {
1079
+ "finding_id" : b.finding_id,
1080
+ "name" : b.name,
1081
+ "provider" : b.provider,
1082
+ "url" : b.url,
1083
+ "status" : b.status,
1084
+ "http_code" : b.http_code,
1085
+ "severity" : b.severity,
1086
+ "headers" : b.headers,
1087
+ "body_snippet" : b.body_snippet,
1088
+ "container" : b.container,
1089
+ }
1090
+ for b in sorted(res.buckets_found, key=lambda x: _sev_rank(x.severity))
1091
+ ],
1092
+ }
1093
+ for res in results
1094
+ ],
1095
+ }
1096
+ Path(path).write_text(_json_mod.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
1097
+
1098
+
1099
+ def _count_by(results: List[CloudEnumResult], attr: str) -> Dict[str, int]:
1100
+ """Cuenta hallazgos por un atributo dado del BucketResult."""
1101
+ counts: Dict[str, int] = {}
1102
+ for res in results:
1103
+ for b in res.buckets_found:
1104
+ key = getattr(b, attr, "unknown")
1105
+ counts[key] = counts.get(key, 0) + 1
1106
+ return counts
1107
+
1108
+
1109
+ def export_html(results: List[CloudEnumResult], path: str) -> None:
1110
+ """
1111
+ Genera un informe HTML dark-theme standalone para vamp-cloud-enum.
1112
+
1113
+ Incluye:
1114
+ · Cabecera con metadatos de la ejecución
1115
+ · Tabla de resumen con código de color por severidad
1116
+ · Tarjetas detalladas para cada hallazgo con evidencia y remediación
1117
+ · Completamente standalone: sin CDN ni dependencias externas
1118
+ """
1119
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
1120
+ all_buckets = sorted(
1121
+ [b for res in results for b in res.buckets_found],
1122
+ key=lambda x: (_sev_rank(x.severity), x.provider, x.name),
1123
+ )
1124
+
1125
+ sev_colors = {
1126
+ "CRITICAL": "#c0392b",
1127
+ "HIGH" : "#e67e22",
1128
+ "MEDIUM" : "#8e44ad",
1129
+ "LOW" : "#2980b9",
1130
+ "INFO" : "#7f8c8d",
1131
+ }
1132
+
1133
+ def badge(sev: str) -> str:
1134
+ color = sev_colors.get(sev, "#555")
1135
+ return (
1136
+ f'<span style="background:{color};color:#fff;padding:2px 8px;'
1137
+ f'border-radius:3px;font-size:.75em;font-weight:700">{sev}</span>'
1138
+ )
1139
+
1140
+ filas = ""
1141
+ for b in all_buckets:
1142
+ filas += (
1143
+ f"<tr>"
1144
+ f"<td><code>{b.finding_id}</code></td>"
1145
+ f"<td>{b.name}{('<br><small style=color:#888>'+b.container+'</small>') if b.container else ''}</td>"
1146
+ f"<td style='text-transform:uppercase;font-size:.8em'>{b.provider}</td>"
1147
+ f"<td style='font-weight:700'>{b.status}</td>"
1148
+ f"<td style='text-align:center'>{b.http_code}</td>"
1149
+ f"<td>{badge(b.severity)}</td>"
1150
+ f"<td style='font-size:.8em;word-break:break-all'><a href='{b.url}' style='color:#5dade2'>{b.url}</a></td>"
1151
+ f"</tr>\n"
1152
+ )
1153
+
1154
+ tarjetas = ""
1155
+ for b in all_buckets:
1156
+ if b.severity not in ("CRITICAL", "HIGH"):
1157
+ continue
1158
+ color = sev_colors.get(b.severity, "#888")
1159
+ hdrs = "\n".join(f"{k}: {v}" for k, v in b.headers.items()) or "(sin cabeceras)"
1160
+ rem = _get_remediation(b).replace("\n", "<br>").replace(" ·", "&nbsp;&nbsp;·")
1161
+ container = f"<p><strong>Container:</strong> {b.container}</p>" if b.container else ""
1162
+ tarjetas += f"""
1163
+ <div style="border:1px solid #333;border-left:4px solid {color};border-radius:6px;margin:14px 0;overflow:hidden">
1164
+ <div style="background:#1e1e2e;padding:12px 16px;display:flex;gap:10px;align-items:center">
1165
+ <code style="color:#888;font-size:.8em">{b.finding_id}</code>
1166
+ <strong style="flex:1">{b.name}</strong>
1167
+ {badge(b.severity)}
1168
+ </div>
1169
+ <div style="padding:14px 16px;font-size:.88em">
1170
+ <p><strong>Proveedor:</strong> {b.provider.upper()}</p>
1171
+ <p><strong>Estado:</strong> {b.status}</p>
1172
+ <p><strong>URL:</strong> <a href="{b.url}" style="color:#5dade2">{b.url}</a></p>
1173
+ {container}
1174
+ <p><strong>HTTP:</strong> {b.http_code}</p>
1175
+ <details style="margin-top:10px"><summary style="cursor:pointer;color:#aaa">Cabeceras de interés</summary>
1176
+ <pre style="background:#111;padding:8px;border-radius:4px;font-size:.8em">{hdrs}</pre>
1177
+ </details>
1178
+ {'<details style="margin-top:10px"><summary style="cursor:pointer;color:#aaa">Fragmento de respuesta</summary><pre style="background:#111;padding:8px;border-radius:4px;font-size:.8em;white-space:pre-wrap">' + b.body_snippet[:500] + '</pre></details>' if b.body_snippet else ''}
1179
+ <div style="margin-top:12px;background:#0d2b1a;padding:10px 12px;border-radius:4px;font-size:.85em">
1180
+ <strong style="color:#2ecc71">Remediación:</strong><br>{rem}
1181
+ </div>
1182
+ </div>
1183
+ </div>"""
1184
+
1185
+ targets_str = ", ".join({r.target for r in results})
1186
+ total_found = len(all_buckets)
1187
+ total_checked = sum(r.buckets_checked for r in results)
1188
+
1189
+ html = f"""<!DOCTYPE html>
1190
+ <html lang="es">
1191
+ <head>
1192
+ <meta charset="UTF-8">
1193
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1194
+ <title>vamp-cloud-enum — Informe Cloud Enum — {now}</title>
1195
+ <style>
1196
+ *{{box-sizing:border-box;margin:0;padding:0}}
1197
+ body{{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,monospace;
1198
+ background:#0d0d1a;color:#d0d0e8;line-height:1.65;font-size:14px}}
1199
+ a{{color:#5dade2;text-decoration:none}}
1200
+ code{{font-family:'Courier New',monospace;background:#1a1a2e;
1201
+ padding:1px 5px;border-radius:3px;font-size:.88em}}
1202
+ pre{{background:#111;border:1px solid #2a2a3e;border-radius:4px;
1203
+ padding:10px;font-size:.8em;white-space:pre-wrap;word-break:break-all}}
1204
+ .wrap{{max-width:1100px;margin:0 auto;padding:0 0 60px}}
1205
+ .header{{background:linear-gradient(140deg,#0d0d1a 0%,#1a0a0a 60%,#3a0505 100%);
1206
+ padding:40px 32px 32px;border-bottom:2px solid #c0392b}}
1207
+ .header h1{{font-size:1.6em;color:#fff;margin-bottom:4px}}
1208
+ .header p{{font-size:.85em;color:#888}}
1209
+ .section{{padding:24px 32px;border-bottom:1px solid #1e1e2e}}
1210
+ .section h2{{font-size:1em;color:#c0392b;text-transform:uppercase;
1211
+ letter-spacing:.6px;margin-bottom:16px}}
1212
+ .kpis{{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px}}
1213
+ .kpi{{background:#1a1a2e;border:1px solid #2a2a3e;border-radius:6px;
1214
+ padding:12px 18px;text-align:center;min-width:100px}}
1215
+ .kv{{font-size:2em;font-weight:700;color:#fff}}
1216
+ .kl{{font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.4px;margin-top:2px}}
1217
+ table{{width:100%;border-collapse:collapse;font-size:.84em}}
1218
+ th{{background:#111;color:#666;padding:8px 10px;text-align:left;
1219
+ border-bottom:2px solid #222;font-size:.76em;text-transform:uppercase;
1220
+ letter-spacing:.4px;font-weight:700}}
1221
+ td{{padding:8px 10px;border-bottom:1px solid #1a1a2a;vertical-align:top}}
1222
+ tr:hover td{{background:#111}}
1223
+ .brand{{padding:20px 32px;font-size:.72em;color:#444;border-top:1px solid #1a1a2e;margin-top:20px}}
1224
+ </style>
1225
+ </head>
1226
+ <body>
1227
+ <div class="wrap">
1228
+ <div class="header">
1229
+ <h1>&#x2601; vamp-cloud-enum v{VERSION} — Cloud Bucket Enumerator</h1>
1230
+ <p>Generado: {now} &nbsp;·&nbsp; Objetivos: {targets_str}</p>
1231
+ <p style="color:#666;font-size:.8em">{AUTHOR}</p>
1232
+ </div>
1233
+
1234
+ <div class="section">
1235
+ <h2>Resumen</h2>
1236
+ <div class="kpis">
1237
+ <div class="kpi"><div class="kv">{total_checked}</div><div class="kl">Comprobados</div></div>
1238
+ <div class="kpi"><div class="kv">{total_found}</div><div class="kl">Encontrados</div></div>
1239
+ {"".join(
1240
+ f'<div class="kpi"><div class="kv" style="color:{sev_colors[s]}">'
1241
+ f'{sum(1 for b in all_buckets if b.severity==s)}</div><div class="kl">{s}</div></div>'
1242
+ for s in ("CRITICAL","HIGH","MEDIUM","LOW") if any(b.severity==s for b in all_buckets)
1243
+ )}
1244
+ </div>
1245
+ </div>
1246
+
1247
+ <div class="section">
1248
+ <h2>Tabla de hallazgos</h2>
1249
+ <table>
1250
+ <tr><th>ID</th><th>Bucket</th><th>Proveedor</th><th>Estado</th><th>HTTP</th><th>Severidad</th><th>URL</th></tr>
1251
+ {filas if filas else '<tr><td colspan="7" style="text-align:center;color:#444;padding:20px">Sin hallazgos</td></tr>'}
1252
+ </table>
1253
+ </div>
1254
+
1255
+ {'<div class="section"><h2>Hallazgos CRITICAL / HIGH — Detalle</h2>' + tarjetas + '</div>' if tarjetas else ''}
1256
+
1257
+ <div class="brand">
1258
+ {AUTHOR} &nbsp;·&nbsp; USO EXCLUSIVO EN AUDITORÍAS AUTORIZADAS
1259
+ </div>
1260
+ </div>
1261
+ </body>
1262
+ </html>"""
1263
+ Path(path).write_text(html, encoding="utf-8")
1264
+
1265
+
1266
+ # =============================================================================
1267
+ # CONVERSIÓN A FORMATO INFORME UNIFICADO VSL
1268
+ # =============================================================================
1269
+
1270
+ def _to_vsl_findings(results: List[CloudEnumResult]) -> List[VSLFinding]:
1271
+ """
1272
+ Convierte los BucketResult al formato Finding del módulo vampsec_report.
1273
+
1274
+ Sólo se incluyen hallazgos con severidad CRITICAL, HIGH o MEDIUM.
1275
+ Para los LOW, se genera un hallazgo agrupado por proveedor si hay muchos.
1276
+ """
1277
+ findings: List[VSLFinding] = []
1278
+ all_buckets = sorted(
1279
+ [b for res in results for b in res.buckets_found],
1280
+ key=lambda x: _sev_rank(x.severity),
1281
+ )
1282
+
1283
+ for b in all_buckets:
1284
+ if b.severity == "INFO":
1285
+ continue
1286
+
1287
+ hdrs_str = "\n".join(f"{k}: {v}" for k, v in b.headers.items()) or "(sin cabeceras)"
1288
+ body_str = f"\nFragmento de cuerpo:\n{b.body_snippet[:400]}" if b.body_snippet else ""
1289
+ container_str = f"\nContainer: {b.container}" if b.container else ""
1290
+
1291
+ if b.severity == "CRITICAL":
1292
+ if b.status == "LISTING":
1293
+ description = (
1294
+ f"El bucket '{b.name}' en {b.provider.upper()} tiene el listado de contenido "
1295
+ f"habilitado públicamente. Cualquier usuario de Internet puede enumerar y "
1296
+ f"descargar todos los objetos/blobs sin autenticación."
1297
+ )
1298
+ else:
1299
+ description = (
1300
+ f"El bucket '{b.name}' en {b.provider.upper()} es accesible públicamente "
1301
+ f"sin autenticación. El acceso de lectura está abierto a cualquier usuario "
1302
+ f"de Internet."
1303
+ )
1304
+ refs = [
1305
+ "https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html"
1306
+ if b.provider == "s3" else
1307
+ "https://learn.microsoft.com/azure/storage/blobs/anonymous-read-access-prevent"
1308
+ if b.provider == "azure" else
1309
+ "https://cloud.google.com/storage/docs/access-control/making-data-public",
1310
+ ]
1311
+ elif b.severity == "HIGH":
1312
+ description = (
1313
+ f"El bucket '{b.name}' en {b.provider.upper()} es accesible públicamente "
1314
+ f"(HTTP {b.http_code}) pero el listado de contenido no está habilitado o "
1315
+ f"no pudo verificarse. El acceso sin autenticación representa un riesgo alto."
1316
+ )
1317
+ refs = []
1318
+ elif b.severity == "MEDIUM":
1319
+ description = (
1320
+ f"El bucket '{b.name}' en {b.provider.upper()} existe y tiene un nombre "
1321
+ f"que sugiere contenido sensible (backup, secrets, config, etc.). "
1322
+ f"Aunque actualmente es privado (HTTP {b.http_code}), su exposición en el "
1323
+ f"espacio de nombres público supone un riesgo de fuga de información si "
1324
+ f"la configuración de acceso cambia."
1325
+ )
1326
+ refs = []
1327
+ else: # LOW
1328
+ description = (
1329
+ f"El bucket '{b.name}' en {b.provider.upper()} existe (HTTP {b.http_code}) "
1330
+ f"y es privado, pero su nombre está expuesto en el espacio de nombres público "
1331
+ f"del proveedor. Esto puede facilitar ataques de enumeración o de "
1332
+ f"bucket squatting."
1333
+ )
1334
+ refs = []
1335
+
1336
+ findings.append(VSLFinding(
1337
+ id = b.finding_id,
1338
+ title = f"{b.provider.upper()} bucket {'con listado público' if b.status=='LISTING' else 'accesible' if b.status=='PUBLIC' else 'existente'}: {b.name}"[:80],
1339
+ severity = b.severity,
1340
+ description = description,
1341
+ evidence = (
1342
+ f"URL: {b.url}\n"
1343
+ f"HTTP: {b.http_code}\n"
1344
+ f"Estado: {b.status}\n"
1345
+ + container_str + "\n"
1346
+ + f"Cabeceras:\n{hdrs_str}"
1347
+ + body_str
1348
+ ),
1349
+ affected = b.url,
1350
+ remediation = _get_remediation(b),
1351
+ references = refs,
1352
+ tags = ["cloud", "storage", b.provider, b.status.lower(), "passive"],
1353
+ ))
1354
+
1355
+ return findings
1356
+
1357
+
1358
+ # =============================================================================
1359
+ # ARGUMENTOS CLI
1360
+ # =============================================================================
1361
+
1362
+ def parse_args() -> argparse.Namespace:
1363
+ """Parsea y valida los argumentos de línea de comandos."""
1364
+ p = argparse.ArgumentParser(
1365
+ prog=TOOL_NAME,
1366
+ description=(
1367
+ f"VampSecure Labs Cloud Enum v{VERSION} — "
1368
+ "Enumerador pasivo de buckets S3 · Azure Blob · GCP Storage"
1369
+ ),
1370
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1371
+ epilog="""Ejemplos:
1372
+ %(prog)s -d ejemplo.com
1373
+ %(prog)s -d ejemplo.com -d miempresa.es --providers s3,gcp
1374
+ %(prog)s -d empresa.com --wordlist mis_buckets.txt --concurrency 50
1375
+ %(prog)s -d empresa.com --json resultado.json --html informe.html
1376
+ %(prog)s -d empresa.com --no-content-listing --timeout 5
1377
+ """,
1378
+ )
1379
+
1380
+ p.add_argument(
1381
+ "-d", "--domain",
1382
+ metavar="DOMINIO",
1383
+ action="append",
1384
+ dest="domains",
1385
+ required=True,
1386
+ help="Dominio o nombre de empresa objetivo (puede repetirse: -d a.com -d b.com)",
1387
+ )
1388
+ p.add_argument(
1389
+ "-w", "--wordlist",
1390
+ metavar="FICHERO",
1391
+ help="Wordlist personalizada de sufijos/prefijos de nombre de bucket (uno por línea)",
1392
+ )
1393
+ p.add_argument(
1394
+ "--providers",
1395
+ metavar="LISTA",
1396
+ default="all",
1397
+ help="Proveedores a comprobar: s3,azure,gcp o 'all' (default: all)",
1398
+ )
1399
+ p.add_argument(
1400
+ "--concurrency",
1401
+ metavar="N",
1402
+ type=int,
1403
+ default=30,
1404
+ help="Máximo de peticiones concurrentes (default: 30)",
1405
+ )
1406
+ p.add_argument(
1407
+ "--timeout",
1408
+ metavar="SEG",
1409
+ type=int,
1410
+ default=8,
1411
+ help="Timeout por petición en segundos (default: 8)",
1412
+ )
1413
+ p.add_argument(
1414
+ "--no-content-listing",
1415
+ action="store_true",
1416
+ dest="no_listing",
1417
+ help="Omitir la comprobación de listado de contenido (sólo verificar acceso público)",
1418
+ )
1419
+ p.add_argument(
1420
+ "--json",
1421
+ metavar="FICHERO",
1422
+ dest="json_out",
1423
+ help="Guardar resultados en formato JSON",
1424
+ )
1425
+ p.add_argument(
1426
+ "--html",
1427
+ metavar="FICHERO",
1428
+ dest="html_out",
1429
+ help="Guardar informe HTML dark-theme",
1430
+ )
1431
+
1432
+ add_report_args(p)
1433
+ return p.parse_args()
1434
+
1435
+
1436
+ def _resolve_providers(providers_arg: str) -> List[str]:
1437
+ """
1438
+ Convierte el argumento --providers en la lista de proveedores activos.
1439
+ Devuelve ["s3", "azure", "gcp"] si el valor es "all".
1440
+ """
1441
+ valid = {"s3", "azure", "gcp"}
1442
+ if providers_arg.lower() == "all":
1443
+ return ["s3", "azure", "gcp"]
1444
+ selected = [p.strip().lower() for p in providers_arg.split(",")]
1445
+ unknown = [p for p in selected if p not in valid]
1446
+ if unknown:
1447
+ console.print(f"[yellow]⚠ Proveedores desconocidos ignorados: {', '.join(unknown)}[/yellow]")
1448
+ return [p for p in selected if p in valid]
1449
+
1450
+
1451
+ def _load_wordlist(path: str) -> List[str]:
1452
+ """
1453
+ Carga una wordlist desde un fichero de texto, una entrada por línea.
1454
+ Ignora líneas vacías y comentarios (#).
1455
+ """
1456
+ try:
1457
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
1458
+ return [l.strip() for l in lines if l.strip() and not l.startswith("#")]
1459
+ except OSError as e:
1460
+ console.print(f"[red]Error al cargar wordlist '{path}': {e}[/red]")
1461
+ return []
1462
+
1463
+
1464
+ # =============================================================================
1465
+ # FUNCIÓN PRINCIPAL
1466
+ # =============================================================================
1467
+
1468
+ async def run(args: argparse.Namespace) -> int:
1469
+ """
1470
+ Orquesta la enumeración completa de buckets cloud.
1471
+
1472
+ 1. Genera candidatos para todos los objetivos
1473
+ 2. Sondeo concurrente con progress bar Rich
1474
+ 3. Muestra tabla de resultados y paneles detallados
1475
+ 4. Exporta JSON/HTML/informe VSL si se solicitó
1476
+ 5. Devuelve código de salida: 2=CRITICAL, 1=HIGH, 0=limpio
1477
+ """
1478
+ providers = _resolve_providers(args.providers)
1479
+ extra_sfx = _load_wordlist(args.wordlist) if args.wordlist else None
1480
+ check_listing = not args.no_listing
1481
+
1482
+ if not providers:
1483
+ console.print("[red]Error: no hay proveedores válidos seleccionados.[/red]")
1484
+ return 1
1485
+
1486
+ # ── Panel de configuración ────────────────────────────────────────────────
1487
+ console.print(Panel.fit(
1488
+ f"Objetivos : [bold]{', '.join(args.domains)}[/]\n"
1489
+ f"Proveedores: [bold]{', '.join(p.upper() for p in providers)}[/]\n"
1490
+ f"Concurrencia: {args.concurrency} · Timeout: {args.timeout}s · "
1491
+ f"Listado: {'sí' if check_listing else 'no (--no-content-listing)'}\n"
1492
+ f"Wordlist: {args.wordlist or '(sin wordlist adicional)'}",
1493
+ title="[bold cyan]VampSecure Labs — Cloud Enum[/]",
1494
+ border_style="cyan",
1495
+ ))
1496
+
1497
+ # ── Generar candidatos ────────────────────────────────────────────────────
1498
+ candidates = generate_bucket_candidates(args.domains, extra_suffixes=extra_sfx)
1499
+ total_probes = len(candidates) * len(providers)
1500
+
1501
+ console.print(
1502
+ f"\n[cyan]Candidatos generados:[/] {len(candidates)} nombres × {len(providers)} proveedor(es) "
1503
+ f"= [bold]{total_probes}[/] sondeos\n"
1504
+ )
1505
+
1506
+ # ── Sondeo con progress bar ───────────────────────────────────────────────
1507
+ enumerator = CloudEnumerator(
1508
+ providers = providers,
1509
+ concurrency = args.concurrency,
1510
+ timeout = args.timeout,
1511
+ check_listing = check_listing,
1512
+ )
1513
+
1514
+ all_results: List[CloudEnumResult] = []
1515
+
1516
+ with Progress(
1517
+ SpinnerColumn(style="cyan"),
1518
+ TextColumn("[progress.description]{task.description}"),
1519
+ BarColumn(bar_width=40),
1520
+ MofNCompleteColumn(),
1521
+ TaskProgressColumn(),
1522
+ TimeElapsedColumn(),
1523
+ console=console,
1524
+ transient=False,
1525
+ ) as progress:
1526
+ task_id = progress.add_task(
1527
+ "[cyan]Sondeando buckets cloud…[/]",
1528
+ total=len(candidates),
1529
+ )
1530
+ found_all = await enumerator.enumerate(candidates, progress, task_id)
1531
+
1532
+ # Agrupar por objetivo (heurística: asignar todos al primer objetivo
1533
+ # ya que los candidatos mezclan todos los objetivos)
1534
+ result = CloudEnumResult(
1535
+ target = " | ".join(args.domains),
1536
+ buckets_checked = len(candidates),
1537
+ buckets_found = found_all,
1538
+ )
1539
+ all_results.append(result)
1540
+
1541
+ # ── Salida en consola ─────────────────────────────────────────────────────
1542
+ print_summary_table(found_all)
1543
+ print_critical_panels(found_all)
1544
+
1545
+ # ── Exportaciones ─────────────────────────────────────────────────────────
1546
+ if args.json_out:
1547
+ export_json(all_results, args.json_out)
1548
+ console.print(f"[green]✔[/] JSON guardado en {args.json_out}")
1549
+
1550
+ if args.html_out:
1551
+ export_html(all_results, args.html_out)
1552
+ console.print(f"[green]✔[/] HTML guardado en {args.html_out}")
1553
+
1554
+ # Informes de cliente (formato unificado VSL)
1555
+ report_html = getattr(args, "report_html", None)
1556
+ report_pdf = getattr(args, "report_pdf", None)
1557
+ if report_html or report_pdf:
1558
+ meta = meta_from_args(args, tool=TOOL_NAME, version=VERSION)
1559
+ vsl_rep = VampSecReport(meta, _to_vsl_findings(all_results))
1560
+ if report_html:
1561
+ vsl_rep.to_html_client(report_html)
1562
+ console.print(f"[green]✔[/] Informe cliente HTML: {report_html}")
1563
+ if report_pdf:
1564
+ try:
1565
+ vsl_rep.to_pdf(report_pdf)
1566
+ console.print(f"[green]✔[/] Informe cliente PDF: {report_pdf}")
1567
+ except RuntimeError as e:
1568
+ console.print(f"[yellow]⚠ PDF no generado: {e}[/yellow]")
1569
+
1570
+ # ── Resumen final + código de salida ──────────────────────────────────────
1571
+ n_critical = sum(1 for b in found_all if b.severity == "CRITICAL")
1572
+ n_high = sum(1 for b in found_all if b.severity == "HIGH")
1573
+ n_medium = sum(1 for b in found_all if b.severity == "MEDIUM")
1574
+ n_low = sum(1 for b in found_all if b.severity == "LOW")
1575
+
1576
+ console.print()
1577
+ console.print(Panel(
1578
+ f"Candidatos comprobados : [bold]{len(candidates)}[/]\n"
1579
+ f"Buckets encontrados : [bold]{len(found_all)}[/]\n"
1580
+ f"[bold red]CRITICAL[/] : {n_critical} "
1581
+ f"[bold yellow]HIGH[/] : {n_high} "
1582
+ f"[bold magenta]MEDIUM[/] : {n_medium} "
1583
+ f"[cyan]LOW[/] : {n_low}",
1584
+ title="[bold]Resumen Final[/]",
1585
+ border_style="cyan",
1586
+ ))
1587
+
1588
+ if n_critical > 0:
1589
+ return 2
1590
+ if n_high > 0:
1591
+ return 1
1592
+ return 0
1593
+
1594
+
1595
+ # =============================================================================
1596
+ # PUNTO DE ENTRADA
1597
+ # =============================================================================
1598
+
1599
+ def main() -> None:
1600
+ """Punto de entrada principal de vamp-cloud-enum."""
1601
+ console.print(BANNER, style="bold cyan")
1602
+
1603
+ args = parse_args()
1604
+ try:
1605
+ exit_code = asyncio.run(run(args))
1606
+ except KeyboardInterrupt:
1607
+ print("\nInterrumpido por el usuario.", file=sys.stderr)
1608
+ sys.exit(130)
1609
+
1610
+ sys.exit(exit_code)
1611
+
1612
+
1613
+ if __name__ == "__main__":
1614
+ main()