vamp-easm 1.2__tar.gz → 1.4__tar.gz
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_easm-1.2 → vamp_easm-1.4}/PKG-INFO +14 -1
- {vamp_easm-1.2 → vamp_easm-1.4}/README.md +13 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/pyproject.toml +1 -1
- {vamp_easm-1.2 → vamp_easm-1.4}/vamp_easm.egg-info/PKG-INFO +14 -1
- {vamp_easm-1.2 → vamp_easm-1.4}/vamp_easm.py +448 -2
- {vamp_easm-1.2 → vamp_easm-1.4}/LICENSE +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/easm/__init__.py +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/easm/alerter.py +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/easm/differ.py +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/easm/scanner.py +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/easm/storage.py +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/setup.cfg +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/vamp_easm.egg-info/SOURCES.txt +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/vamp_easm.egg-info/dependency_links.txt +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/vamp_easm.egg-info/entry_points.txt +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/vamp_easm.egg-info/requires.txt +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/vamp_easm.egg-info/top_level.txt +0 -0
- {vamp_easm-1.2 → vamp_easm-1.4}/vampsec_report.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vamp-easm
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4
|
|
4
4
|
Summary: External Attack Surface Management (EASM) scanner for authorized audits
|
|
5
5
|
Author-email: VampSecure Studios <contact@vampsecurestudios.com>
|
|
6
6
|
License: MIT
|
|
@@ -20,6 +20,7 @@ License-File: LICENSE
|
|
|
20
20
|
Requires-Dist: rich>=13.7.0
|
|
21
21
|
Dynamic: license-file
|
|
22
22
|
|
|
23
|
+
<!-- © VampSecure Studios — VampSecure Labs Security Research Division -->
|
|
23
24
|
# vamp-easm
|
|
24
25
|
|
|
25
26
|
**Continuous External Attack Surface Management with daily diff tracking**
|
|
@@ -59,6 +60,13 @@ Part of the [VampSecure Labs](https://github.com/Vampsecure-Labs) security toolk
|
|
|
59
60
|
|
|
60
61
|
## Installation
|
|
61
62
|
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install vamp-easm
|
|
66
|
+
# o con Homebrew:
|
|
67
|
+
brew install vampsecure-labs/labs/vamp-easm
|
|
68
|
+
```
|
|
69
|
+
|
|
62
70
|
```bash
|
|
63
71
|
# 1. Clone and enter the directory
|
|
64
72
|
git clone https://github.com/Vampsecure-Labs/vamp-easm.git
|
|
@@ -210,3 +218,8 @@ MIT License — see [LICENSE](LICENSE) for details.
|
|
|
210
218
|
|
|
211
219
|
© VampSecure Studios — VampSecure Labs Security Research Division
|
|
212
220
|
For authorized penetration testing use only.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Versión
|
|
225
|
+
v1.3 — VampSecure Labs Security Research Division
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<!-- © VampSecure Studios — VampSecure Labs Security Research Division -->
|
|
1
2
|
# vamp-easm
|
|
2
3
|
|
|
3
4
|
**Continuous External Attack Surface Management with daily diff tracking**
|
|
@@ -37,6 +38,13 @@ Part of the [VampSecure Labs](https://github.com/Vampsecure-Labs) security toolk
|
|
|
37
38
|
|
|
38
39
|
## Installation
|
|
39
40
|
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install vamp-easm
|
|
44
|
+
# o con Homebrew:
|
|
45
|
+
brew install vampsecure-labs/labs/vamp-easm
|
|
46
|
+
```
|
|
47
|
+
|
|
40
48
|
```bash
|
|
41
49
|
# 1. Clone and enter the directory
|
|
42
50
|
git clone https://github.com/Vampsecure-Labs/vamp-easm.git
|
|
@@ -188,3 +196,8 @@ MIT License — see [LICENSE](LICENSE) for details.
|
|
|
188
196
|
|
|
189
197
|
© VampSecure Studios — VampSecure Labs Security Research Division
|
|
190
198
|
For authorized penetration testing use only.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Versión
|
|
203
|
+
v1.3 — VampSecure Labs Security Research Division
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vamp-easm
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4
|
|
4
4
|
Summary: External Attack Surface Management (EASM) scanner for authorized audits
|
|
5
5
|
Author-email: VampSecure Studios <contact@vampsecurestudios.com>
|
|
6
6
|
License: MIT
|
|
@@ -20,6 +20,7 @@ License-File: LICENSE
|
|
|
20
20
|
Requires-Dist: rich>=13.7.0
|
|
21
21
|
Dynamic: license-file
|
|
22
22
|
|
|
23
|
+
<!-- © VampSecure Studios — VampSecure Labs Security Research Division -->
|
|
23
24
|
# vamp-easm
|
|
24
25
|
|
|
25
26
|
**Continuous External Attack Surface Management with daily diff tracking**
|
|
@@ -59,6 +60,13 @@ Part of the [VampSecure Labs](https://github.com/Vampsecure-Labs) security toolk
|
|
|
59
60
|
|
|
60
61
|
## Installation
|
|
61
62
|
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install vamp-easm
|
|
66
|
+
# o con Homebrew:
|
|
67
|
+
brew install vampsecure-labs/labs/vamp-easm
|
|
68
|
+
```
|
|
69
|
+
|
|
62
70
|
```bash
|
|
63
71
|
# 1. Clone and enter the directory
|
|
64
72
|
git clone https://github.com/Vampsecure-Labs/vamp-easm.git
|
|
@@ -210,3 +218,8 @@ MIT License — see [LICENSE](LICENSE) for details.
|
|
|
210
218
|
|
|
211
219
|
© VampSecure Studios — VampSecure Labs Security Research Division
|
|
212
220
|
For authorized penetration testing use only.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Versión
|
|
225
|
+
v1.3 — VampSecure Labs Security Research Division
|
|
@@ -76,7 +76,7 @@ from vampsec_report import (
|
|
|
76
76
|
# Constantes
|
|
77
77
|
# ---------------------------------------------------------------------------
|
|
78
78
|
|
|
79
|
-
VERSION = "1.
|
|
79
|
+
VERSION = "1.4"
|
|
80
80
|
TOOL = "vamp-easm"
|
|
81
81
|
BRAND = "VampSecure Labs — EASM Continuo"
|
|
82
82
|
|
|
@@ -87,7 +87,7 @@ BANNER = (
|
|
|
87
87
|
" \\ V / _ \\| |\\/| | _/\\__ \\ _| (__| |_| | / _|| |__ / _ \\| _ \\__ \\\n"
|
|
88
88
|
" \\_/_/ \\_\\_| |_|_| |___/___\\___|\\___/|_|_\\___|____/_/ \\_\\___/___/\n"
|
|
89
89
|
' by Antonio Hernandez "Belky" — VampSecure Studios\n'
|
|
90
|
-
" vamp-easm v1.
|
|
90
|
+
" vamp-easm v1.4 · External Attack Surface Management\n"
|
|
91
91
|
" ────────────────────────────────────────────────────────────────────────\n"
|
|
92
92
|
" USO EXCLUSIVO EN AUDITORÍAS AUTORIZADAS · El uso no autorizado es ilegal\n"
|
|
93
93
|
)
|
|
@@ -196,6 +196,374 @@ def _tabla_historial(rows) -> Table:
|
|
|
196
196
|
return tabla
|
|
197
197
|
|
|
198
198
|
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# Integración Shodan (v1.3)
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
# Puertos considerados sensibles para emitir hallazgo MEDIUM
|
|
204
|
+
_SHODAN_PUERTOS_SENSIBLES = {21, 23, 445, 3389, 1433, 3306, 5432, 5900, 6379, 27017, 9200}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ShodanEASMEnricher:
|
|
208
|
+
"""
|
|
209
|
+
Enriquecimiento EASM con la API REST de Shodan por IP.
|
|
210
|
+
|
|
211
|
+
Para cada IP descubierta en el escaneo consulta
|
|
212
|
+
https://api.shodan.io/shodan/host/<ip>?key=<api_key>
|
|
213
|
+
y añade hallazgos al listado de findings del escaneo:
|
|
214
|
+
|
|
215
|
+
- Puerto sensible abierto → MEDIUM
|
|
216
|
+
- Vulnerabilidades en campo 'vulns' → HIGH
|
|
217
|
+
- Hostname alternativo → INFO
|
|
218
|
+
|
|
219
|
+
Límite: 10 IPs máximo por invocación (para no agotar créditos API).
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
_HOST_URL = "https://api.shodan.io/shodan/host/{ip}?key={key}"
|
|
223
|
+
_MAX_IPS = 10
|
|
224
|
+
|
|
225
|
+
def __init__(self, api_key: str) -> None:
|
|
226
|
+
self._key = api_key
|
|
227
|
+
|
|
228
|
+
def _enrich_with_shodan(
|
|
229
|
+
self,
|
|
230
|
+
ips_or_hosts: list,
|
|
231
|
+
api_key: str,
|
|
232
|
+
findings: list,
|
|
233
|
+
) -> None:
|
|
234
|
+
"""
|
|
235
|
+
Enriquece la lista de findings con datos Shodan para cada IP.
|
|
236
|
+
|
|
237
|
+
Modifica findings in-place añadiendo los hallazgos relevantes.
|
|
238
|
+
|
|
239
|
+
Parámetros
|
|
240
|
+
----------
|
|
241
|
+
ips_or_hosts : list[str] — IPs o nombres de host a consultar
|
|
242
|
+
api_key : str — Clave API de Shodan
|
|
243
|
+
findings : list — Lista de Finding a la que se añaden los hallazgos
|
|
244
|
+
"""
|
|
245
|
+
import urllib.request as _ureq
|
|
246
|
+
import json as _json
|
|
247
|
+
|
|
248
|
+
# Filtrar solo IPs (IPv4 básico) y descartar repeticiones
|
|
249
|
+
ips_validas = []
|
|
250
|
+
vistas: set = set()
|
|
251
|
+
for h in ips_or_hosts:
|
|
252
|
+
h = h.strip()
|
|
253
|
+
partes = h.split(".")
|
|
254
|
+
if len(partes) == 4 and all(p.isdigit() for p in partes) and h not in vistas:
|
|
255
|
+
ips_validas.append(h)
|
|
256
|
+
vistas.add(h)
|
|
257
|
+
|
|
258
|
+
ips_validas = ips_validas[: self._MAX_IPS]
|
|
259
|
+
|
|
260
|
+
idx_base = max((int(f.id.split("-")[-1]) for f in findings if "-" in f.id), default=0)
|
|
261
|
+
|
|
262
|
+
for i, ip in enumerate(ips_validas, start=1):
|
|
263
|
+
url = self._HOST_URL.format(ip=ip, key=api_key)
|
|
264
|
+
try:
|
|
265
|
+
req = _ureq.Request(
|
|
266
|
+
url,
|
|
267
|
+
headers={"User-Agent": f"vamp-easm/{VERSION}"},
|
|
268
|
+
)
|
|
269
|
+
with _ureq.urlopen(req, timeout=15) as resp:
|
|
270
|
+
codigo = resp.status
|
|
271
|
+
if codigo == 401:
|
|
272
|
+
findings.append(Finding(
|
|
273
|
+
id = f"EASM-SHD-{idx_base + i:03d}",
|
|
274
|
+
title = "API key Shodan inválida o sin créditos",
|
|
275
|
+
severity = "INFO",
|
|
276
|
+
description = "La clave API de Shodan devolvió 401. Verificar validez y cuota.",
|
|
277
|
+
evidence = f"URL: {self._HOST_URL.format(ip=ip, key='***')}",
|
|
278
|
+
affected = ip,
|
|
279
|
+
remediation = "Comprobar la clave API en https://account.shodan.io/",
|
|
280
|
+
tags = ["shodan", "easm"],
|
|
281
|
+
))
|
|
282
|
+
return # Si 401, todas las demás peticiones fallarán también
|
|
283
|
+
if codigo == 404:
|
|
284
|
+
# IP no indexada en Shodan: INFO silencioso
|
|
285
|
+
continue
|
|
286
|
+
if codigo != 200:
|
|
287
|
+
continue
|
|
288
|
+
data = _json.loads(resp.read())
|
|
289
|
+
except Exception:
|
|
290
|
+
# Error de red o timeout: continuar con la siguiente IP
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
puertos: list = data.get("ports", []) or []
|
|
294
|
+
vulns_raw: dict = data.get("vulns", {}) or {}
|
|
295
|
+
hostnames: list = data.get("hostnames", []) or []
|
|
296
|
+
org: str = data.get("org", "") or ""
|
|
297
|
+
sistema: str = data.get("os", "") or ""
|
|
298
|
+
|
|
299
|
+
# Hallazgo: puertos sensibles abiertos
|
|
300
|
+
sensibles = [p for p in puertos if p in _SHODAN_PUERTOS_SENSIBLES]
|
|
301
|
+
for puerto in sensibles:
|
|
302
|
+
idx_base += 1
|
|
303
|
+
findings.append(Finding(
|
|
304
|
+
id = f"EASM-SHD-{idx_base:03d}",
|
|
305
|
+
title = f"Puerto sensible {puerto} abierto según Shodan — {ip}",
|
|
306
|
+
severity = "MEDIUM",
|
|
307
|
+
description = (
|
|
308
|
+
f"Shodan ha indexado el puerto {puerto} como abierto en la IP {ip}. "
|
|
309
|
+
"Este puerto corresponde a un servicio de alto riesgo si está expuesto "
|
|
310
|
+
"sin control de acceso."
|
|
311
|
+
),
|
|
312
|
+
evidence = (
|
|
313
|
+
f"IP: {ip}\n"
|
|
314
|
+
f"Puerto: {puerto}\n"
|
|
315
|
+
f"Org: {org or '—'}\n"
|
|
316
|
+
f"OS: {sistema or '—'}\n"
|
|
317
|
+
f"Fuente: Shodan (datos históricos — verificar estado actual)"
|
|
318
|
+
),
|
|
319
|
+
affected = f"{ip}:{puerto}",
|
|
320
|
+
remediation = (
|
|
321
|
+
f"Verificar si el puerto {puerto} en {ip} debe estar accesible desde Internet. "
|
|
322
|
+
"Aplicar reglas de firewall para restringir el acceso por IP de origen. "
|
|
323
|
+
"Actualizar el servicio a la última versión segura."
|
|
324
|
+
),
|
|
325
|
+
tags = ["shodan", "easm", "exposed-port"],
|
|
326
|
+
))
|
|
327
|
+
|
|
328
|
+
# Hallazgo: vulnerabilidades reportadas por Shodan (campo 'vulns')
|
|
329
|
+
if vulns_raw:
|
|
330
|
+
cves_str = ", ".join(list(vulns_raw.keys())[:10])
|
|
331
|
+
idx_base += 1
|
|
332
|
+
findings.append(Finding(
|
|
333
|
+
id = f"EASM-SHD-{idx_base:03d}",
|
|
334
|
+
title = f"Vulnerabilidades Shodan en {ip}: {cves_str[:60]}",
|
|
335
|
+
severity = "HIGH",
|
|
336
|
+
description = (
|
|
337
|
+
f"Shodan reporta {len(vulns_raw)} vulnerabilidad(es) conocida(s) en la IP {ip}. "
|
|
338
|
+
"Estas vulnerabilidades pueden estar presentes en los servicios actualmente "
|
|
339
|
+
"expuestos según el índice de Shodan."
|
|
340
|
+
),
|
|
341
|
+
evidence = (
|
|
342
|
+
f"IP: {ip}\n"
|
|
343
|
+
f"Vulnerabilidades Shodan: {cves_str}\n"
|
|
344
|
+
f"Org: {org or '—'}\n"
|
|
345
|
+
f"Fuente: Shodan — los datos pueden tener latencia de semanas"
|
|
346
|
+
),
|
|
347
|
+
affected = ip,
|
|
348
|
+
remediation = (
|
|
349
|
+
"Verificar el estado actual de las vulnerabilidades listadas en los servicios "
|
|
350
|
+
"del host. Consultar el NVD para detalles de parches y aplicarlos. "
|
|
351
|
+
"Referencia: https://nvd.nist.gov/"
|
|
352
|
+
),
|
|
353
|
+
cve = list(vulns_raw.keys())[0] if vulns_raw else None,
|
|
354
|
+
tags = ["shodan", "easm", "cve", "vulnerability"],
|
|
355
|
+
))
|
|
356
|
+
|
|
357
|
+
# Hallazgo INFO: hostname alternativo reportado por Shodan
|
|
358
|
+
for hostname in hostnames[:3]:
|
|
359
|
+
idx_base += 1
|
|
360
|
+
findings.append(Finding(
|
|
361
|
+
id = f"EASM-SHD-{idx_base:03d}",
|
|
362
|
+
title = f"Shodan reporta hostname alternativo: {hostname}",
|
|
363
|
+
severity = "INFO",
|
|
364
|
+
description = (
|
|
365
|
+
f"Shodan indexa el hostname '{hostname}' para la IP {ip}. "
|
|
366
|
+
"Puede indicar servicios adicionales o dominios compartiendo esta IP."
|
|
367
|
+
),
|
|
368
|
+
evidence = f"IP: {ip}\nHostname Shodan: {hostname}",
|
|
369
|
+
affected = ip,
|
|
370
|
+
remediation = "Verificar que el hostname corresponde a infraestructura autorizada.",
|
|
371
|
+
tags = ["shodan", "easm", "hostname"],
|
|
372
|
+
))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ---------------------------------------------------------------------------
|
|
376
|
+
# Integración Censys (v1.4)
|
|
377
|
+
# ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
# URL base de la API Censys v2
|
|
380
|
+
_CENSYS_SEARCH_URL = "https://search.censys.io/api/v2/hosts/search"
|
|
381
|
+
|
|
382
|
+
# Puertos considerados sensibles para enriquecer hallazgos Censys
|
|
383
|
+
_CENSYS_PUERTOS_SENSIBLES = {21, 22, 23, 25, 80, 443, 445, 3389, 1433, 3306, 5432, 5900, 6379, 8080, 8443, 27017, 9200}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class CensysEASMEnricher:
|
|
387
|
+
"""
|
|
388
|
+
Enriquecimiento EASM con la API Censys v2.
|
|
389
|
+
|
|
390
|
+
Consulta el endpoint de búsqueda de hosts Censys usando la query
|
|
391
|
+
``parsed.names:<dominio>`` para detectar hosts indexados por Censys
|
|
392
|
+
que no fueron descubiertos por crt.sh/HackerTarget.
|
|
393
|
+
|
|
394
|
+
Hallazgos emitidos:
|
|
395
|
+
- CENSYS_HOST_EXPOSED: host encontrado por Censys pero no en el
|
|
396
|
+
escaneo activo (superficie no visible para la organización).
|
|
397
|
+
- Puerto sensible abierto según Censys: MEDIUM.
|
|
398
|
+
|
|
399
|
+
Límite: 1 petición por segundo (rate limit de la API gratuita).
|
|
400
|
+
Resultados por página: 100 hosts (máximo permitido por la API).
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
_MAX_PAGINAS = 3 # máximo de páginas (300 hosts) por invocación
|
|
404
|
+
|
|
405
|
+
def __init__(self, censys_id: str, censys_secret: str) -> None:
|
|
406
|
+
self._id = censys_id
|
|
407
|
+
self._secret = censys_secret
|
|
408
|
+
|
|
409
|
+
def _auth_header(self) -> str:
|
|
410
|
+
"""Devuelve el valor de la cabecera Authorization (Basic Auth)."""
|
|
411
|
+
import base64 as _b64
|
|
412
|
+
creds = f"{self._id}:{self._secret}".encode()
|
|
413
|
+
return "Basic " + _b64.b64encode(creds).decode()
|
|
414
|
+
|
|
415
|
+
def enriquecer(
|
|
416
|
+
self,
|
|
417
|
+
dominio: str,
|
|
418
|
+
ips_conocidas: set,
|
|
419
|
+
findings: list,
|
|
420
|
+
) -> None:
|
|
421
|
+
"""
|
|
422
|
+
Consulta Censys por el dominio y añade hallazgos a la lista recibida.
|
|
423
|
+
|
|
424
|
+
Parámetros
|
|
425
|
+
----------
|
|
426
|
+
dominio : str — Dominio objetivo
|
|
427
|
+
ips_conocidas : set[str] — IPs ya descubiertas por el escaneo
|
|
428
|
+
findings : list — Lista de Finding a la que se añaden hallazgos
|
|
429
|
+
"""
|
|
430
|
+
import urllib.request as _ureq
|
|
431
|
+
import urllib.error as _uerr
|
|
432
|
+
import json as _json
|
|
433
|
+
import time as _time
|
|
434
|
+
|
|
435
|
+
auth = self._auth_header()
|
|
436
|
+
idx_base = max(
|
|
437
|
+
(int(f.id.split("-")[-1]) for f in findings if "-" in f.id),
|
|
438
|
+
default=0,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
cursor: str | None = None
|
|
442
|
+
pagina = 0
|
|
443
|
+
hosts_censys: list[dict] = []
|
|
444
|
+
|
|
445
|
+
while pagina < self._MAX_PAGINAS:
|
|
446
|
+
# Construir URL con paginación
|
|
447
|
+
params = f"q=parsed.names%3A{_ureq.quote(dominio)}&per_page=100"
|
|
448
|
+
if cursor:
|
|
449
|
+
params += f"&cursor={_ureq.quote(cursor)}"
|
|
450
|
+
url = f"{_CENSYS_SEARCH_URL}?{params}"
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
req = _ureq.Request(
|
|
454
|
+
url,
|
|
455
|
+
headers={
|
|
456
|
+
"Authorization": auth,
|
|
457
|
+
"User-Agent": f"vamp-easm/{VERSION}",
|
|
458
|
+
"Accept": "application/json",
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
with _ureq.urlopen(req, timeout=20) as resp:
|
|
462
|
+
if resp.status != 200:
|
|
463
|
+
break
|
|
464
|
+
data = _json.loads(resp.read())
|
|
465
|
+
except _uerr.HTTPError as e:
|
|
466
|
+
if e.code in (401, 403):
|
|
467
|
+
findings.append(Finding(
|
|
468
|
+
id = f"EASM-CNS-{idx_base + 1:03d}",
|
|
469
|
+
title = "Credenciales Censys inválidas o sin permisos",
|
|
470
|
+
severity = "INFO",
|
|
471
|
+
description = (
|
|
472
|
+
f"La API Censys devolvió {e.code}. "
|
|
473
|
+
"Verificar --censys-id y --censys-secret."
|
|
474
|
+
),
|
|
475
|
+
evidence = f"URL: {_CENSYS_SEARCH_URL}",
|
|
476
|
+
affected = dominio,
|
|
477
|
+
remediation = "Comprobar credenciales en https://search.censys.io/account",
|
|
478
|
+
tags = ["censys", "easm"],
|
|
479
|
+
))
|
|
480
|
+
break
|
|
481
|
+
except Exception:
|
|
482
|
+
break
|
|
483
|
+
|
|
484
|
+
lote = data.get("result", {}).get("hits", [])
|
|
485
|
+
hosts_censys.extend(lote)
|
|
486
|
+
cursor = data.get("result", {}).get("links", {}).get("next")
|
|
487
|
+
pagina += 1
|
|
488
|
+
|
|
489
|
+
if not cursor or not lote:
|
|
490
|
+
break
|
|
491
|
+
|
|
492
|
+
# Respetar el límite de 1 req/seg de la API gratuita
|
|
493
|
+
_time.sleep(1.0)
|
|
494
|
+
|
|
495
|
+
# Procesar cada host devuelto por Censys
|
|
496
|
+
for host in hosts_censys:
|
|
497
|
+
ip = host.get("ip", "")
|
|
498
|
+
nombres = host.get("names", []) or []
|
|
499
|
+
servicios = host.get("services", []) or []
|
|
500
|
+
puertos = [s.get("port") for s in servicios if s.get("port")]
|
|
501
|
+
|
|
502
|
+
# Si la IP no fue encontrada por crt.sh/HackerTarget → hallazgo
|
|
503
|
+
if ip and ip not in ips_conocidas:
|
|
504
|
+
idx_base += 1
|
|
505
|
+
nombres_str = ", ".join(nombres[:5]) or "—"
|
|
506
|
+
puertos_str = ", ".join(str(p) for p in puertos[:10]) or "—"
|
|
507
|
+
findings.append(Finding(
|
|
508
|
+
id = f"EASM-CNS-{idx_base:03d}",
|
|
509
|
+
title = f"Host expuesto detectado por Censys (no en escaneo activo) — {ip}",
|
|
510
|
+
severity = "HIGH",
|
|
511
|
+
description = (
|
|
512
|
+
f"Censys indexa el host {ip} asociado al dominio '{dominio}' "
|
|
513
|
+
"pero no fue descubierto por crt.sh ni HackerTarget. Puede "
|
|
514
|
+
"tratarse de infraestructura shadow, activos olvidados o "
|
|
515
|
+
"exposición no intencionada."
|
|
516
|
+
),
|
|
517
|
+
evidence = (
|
|
518
|
+
f"IP: {ip}\n"
|
|
519
|
+
f"Nombres DNS (Censys): {nombres_str}\n"
|
|
520
|
+
f"Puertos indexados: {puertos_str}\n"
|
|
521
|
+
f"Dominio consultado: {dominio}\n"
|
|
522
|
+
"Fuente: Censys API v2 (datos históricos — verificar estado actual)"
|
|
523
|
+
),
|
|
524
|
+
affected = ip,
|
|
525
|
+
remediation = (
|
|
526
|
+
"Verificar si el activo pertenece a la organización. "
|
|
527
|
+
"Si es legítimo, incluirlo en el inventario de superficie de ataque. "
|
|
528
|
+
"Si no, investigar posible shadow IT o activo olvidado."
|
|
529
|
+
),
|
|
530
|
+
tags = ["censys", "easm", "CENSYS_HOST_EXPOSED"],
|
|
531
|
+
))
|
|
532
|
+
|
|
533
|
+
# Puertos sensibles abiertos según Censys (para IPs conocidas y nuevas)
|
|
534
|
+
sensibles = [p for p in puertos if p in _CENSYS_PUERTOS_SENSIBLES]
|
|
535
|
+
for puerto in sensibles:
|
|
536
|
+
# Evitar duplicados con hallazgos Shodan
|
|
537
|
+
ya_reportado = any(
|
|
538
|
+
f"{ip}:{puerto}" in (getattr(f, "affected", "") or "")
|
|
539
|
+
for f in findings
|
|
540
|
+
)
|
|
541
|
+
if ya_reportado:
|
|
542
|
+
continue
|
|
543
|
+
idx_base += 1
|
|
544
|
+
findings.append(Finding(
|
|
545
|
+
id = f"EASM-CNS-{idx_base:03d}",
|
|
546
|
+
title = f"Puerto sensible {puerto} abierto según Censys — {ip}",
|
|
547
|
+
severity = "MEDIUM",
|
|
548
|
+
description = (
|
|
549
|
+
f"Censys ha indexado el puerto {puerto} como abierto en "
|
|
550
|
+
f"la IP {ip}. Este puerto corresponde a un servicio de "
|
|
551
|
+
"alto riesgo si está expuesto sin control de acceso."
|
|
552
|
+
),
|
|
553
|
+
evidence = (
|
|
554
|
+
f"IP: {ip} Puerto: {puerto}\n"
|
|
555
|
+
f"Servicios Censys: {', '.join(s.get('service_name','?') for s in servicios if s.get('port') == puerto) or '—'}\n"
|
|
556
|
+
"Fuente: Censys API v2"
|
|
557
|
+
),
|
|
558
|
+
affected = f"{ip}:{puerto}",
|
|
559
|
+
remediation = (
|
|
560
|
+
f"Comprobar si el puerto {puerto} debe estar expuesto a internet. "
|
|
561
|
+
"Si no es necesario, restringir acceso con firewall."
|
|
562
|
+
),
|
|
563
|
+
tags = ["censys", "easm", "port"],
|
|
564
|
+
))
|
|
565
|
+
|
|
566
|
+
|
|
199
567
|
# ---------------------------------------------------------------------------
|
|
200
568
|
# Comando: scan
|
|
201
569
|
# ---------------------------------------------------------------------------
|
|
@@ -291,6 +659,24 @@ def cmd_scan(args: argparse.Namespace) -> int:
|
|
|
291
659
|
certs_ok = [c for c in resultado.certs if not c.error]
|
|
292
660
|
console.print(f" {len(certs_ok)} certificados inspeccionados.")
|
|
293
661
|
|
|
662
|
+
# ── Nota Shodan al inicio (si no se proporcionó clave) ───────────────────
|
|
663
|
+
shodan_key_easm = (
|
|
664
|
+
getattr(args, "shodan_key", None)
|
|
665
|
+
or __import__("os").environ.get("SHODAN_API_KEY", "")
|
|
666
|
+
)
|
|
667
|
+
if not shodan_key_easm:
|
|
668
|
+
console.print(
|
|
669
|
+
"[dim] ℹ Shodan no activo — usa --shodan-key para enriquecer con datos Shodan[/dim]"
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
# Leer claves Censys (opcional, v1.4)
|
|
673
|
+
censys_id = getattr(args, "censys_id", None) or __import__("os").environ.get("CENSYS_API_ID", "")
|
|
674
|
+
censys_secret = getattr(args, "censys_secret", None) or __import__("os").environ.get("CENSYS_API_SECRET", "")
|
|
675
|
+
if not (censys_id and censys_secret):
|
|
676
|
+
console.print(
|
|
677
|
+
"[dim] ℹ Censys no activo — usa --censys-id y --censys-secret para enriquecer con datos Censys[/dim]"
|
|
678
|
+
)
|
|
679
|
+
|
|
294
680
|
# ── Motor de diffs ────────────────────────────────────────────────────
|
|
295
681
|
console.print("\n[cyan]►[/cyan] [bold]Calculando diffs respecto al escaneo anterior…[/bold]")
|
|
296
682
|
scan_anterior = storage.ultimo_scan(target)
|
|
@@ -317,6 +703,42 @@ def cmd_scan(args: argparse.Namespace) -> int:
|
|
|
317
703
|
# Cerrar escaneo en SQLite
|
|
318
704
|
storage.cerrar_scan(scan_id, assets_found=assets_total, diffs_found=len(diffs))
|
|
319
705
|
|
|
706
|
+
# ── Enriquecimiento Shodan (opcional) ────────────────────────────────────
|
|
707
|
+
findings_shodan: List[Finding] = []
|
|
708
|
+
if shodan_key_easm:
|
|
709
|
+
console.print("\n[cyan]►[/cyan] [bold]Enriquecimiento Shodan (por IP)…[/bold]")
|
|
710
|
+
ips_descubiertas = []
|
|
711
|
+
for sub in resultado.subdominios:
|
|
712
|
+
if sub.ips:
|
|
713
|
+
ips_descubiertas.extend(sub.ips)
|
|
714
|
+
if ips_descubiertas:
|
|
715
|
+
enricher = ShodanEASMEnricher(shodan_key_easm)
|
|
716
|
+
enricher._enrich_with_shodan(ips_descubiertas, shodan_key_easm, findings_shodan)
|
|
717
|
+
console.print(
|
|
718
|
+
f" {len([f for f in findings_shodan if f.severity in ('HIGH','MEDIUM')])} "
|
|
719
|
+
f"hallazgos Shodan (MEDIUM+HIGH) · {len(findings_shodan)} totales"
|
|
720
|
+
)
|
|
721
|
+
else:
|
|
722
|
+
console.print(" [dim]Sin IPs resueltas para consultar Shodan.[/dim]")
|
|
723
|
+
|
|
724
|
+
# ── Enriquecimiento Censys (v1.4, opcional) ───────────────────────────
|
|
725
|
+
findings_censys: List[Finding] = []
|
|
726
|
+
if censys_id and censys_secret:
|
|
727
|
+
console.print("\n[cyan]►[/cyan] [bold]Enriquecimiento Censys (por dominio)…[/bold]")
|
|
728
|
+
# Recopilar IPs ya conocidas del escaneo activo
|
|
729
|
+
ips_conocidas: set = set()
|
|
730
|
+
for sub in resultado.subdominios:
|
|
731
|
+
if sub.ips:
|
|
732
|
+
ips_conocidas.update(sub.ips)
|
|
733
|
+
|
|
734
|
+
censys_enricher = CensysEASMEnricher(censys_id, censys_secret)
|
|
735
|
+
censys_enricher.enriquecer(target, ips_conocidas, findings_censys)
|
|
736
|
+
nuevos = len([f for f in findings_censys if "CENSYS_HOST_EXPOSED" in (getattr(f, "tags", None) or [])])
|
|
737
|
+
console.print(
|
|
738
|
+
f" {nuevos} host(s) nuevos (CENSYS_HOST_EXPOSED) · "
|
|
739
|
+
f"{len(findings_censys)} hallazgos totales Censys"
|
|
740
|
+
)
|
|
741
|
+
|
|
320
742
|
# ── Mostrar resultados ────────────────────────────────────────────────
|
|
321
743
|
if diffs:
|
|
322
744
|
console.print(_tabla_diffs(diffs))
|
|
@@ -332,6 +754,10 @@ def cmd_scan(args: argparse.Namespace) -> int:
|
|
|
332
754
|
|
|
333
755
|
# ── Exportar informes si se pidió ─────────────────────────────────────
|
|
334
756
|
findings = [d.finding for d in diffs if d.finding is not None]
|
|
757
|
+
# Incluir hallazgos Shodan en el informe si los hay
|
|
758
|
+
findings.extend(findings_shodan)
|
|
759
|
+
# Incluir hallazgos Censys en el informe si los hay (v1.4)
|
|
760
|
+
findings.extend(findings_censys)
|
|
335
761
|
if findings:
|
|
336
762
|
meta = meta_from_args(args, tool=TOOL, version=VERSION)
|
|
337
763
|
meta.scope = target
|
|
@@ -750,6 +1176,21 @@ def construir_parser() -> argparse.ArgumentParser:
|
|
|
750
1176
|
"--alert-webhook", metavar="URL", dest="alert_webhook",
|
|
751
1177
|
help="URL del webhook para alertas CRITICAL/HIGH (alternativa: env EASM_ALERT_WEBHOOK)",
|
|
752
1178
|
)
|
|
1179
|
+
p_scan.add_argument(
|
|
1180
|
+
"--shodan-key", metavar="API_KEY", dest="shodan_key",
|
|
1181
|
+
help="Clave API Shodan (o var SHODAN_API_KEY) — enriquece IPs descubiertas "
|
|
1182
|
+
"con puertos, servicios y vulnerabilidades indexadas por Shodan",
|
|
1183
|
+
)
|
|
1184
|
+
p_scan.add_argument(
|
|
1185
|
+
"--censys-id", metavar="ID", dest="censys_id",
|
|
1186
|
+
help="ID de la API Censys v2 (o var CENSYS_API_ID) — enriquece el escaneo "
|
|
1187
|
+
"con hosts detectados por Censys no visibles en crt.sh/HackerTarget (v1.4)",
|
|
1188
|
+
)
|
|
1189
|
+
p_scan.add_argument(
|
|
1190
|
+
"--censys-secret", metavar="SECRET", dest="censys_secret",
|
|
1191
|
+
help="Secret de la API Censys v2 (o var CENSYS_API_SECRET) — requerido junto "
|
|
1192
|
+
"con --censys-id para activar el enriquecimiento Censys (v1.4)",
|
|
1193
|
+
)
|
|
753
1194
|
add_report_args(p_scan)
|
|
754
1195
|
|
|
755
1196
|
# ── history ───────────────────────────────────────────────────────────
|
|
@@ -794,6 +1235,11 @@ def construir_parser() -> argparse.ArgumentParser:
|
|
|
794
1235
|
"--nmap", action="store_true",
|
|
795
1236
|
help="Usar nmap como backend de escaneo de puertos",
|
|
796
1237
|
)
|
|
1238
|
+
p_watch.add_argument(
|
|
1239
|
+
"--shodan-key", metavar="API_KEY", dest="shodan_key",
|
|
1240
|
+
help="Clave API Shodan (o var SHODAN_API_KEY) — enriquece IPs descubiertas "
|
|
1241
|
+
"con datos Shodan en cada ciclo de watch",
|
|
1242
|
+
)
|
|
797
1243
|
|
|
798
1244
|
# ── diff ──────────────────────────────────────────────────────────────
|
|
799
1245
|
p_diff = subparsers.add_parser(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|