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(" ·", " ·")
|
|
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>☁ vamp-cloud-enum v{VERSION} — Cloud Bucket Enumerator</h1>
|
|
1230
|
+
<p>Generado: {now} · 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} · 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()
|