vamp-easm 1.1__tar.gz → 1.3__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.3/LICENSE +21 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/PKG-INFO +3 -1
- {vamp_easm-1.1 → vamp_easm-1.3}/pyproject.toml +1 -1
- {vamp_easm-1.1 → vamp_easm-1.3}/vamp_easm.egg-info/PKG-INFO +3 -1
- {vamp_easm-1.1 → vamp_easm-1.3}/vamp_easm.egg-info/SOURCES.txt +1 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/vamp_easm.py +510 -6
- {vamp_easm-1.1 → vamp_easm-1.3}/README.md +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/easm/__init__.py +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/easm/alerter.py +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/easm/differ.py +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/easm/scanner.py +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/easm/storage.py +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/setup.cfg +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/vamp_easm.egg-info/dependency_links.txt +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/vamp_easm.egg-info/entry_points.txt +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/vamp_easm.egg-info/requires.txt +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/vamp_easm.egg-info/top_level.txt +0 -0
- {vamp_easm-1.1 → vamp_easm-1.3}/vampsec_report.py +0 -0
vamp_easm-1.3/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VampSecure Studios
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vamp-easm
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3
|
|
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
|
|
@@ -16,7 +16,9 @@ Classifier: Programming Language :: Python :: 3
|
|
|
16
16
|
Classifier: Topic :: Security
|
|
17
17
|
Requires-Python: >=3.9
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
19
20
|
Requires-Dist: rich>=13.7.0
|
|
21
|
+
Dynamic: license-file
|
|
20
22
|
|
|
21
23
|
# vamp-easm
|
|
22
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: vamp-easm
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3
|
|
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
|
|
@@ -16,7 +16,9 @@ Classifier: Programming Language :: Python :: 3
|
|
|
16
16
|
Classifier: Topic :: Security
|
|
17
17
|
Requires-Python: >=3.9
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
19
20
|
Requires-Dist: rich>=13.7.0
|
|
21
|
+
Dynamic: license-file
|
|
20
22
|
|
|
21
23
|
# vamp-easm
|
|
22
24
|
|
|
@@ -57,6 +57,7 @@ from __future__ import annotations
|
|
|
57
57
|
import argparse
|
|
58
58
|
import json
|
|
59
59
|
import sys
|
|
60
|
+
import time
|
|
60
61
|
from datetime import datetime, timezone
|
|
61
62
|
from typing import List, Optional
|
|
62
63
|
|
|
@@ -75,7 +76,7 @@ from vampsec_report import (
|
|
|
75
76
|
# Constantes
|
|
76
77
|
# ---------------------------------------------------------------------------
|
|
77
78
|
|
|
78
|
-
VERSION = "1.
|
|
79
|
+
VERSION = "1.3"
|
|
79
80
|
TOOL = "vamp-easm"
|
|
80
81
|
BRAND = "VampSecure Labs — EASM Continuo"
|
|
81
82
|
|
|
@@ -86,7 +87,7 @@ BANNER = (
|
|
|
86
87
|
" \\ V / _ \\| |\\/| | _/\\__ \\ _| (__| |_| | / _|| |__ / _ \\| _ \\__ \\\n"
|
|
87
88
|
" \\_/_/ \\_\\_| |_|_| |___/___\\___|\\___/|_|_\\___|____/_/ \\_\\___/___/\n"
|
|
88
89
|
' by Antonio Hernandez "Belky" — VampSecure Studios\n'
|
|
89
|
-
" vamp-easm v1.
|
|
90
|
+
" vamp-easm v1.3 · External Attack Surface Management\n"
|
|
90
91
|
" ────────────────────────────────────────────────────────────────────────\n"
|
|
91
92
|
" USO EXCLUSIVO EN AUDITORÍAS AUTORIZADAS · El uso no autorizado es ilegal\n"
|
|
92
93
|
)
|
|
@@ -195,6 +196,182 @@ def _tabla_historial(rows) -> Table:
|
|
|
195
196
|
return tabla
|
|
196
197
|
|
|
197
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
|
+
|
|
198
375
|
# ---------------------------------------------------------------------------
|
|
199
376
|
# Comando: scan
|
|
200
377
|
# ---------------------------------------------------------------------------
|
|
@@ -290,6 +467,16 @@ def cmd_scan(args: argparse.Namespace) -> int:
|
|
|
290
467
|
certs_ok = [c for c in resultado.certs if not c.error]
|
|
291
468
|
console.print(f" {len(certs_ok)} certificados inspeccionados.")
|
|
292
469
|
|
|
470
|
+
# ── Nota Shodan al inicio (si no se proporcionó clave) ───────────────────
|
|
471
|
+
shodan_key_easm = (
|
|
472
|
+
getattr(args, "shodan_key", None)
|
|
473
|
+
or __import__("os").environ.get("SHODAN_API_KEY", "")
|
|
474
|
+
)
|
|
475
|
+
if not shodan_key_easm:
|
|
476
|
+
console.print(
|
|
477
|
+
"[dim] ℹ Shodan no activo — usa --shodan-key para enriquecer con datos Shodan[/dim]"
|
|
478
|
+
)
|
|
479
|
+
|
|
293
480
|
# ── Motor de diffs ────────────────────────────────────────────────────
|
|
294
481
|
console.print("\n[cyan]►[/cyan] [bold]Calculando diffs respecto al escaneo anterior…[/bold]")
|
|
295
482
|
scan_anterior = storage.ultimo_scan(target)
|
|
@@ -316,6 +503,24 @@ def cmd_scan(args: argparse.Namespace) -> int:
|
|
|
316
503
|
# Cerrar escaneo en SQLite
|
|
317
504
|
storage.cerrar_scan(scan_id, assets_found=assets_total, diffs_found=len(diffs))
|
|
318
505
|
|
|
506
|
+
# ── Enriquecimiento Shodan (opcional) ────────────────────────────────────
|
|
507
|
+
findings_shodan: List[Finding] = []
|
|
508
|
+
if shodan_key_easm:
|
|
509
|
+
console.print("\n[cyan]►[/cyan] [bold]Enriquecimiento Shodan (por IP)…[/bold]")
|
|
510
|
+
ips_descubiertas = []
|
|
511
|
+
for sub in resultado.subdominios:
|
|
512
|
+
if sub.ips:
|
|
513
|
+
ips_descubiertas.extend(sub.ips)
|
|
514
|
+
if ips_descubiertas:
|
|
515
|
+
enricher = ShodanEASMEnricher(shodan_key_easm)
|
|
516
|
+
enricher._enrich_with_shodan(ips_descubiertas, shodan_key_easm, findings_shodan)
|
|
517
|
+
console.print(
|
|
518
|
+
f" {len([f for f in findings_shodan if f.severity in ('HIGH','MEDIUM')])} "
|
|
519
|
+
f"hallazgos Shodan (MEDIUM+HIGH) · {len(findings_shodan)} totales"
|
|
520
|
+
)
|
|
521
|
+
else:
|
|
522
|
+
console.print(" [dim]Sin IPs resueltas para consultar Shodan.[/dim]")
|
|
523
|
+
|
|
319
524
|
# ── Mostrar resultados ────────────────────────────────────────────────
|
|
320
525
|
if diffs:
|
|
321
526
|
console.print(_tabla_diffs(diffs))
|
|
@@ -331,6 +536,8 @@ def cmd_scan(args: argparse.Namespace) -> int:
|
|
|
331
536
|
|
|
332
537
|
# ── Exportar informes si se pidió ─────────────────────────────────────
|
|
333
538
|
findings = [d.finding for d in diffs if d.finding is not None]
|
|
539
|
+
# Incluir hallazgos Shodan en el informe si los hay
|
|
540
|
+
findings.extend(findings_shodan)
|
|
334
541
|
if findings:
|
|
335
542
|
meta = meta_from_args(args, tool=TOOL, version=VERSION)
|
|
336
543
|
meta.scope = target
|
|
@@ -454,6 +661,243 @@ def cmd_export(args: argparse.Namespace) -> int:
|
|
|
454
661
|
return 0
|
|
455
662
|
|
|
456
663
|
|
|
664
|
+
# ---------------------------------------------------------------------------
|
|
665
|
+
# Comando: watch
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
def _escanear_target(
|
|
669
|
+
target: str,
|
|
670
|
+
puertos: List[int],
|
|
671
|
+
usar_nmap: bool,
|
|
672
|
+
alert_webhook: Optional[str],
|
|
673
|
+
) -> List[differ.Diff]:
|
|
674
|
+
"""
|
|
675
|
+
Ejecuta un escaneo completo del target, persiste en SQLite y devuelve
|
|
676
|
+
los diffs calculados frente al escaneo inmediatamente anterior.
|
|
677
|
+
|
|
678
|
+
Función interna compartida por cmd_scan (indirectamente) y cmd_watch.
|
|
679
|
+
"""
|
|
680
|
+
storage.inicializar_db()
|
|
681
|
+
scan_id = storage.nuevo_scan(target)
|
|
682
|
+
|
|
683
|
+
resultado = scanner.ejecutar_escaneo(
|
|
684
|
+
target=target,
|
|
685
|
+
puertos=puertos,
|
|
686
|
+
usar_nmap=usar_nmap,
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
# Persistir subdominios
|
|
690
|
+
for sub in resultado.subdominios:
|
|
691
|
+
ip = sub.ips[0] if sub.ips else None
|
|
692
|
+
storage.upsert_asset(target=target, subdominio=sub.nombre,
|
|
693
|
+
scan_id=scan_id, ip=ip)
|
|
694
|
+
|
|
695
|
+
# Persistir puertos
|
|
696
|
+
for pa in resultado.puertos:
|
|
697
|
+
ip_pa = None
|
|
698
|
+
for sub in resultado.subdominios:
|
|
699
|
+
if sub.nombre == pa.host:
|
|
700
|
+
ip_pa = sub.ips[0] if sub.ips else None
|
|
701
|
+
break
|
|
702
|
+
storage.upsert_asset(target=target, subdominio=pa.host,
|
|
703
|
+
scan_id=scan_id, ip=ip_pa,
|
|
704
|
+
puerto=pa.puerto, protocolo=pa.protocolo,
|
|
705
|
+
servicio=pa.servicio)
|
|
706
|
+
|
|
707
|
+
# Persistir certificados
|
|
708
|
+
for cert in resultado.certs:
|
|
709
|
+
if cert.error:
|
|
710
|
+
continue
|
|
711
|
+
storage.insertar_cert(target=target, host=cert.host,
|
|
712
|
+
scan_id=scan_id, issued_to=cert.issued_to,
|
|
713
|
+
issuer=cert.issuer, not_after=cert.not_after,
|
|
714
|
+
sans=",".join(cert.sans) if cert.sans else None,
|
|
715
|
+
fingerprint=cert.fingerprint,
|
|
716
|
+
autofirmado=cert.autofirmado)
|
|
717
|
+
|
|
718
|
+
# Calcular diffs frente al escaneo anterior
|
|
719
|
+
historial = storage.historial_scans(target, limit=5)
|
|
720
|
+
scan_id_prev: Optional[str] = None
|
|
721
|
+
for row in historial:
|
|
722
|
+
if row["id"] != scan_id:
|
|
723
|
+
scan_id_prev = row["id"]
|
|
724
|
+
break
|
|
725
|
+
|
|
726
|
+
motor = differ.MotorDiff(target=target, scan_id=scan_id,
|
|
727
|
+
scan_id_prev=scan_id_prev, storage=storage)
|
|
728
|
+
diffs = motor.calcular()
|
|
729
|
+
|
|
730
|
+
assets_total = len(resultado.subdominios) + len(resultado.puertos)
|
|
731
|
+
storage.cerrar_scan(scan_id, assets_found=assets_total,
|
|
732
|
+
diffs_found=len(diffs))
|
|
733
|
+
|
|
734
|
+
# Gestionar alertas solo si hay cambios
|
|
735
|
+
if diffs:
|
|
736
|
+
webhook_url = alerter.resolver_webhook_url(alert_webhook)
|
|
737
|
+
alerter.gestionar_alertas(webhook_url, target, diffs, scan_id)
|
|
738
|
+
|
|
739
|
+
return diffs
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def cmd_watch(args: argparse.Namespace) -> int:
|
|
743
|
+
"""
|
|
744
|
+
Modo watch continuo: ejecuta escaneos periódicos del target, compara
|
|
745
|
+
con el resultado anterior almacenado en SQLite e imprime solo los
|
|
746
|
+
cambios detectados (nuevos puertos, subdominios, certs, etc.).
|
|
747
|
+
|
|
748
|
+
El webhook (--alert-webhook) solo se dispara cuando hay diffs.
|
|
749
|
+
El bucle continúa hasta interrupción manual (Ctrl+C).
|
|
750
|
+
|
|
751
|
+
Returns
|
|
752
|
+
-------
|
|
753
|
+
int : Siempre 0 al salir por interrupción.
|
|
754
|
+
"""
|
|
755
|
+
target = args.target
|
|
756
|
+
interval = getattr(args, "interval", 3600)
|
|
757
|
+
webhook = getattr(args, "alert_webhook", None)
|
|
758
|
+
|
|
759
|
+
# Determinar lista de puertos
|
|
760
|
+
if getattr(args, "ports", None) and args.ports != "top100":
|
|
761
|
+
try:
|
|
762
|
+
puertos = [int(p.strip()) for p in args.ports.split(",") if p.strip()]
|
|
763
|
+
except ValueError:
|
|
764
|
+
console.print("[red]Error:[/red] --ports debe ser 'top100' o lista CSV (ej: 22,80,443)")
|
|
765
|
+
return 1
|
|
766
|
+
else:
|
|
767
|
+
puertos = scanner.TOP_100_PORTS
|
|
768
|
+
|
|
769
|
+
console.print(Panel(
|
|
770
|
+
f"[bold cyan]vamp-easm v{VERSION}[/bold cyan] · {BRAND}\n"
|
|
771
|
+
f"Modo: [bold]WATCH[/bold] · Target: [bold]{target}[/bold] · "
|
|
772
|
+
f"Intervalo: {interval}s · {_ts_ahora()}",
|
|
773
|
+
border_style="yellow",
|
|
774
|
+
))
|
|
775
|
+
|
|
776
|
+
iteracion = 0
|
|
777
|
+
try:
|
|
778
|
+
while True:
|
|
779
|
+
iteracion += 1
|
|
780
|
+
ts_scan = _ts_ahora()
|
|
781
|
+
console.print(
|
|
782
|
+
f"\n[yellow]►[/yellow] [bold]Escaneo #{iteracion}[/bold] — {ts_scan}"
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
diffs = _escanear_target(
|
|
786
|
+
target=target,
|
|
787
|
+
puertos=puertos,
|
|
788
|
+
usar_nmap=getattr(args, "nmap", False),
|
|
789
|
+
alert_webhook=webhook,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
if diffs:
|
|
793
|
+
console.print(_tabla_diffs(diffs))
|
|
794
|
+
else:
|
|
795
|
+
console.print(
|
|
796
|
+
"[green]✓ Sin cambios respecto al escaneo anterior.[/green]"
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
console.print(
|
|
800
|
+
f"[dim]Próximo escaneo en {interval}s "
|
|
801
|
+
f"(Ctrl+C para detener)[/dim]"
|
|
802
|
+
)
|
|
803
|
+
time.sleep(interval)
|
|
804
|
+
|
|
805
|
+
except KeyboardInterrupt:
|
|
806
|
+
console.print("\n[yellow]Watch detenido por el usuario.[/yellow]")
|
|
807
|
+
|
|
808
|
+
return 0
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# ---------------------------------------------------------------------------
|
|
812
|
+
# Comando: diff
|
|
813
|
+
# ---------------------------------------------------------------------------
|
|
814
|
+
|
|
815
|
+
def cmd_diff(args: argparse.Namespace) -> int:
|
|
816
|
+
"""
|
|
817
|
+
Muestra todos los cambios detectados entre dos snapshots almacenados.
|
|
818
|
+
|
|
819
|
+
--from y --to aceptan fechas en formato YYYY-MM-DD o YYYY-MM-DDTHH:MM.
|
|
820
|
+
Se busca el escaneo cuyo ts_start sea el más cercano (posterior) a cada
|
|
821
|
+
fecha indicada.
|
|
822
|
+
|
|
823
|
+
Returns
|
|
824
|
+
-------
|
|
825
|
+
int : 0 sin diffs, 1 con diffs HIGH, 2 con diffs CRITICAL.
|
|
826
|
+
"""
|
|
827
|
+
storage.inicializar_db()
|
|
828
|
+
|
|
829
|
+
target = args.target
|
|
830
|
+
fecha_from = getattr(args, "from_date", None) or ""
|
|
831
|
+
fecha_to = getattr(args, "to_date", None) or ""
|
|
832
|
+
|
|
833
|
+
# Obtener historial completo del target (máx 1000 para cubrir rango amplio)
|
|
834
|
+
historial = storage.historial_scans(target, limit=1000)
|
|
835
|
+
if not historial:
|
|
836
|
+
console.print(f"[red]Error:[/red] Sin historial para {target}")
|
|
837
|
+
return 1
|
|
838
|
+
|
|
839
|
+
def _scan_id_por_fecha(fecha_prefix: str, historial_rows) -> Optional[str]:
|
|
840
|
+
"""
|
|
841
|
+
Devuelve el scan_id cuyo ts_start comienza con fecha_prefix.
|
|
842
|
+
Si no hay match exacto, devuelve el más antiguo posterior a ese prefijo.
|
|
843
|
+
"""
|
|
844
|
+
if not fecha_prefix:
|
|
845
|
+
return None
|
|
846
|
+
for row in reversed(historial_rows): # orden cronológico ascendente
|
|
847
|
+
if row["ts_start"] and row["ts_start"] >= fecha_prefix:
|
|
848
|
+
return row["id"]
|
|
849
|
+
return None
|
|
850
|
+
|
|
851
|
+
scan_id_from = _scan_id_por_fecha(fecha_from, historial)
|
|
852
|
+
scan_id_to = _scan_id_por_fecha(fecha_to, historial)
|
|
853
|
+
|
|
854
|
+
if not scan_id_from:
|
|
855
|
+
console.print(
|
|
856
|
+
f"[red]Error:[/red] No se encontró escaneo en o posterior a "
|
|
857
|
+
f"[bold]{fecha_from}[/bold] para {target}"
|
|
858
|
+
)
|
|
859
|
+
return 1
|
|
860
|
+
|
|
861
|
+
if not scan_id_to:
|
|
862
|
+
console.print(
|
|
863
|
+
f"[red]Error:[/red] No se encontró escaneo en o posterior a "
|
|
864
|
+
f"[bold]{fecha_to}[/bold] para {target}"
|
|
865
|
+
)
|
|
866
|
+
return 1
|
|
867
|
+
|
|
868
|
+
if scan_id_from == scan_id_to:
|
|
869
|
+
console.print("[yellow]⚠ Los dos snapshots apuntan al mismo escaneo — sin diff.[/yellow]")
|
|
870
|
+
return 0
|
|
871
|
+
|
|
872
|
+
console.print(Panel(
|
|
873
|
+
f"[bold cyan]vamp-easm v{VERSION}[/bold cyan] · {BRAND}\n"
|
|
874
|
+
f"Target: [bold]{target}[/bold] · Diff acumulado\n"
|
|
875
|
+
f"Desde: [dim]{fecha_from}[/dim] (scan {scan_id_from[:8]})\n"
|
|
876
|
+
f"Hasta: [dim]{fecha_to}[/dim] (scan {scan_id_to[:8]})",
|
|
877
|
+
border_style="dim",
|
|
878
|
+
))
|
|
879
|
+
|
|
880
|
+
motor = differ.MotorDiff(
|
|
881
|
+
target=target,
|
|
882
|
+
scan_id=scan_id_to,
|
|
883
|
+
scan_id_prev=scan_id_from,
|
|
884
|
+
storage=storage,
|
|
885
|
+
)
|
|
886
|
+
diffs = motor.calcular()
|
|
887
|
+
|
|
888
|
+
if diffs:
|
|
889
|
+
console.print(_tabla_diffs(diffs))
|
|
890
|
+
else:
|
|
891
|
+
console.print("[green]✓ Sin cambios entre los dos snapshots indicados.[/green]")
|
|
892
|
+
|
|
893
|
+
sevs = {d.severidad for d in diffs}
|
|
894
|
+
if "CRITICAL" in sevs:
|
|
895
|
+
return 2
|
|
896
|
+
if "HIGH" in sevs:
|
|
897
|
+
return 1
|
|
898
|
+
return 0
|
|
899
|
+
|
|
900
|
+
|
|
457
901
|
# ---------------------------------------------------------------------------
|
|
458
902
|
# CLI — definición de argumentos
|
|
459
903
|
# ---------------------------------------------------------------------------
|
|
@@ -469,11 +913,13 @@ def construir_parser() -> argparse.ArgumentParser:
|
|
|
469
913
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
470
914
|
epilog=(
|
|
471
915
|
"Ejemplos:\n"
|
|
472
|
-
" python vamp_easm.py scan
|
|
473
|
-
" python vamp_easm.py scan
|
|
916
|
+
" python vamp_easm.py scan --target ejemplo.com\n"
|
|
917
|
+
" python vamp_easm.py scan --target ejemplo.com --ports 22,80,443 --html\n"
|
|
474
918
|
" python vamp_easm.py history --target ejemplo.com --limit 5\n"
|
|
475
|
-
" python vamp_easm.py assets
|
|
476
|
-
" python vamp_easm.py export
|
|
919
|
+
" python vamp_easm.py assets --target ejemplo.com\n"
|
|
920
|
+
" python vamp_easm.py export --target ejemplo.com --json\n"
|
|
921
|
+
" python vamp_easm.py watch --target ejemplo.com --interval 1800\n"
|
|
922
|
+
" python vamp_easm.py diff --target ejemplo.com --from 2026-09-01 --to 2026-09-17\n"
|
|
477
923
|
),
|
|
478
924
|
)
|
|
479
925
|
parser.add_argument(
|
|
@@ -483,6 +929,7 @@ def construir_parser() -> argparse.ArgumentParser:
|
|
|
483
929
|
|
|
484
930
|
subparsers = parser.add_subparsers(dest="comando", help="Subcomando a ejecutar")
|
|
485
931
|
|
|
932
|
+
|
|
486
933
|
# ── scan ─────────────────────────────────────────────────────────────
|
|
487
934
|
p_scan = subparsers.add_parser("scan", help="Escanear un target y calcular diffs")
|
|
488
935
|
p_scan.add_argument(
|
|
@@ -509,6 +956,11 @@ def construir_parser() -> argparse.ArgumentParser:
|
|
|
509
956
|
"--alert-webhook", metavar="URL", dest="alert_webhook",
|
|
510
957
|
help="URL del webhook para alertas CRITICAL/HIGH (alternativa: env EASM_ALERT_WEBHOOK)",
|
|
511
958
|
)
|
|
959
|
+
p_scan.add_argument(
|
|
960
|
+
"--shodan-key", metavar="API_KEY", dest="shodan_key",
|
|
961
|
+
help="Clave API Shodan (o var SHODAN_API_KEY) — enriquece IPs descubiertas "
|
|
962
|
+
"con puertos, servicios y vulnerabilidades indexadas por Shodan",
|
|
963
|
+
)
|
|
512
964
|
add_report_args(p_scan)
|
|
513
965
|
|
|
514
966
|
# ── history ───────────────────────────────────────────────────────────
|
|
@@ -527,6 +979,56 @@ def construir_parser() -> argparse.ArgumentParser:
|
|
|
527
979
|
p_export.add_argument("--html", action="store_true", help="Exportar como HTML")
|
|
528
980
|
add_report_args(p_export)
|
|
529
981
|
|
|
982
|
+
# ── watch ─────────────────────────────────────────────────────────────
|
|
983
|
+
p_watch = subparsers.add_parser(
|
|
984
|
+
"watch",
|
|
985
|
+
help="Modo watch continuo: escanea periódicamente y notifica solo cambios",
|
|
986
|
+
)
|
|
987
|
+
p_watch.add_argument(
|
|
988
|
+
"--target", required=True, metavar="DOMINIO",
|
|
989
|
+
help="Dominio objetivo a monitorizar",
|
|
990
|
+
)
|
|
991
|
+
p_watch.add_argument(
|
|
992
|
+
"--interval", type=int, default=3600, metavar="SEGUNDOS",
|
|
993
|
+
help="Segundos entre escaneos (default: 3600)",
|
|
994
|
+
)
|
|
995
|
+
p_watch.add_argument(
|
|
996
|
+
"--alert-webhook", metavar="URL", dest="alert_webhook",
|
|
997
|
+
help="URL del webhook para alertas cuando se detecten cambios "
|
|
998
|
+
"(alternativa: env EASM_ALERT_WEBHOOK)",
|
|
999
|
+
)
|
|
1000
|
+
p_watch.add_argument(
|
|
1001
|
+
"--ports", default="top100", metavar="PORTS",
|
|
1002
|
+
help="Puertos a escanear: 'top100' (default) o lista CSV (ej: 22,80,443)",
|
|
1003
|
+
)
|
|
1004
|
+
p_watch.add_argument(
|
|
1005
|
+
"--nmap", action="store_true",
|
|
1006
|
+
help="Usar nmap como backend de escaneo de puertos",
|
|
1007
|
+
)
|
|
1008
|
+
p_watch.add_argument(
|
|
1009
|
+
"--shodan-key", metavar="API_KEY", dest="shodan_key",
|
|
1010
|
+
help="Clave API Shodan (o var SHODAN_API_KEY) — enriquece IPs descubiertas "
|
|
1011
|
+
"con datos Shodan en cada ciclo de watch",
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
# ── diff ──────────────────────────────────────────────────────────────
|
|
1015
|
+
p_diff = subparsers.add_parser(
|
|
1016
|
+
"diff",
|
|
1017
|
+
help="Mostrar cambios acumulados entre dos snapshots almacenados",
|
|
1018
|
+
)
|
|
1019
|
+
p_diff.add_argument(
|
|
1020
|
+
"--target", required=True, metavar="DOMINIO",
|
|
1021
|
+
help="Dominio objetivo",
|
|
1022
|
+
)
|
|
1023
|
+
p_diff.add_argument(
|
|
1024
|
+
"--from", required=True, dest="from_date", metavar="FECHA",
|
|
1025
|
+
help="Fecha de inicio del rango (YYYY-MM-DD o YYYY-MM-DDTHH:MM)",
|
|
1026
|
+
)
|
|
1027
|
+
p_diff.add_argument(
|
|
1028
|
+
"--to", required=True, dest="to_date", metavar="FECHA",
|
|
1029
|
+
help="Fecha de fin del rango (YYYY-MM-DD o YYYY-MM-DDTHH:MM)",
|
|
1030
|
+
)
|
|
1031
|
+
|
|
530
1032
|
return parser
|
|
531
1033
|
|
|
532
1034
|
|
|
@@ -548,6 +1050,8 @@ def main() -> None:
|
|
|
548
1050
|
"history": cmd_history,
|
|
549
1051
|
"assets": cmd_assets,
|
|
550
1052
|
"export": cmd_export,
|
|
1053
|
+
"watch": cmd_watch,
|
|
1054
|
+
"diff": cmd_diff,
|
|
551
1055
|
}
|
|
552
1056
|
|
|
553
1057
|
handler = _dispatch.get(args.comando)
|
|
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
|