vamp-easm 1.3__tar.gz → 1.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vamp-easm
3
- Version: 1.3
3
+ Version: 1.5
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vamp-easm"
7
- version = "1.3"
7
+ version = "1.5"
8
8
  description = "External Attack Surface Management (EASM) scanner for authorized audits"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vamp-easm
3
- Version: 1.3
3
+ Version: 1.5
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
@@ -36,9 +36,14 @@ DIFFS DETECTADOS
36
36
  USO
37
37
  ---
38
38
  python vamp_easm.py scan --target ejemplo.com [--ports top100|22,80,443]
39
+ python vamp_easm.py scan --target ejemplo.com --shodan-key KEY --shodan-monitor
39
40
  python vamp_easm.py history --target ejemplo.com [--limit 10]
40
41
  python vamp_easm.py assets --target ejemplo.com
41
42
  python vamp_easm.py export --target ejemplo.com [--json] [--html]
43
+ python vamp_easm.py monitor --target ejemplo.com --shodan-key KEY --setup
44
+ python vamp_easm.py monitor --target ejemplo.com --shodan-key KEY --check
45
+ python vamp_easm.py monitor --target ejemplo.com --shodan-key KEY --remove
46
+ python vamp_easm.py monitor --shodan-key KEY --list
42
47
 
43
48
  EXIT CODES
44
49
  ----------
@@ -59,6 +64,7 @@ import json
59
64
  import sys
60
65
  import time
61
66
  from datetime import datetime, timezone
67
+ from pathlib import Path
62
68
  from typing import List, Optional
63
69
 
64
70
  from rich.console import Console
@@ -76,7 +82,7 @@ from vampsec_report import (
76
82
  # Constantes
77
83
  # ---------------------------------------------------------------------------
78
84
 
79
- VERSION = "1.3"
85
+ VERSION = "1.5"
80
86
  TOOL = "vamp-easm"
81
87
  BRAND = "VampSecure Labs — EASM Continuo"
82
88
 
@@ -87,7 +93,7 @@ BANNER = (
87
93
  " \\ V / _ \\| |\\/| | _/\\__ \\ _| (__| |_| | / _|| |__ / _ \\| _ \\__ \\\n"
88
94
  " \\_/_/ \\_\\_| |_|_| |___/___\\___|\\___/|_|_\\___|____/_/ \\_\\___/___/\n"
89
95
  ' by Antonio Hernandez "Belky" — VampSecure Studios\n'
90
- " vamp-easm v1.3 · External Attack Surface Management\n"
96
+ " vamp-easm v1.5 · External Attack Surface Management\n"
91
97
  " ────────────────────────────────────────────────────────────────────────\n"
92
98
  " USO EXCLUSIVO EN AUDITORÍAS AUTORIZADAS · El uso no autorizado es ilegal\n"
93
99
  )
@@ -372,6 +378,527 @@ class ShodanEASMEnricher:
372
378
  ))
373
379
 
374
380
 
381
+ # ---------------------------------------------------------------------------
382
+ # Integración Shodan Monitor (v1.5)
383
+ # ---------------------------------------------------------------------------
384
+
385
+ class ShodanMonitorManager:
386
+ """
387
+ Gestión de alertas Shodan Monitor para monitorización automática de IPs.
388
+
389
+ Shodan Monitor crea alertas que se activan cuando Shodan detecta nuevos
390
+ puertos abiertos, vulnerabilidades o cambios en las IPs monitorizadas.
391
+
392
+ Endpoints:
393
+ GET /shodan/alert/info?key= → lista todas las alertas activas
394
+ POST /shodan/alert?key= → crea una alerta nueva
395
+ GET /shodan/alert/{id}/info?key= → estado y matches de una alerta
396
+ DELETE /shodan/alert/{id}?key= → elimina una alerta
397
+ """
398
+
399
+ _BASE = "https://api.shodan.io/shodan/alert"
400
+ _ESTADO = Path.home() / ".config" / "vampsec" / "easm_shodan_monitor.json"
401
+
402
+ def __init__(self, api_key: str) -> None:
403
+ self._key = api_key
404
+
405
+ def _peticion(self, metodo: str, url: str, body: Optional[dict] = None) -> dict:
406
+ """Realiza una petición HTTP a la API de Shodan Monitor."""
407
+ import urllib.request as _ureq
408
+ import urllib.error as _uerr
409
+ import json as _json
410
+
411
+ data_bytes = None
412
+ if body is not None:
413
+ data_bytes = _json.dumps(body).encode()
414
+
415
+ req = _ureq.Request(
416
+ url,
417
+ data=data_bytes,
418
+ method=metodo,
419
+ headers={
420
+ "User-Agent": f"vamp-easm/{VERSION}",
421
+ "Accept": "application/json",
422
+ "Content-Type": "application/json",
423
+ },
424
+ )
425
+ try:
426
+ with _ureq.urlopen(req, timeout=20) as resp:
427
+ contenido = resp.read()
428
+ if not contenido:
429
+ return {}
430
+ return _json.loads(contenido)
431
+ except _uerr.HTTPError as e:
432
+ return {"__error__": e.code, "__msg__": e.reason}
433
+ except Exception as e:
434
+ return {"__error__": -1, "__msg__": str(e)}
435
+
436
+ def listar_alertas(self) -> list:
437
+ """Devuelve la lista de todas las alertas Shodan Monitor activas."""
438
+ resultado = self._peticion("GET", f"{self._BASE}/info?key={self._key}")
439
+ if "__error__" in resultado:
440
+ return []
441
+ if isinstance(resultado, list):
442
+ return resultado
443
+ return resultado.get("alerts", [])
444
+
445
+ def crear_alerta(self, nombre: str, ips: List[str]) -> Optional[dict]:
446
+ """
447
+ Crea una alerta Shodan Monitor para la lista de IPs.
448
+
449
+ Parámetros
450
+ ----------
451
+ nombre : str — Nombre identificativo de la alerta
452
+ ips : list[str] — Lista de IPs o rangos CIDR a monitorizar
453
+
454
+ Devuelve el dict de la alerta creada, o None si hay error.
455
+ """
456
+ ips_filtradas = [ip.strip() for ip in ips if ip.strip()]
457
+ if not ips_filtradas:
458
+ return None
459
+
460
+ body = {
461
+ "name": nombre,
462
+ "filters": {"ip": " ".join(ips_filtradas)},
463
+ }
464
+ resultado = self._peticion("POST", f"{self._BASE}?key={self._key}", body=body)
465
+ if "__error__" in resultado:
466
+ return None
467
+ return resultado
468
+
469
+ def obtener_alerta(self, alert_id: str) -> Optional[dict]:
470
+ """Obtiene el estado actual y los matches de una alerta por ID."""
471
+ resultado = self._peticion("GET", f"{self._BASE}/{alert_id}/info?key={self._key}")
472
+ if "__error__" in resultado:
473
+ return None
474
+ return resultado
475
+
476
+ def eliminar_alerta(self, alert_id: str) -> bool:
477
+ """Elimina una alerta Shodan Monitor. Devuelve True si tuvo éxito."""
478
+ resultado = self._peticion("DELETE", f"{self._BASE}/{alert_id}?key={self._key}")
479
+ return resultado.get("success", False) or "__error__" not in resultado
480
+
481
+ def _cargar_estado(self) -> dict:
482
+ """Carga el estado persistido en ~/.config/vampsec/easm_shodan_monitor.json."""
483
+ import json as _j
484
+ if self._ESTADO.exists():
485
+ try:
486
+ return _j.loads(self._ESTADO.read_text())
487
+ except Exception:
488
+ return {}
489
+ return {}
490
+
491
+ def _guardar_estado(self, estado: dict) -> None:
492
+ """Persiste el estado en ~/.config/vampsec/easm_shodan_monitor.json."""
493
+ import json as _j
494
+ self._ESTADO.parent.mkdir(parents=True, exist_ok=True)
495
+ self._ESTADO.write_text(_j.dumps(estado, indent=2, ensure_ascii=False))
496
+
497
+ def setup_para_target(self, target: str, ips: List[str]) -> Optional[str]:
498
+ """
499
+ Crea (o reutiliza) una alerta Shodan Monitor para el target.
500
+
501
+ Devuelve el ID de la alerta, o None si no se pudo crear.
502
+ """
503
+ estado = self._cargar_estado()
504
+
505
+ # Reutilizar alerta existente si sigue activa en Shodan
506
+ if target in estado:
507
+ alert_id = estado[target].get("alert_id")
508
+ if alert_id and self.obtener_alerta(alert_id):
509
+ console.print(
510
+ f" [dim]Alerta Shodan Monitor ya existente: "
511
+ f"ID [bold]{alert_id}[/bold][/dim]"
512
+ )
513
+ return alert_id
514
+
515
+ nombre = f"vamp-easm:{target}"
516
+ alerta = self.crear_alerta(nombre, ips)
517
+ if not alerta:
518
+ return None
519
+
520
+ alert_id = alerta.get("id") or alerta.get("alert_id")
521
+ if not alert_id:
522
+ return None
523
+
524
+ estado[target] = {
525
+ "alert_id": alert_id,
526
+ "alert_name": nombre,
527
+ "ips": ips,
528
+ "created_at": datetime.now(timezone.utc).isoformat(),
529
+ "last_checked": None,
530
+ "last_matches": {},
531
+ }
532
+ self._guardar_estado(estado)
533
+ return alert_id
534
+
535
+ def check_matches(self, target: str, findings: List) -> None:
536
+ """
537
+ Consulta la alerta activa del target y genera findings por cambios.
538
+
539
+ Compara los matches actuales con el estado previo persistido en disco
540
+ y emite hallazgos para nuevos puertos o CVEs detectados desde la
541
+ última comprobación. Modifica findings in-place.
542
+ """
543
+ estado = self._cargar_estado()
544
+ if target not in estado:
545
+ findings.append(Finding(
546
+ id = "EASM-MON-001",
547
+ title = "Shodan Monitor no configurado para este target",
548
+ severity = "INFO",
549
+ description = (
550
+ "No hay ninguna alerta Shodan Monitor activa para este target. "
551
+ "Ejecuta 'vamp-easm monitor --target ... --setup' para crearla."
552
+ ),
553
+ evidence = f"Target: {target}\nEstado: sin alerta",
554
+ affected = target,
555
+ remediation = (
556
+ "Ejecutar: vamp-easm monitor --target <dominio> "
557
+ "--shodan-key <KEY> --setup"
558
+ ),
559
+ tags = ["shodan-monitor", "easm"],
560
+ ))
561
+ return
562
+
563
+ info_target = estado[target]
564
+ alert_id = info_target.get("alert_id")
565
+ if not alert_id:
566
+ return
567
+
568
+ alerta = self.obtener_alerta(alert_id)
569
+ if alerta is None:
570
+ findings.append(Finding(
571
+ id = "EASM-MON-002",
572
+ title = f"Alerta Shodan Monitor {alert_id} no encontrada",
573
+ severity = "HIGH",
574
+ description = (
575
+ f"La alerta Shodan Monitor con ID '{alert_id}' no responde. "
576
+ "Puede haber sido eliminada o expirado. Recrear con '--setup'."
577
+ ),
578
+ evidence = f"Alert ID: {alert_id}\nTarget: {target}",
579
+ affected = target,
580
+ remediation = (
581
+ "Ejecutar: vamp-easm monitor --target <dominio> "
582
+ "--shodan-key <KEY> --setup"
583
+ ),
584
+ tags = ["shodan-monitor", "easm"],
585
+ ))
586
+ return
587
+
588
+ # Construir mapa {ip: {ports, vulns}} de los matches actuales
589
+ matches_actuales: dict = {}
590
+ for match in alerta.get("matches", []) or []:
591
+ ip = match.get("ip_str", "")
592
+ if not ip:
593
+ continue
594
+ puertos = match.get("port", [])
595
+ if isinstance(puertos, int):
596
+ puertos = [puertos]
597
+ cves = list((match.get("vulns", {}) or {}).keys())
598
+ if ip not in matches_actuales:
599
+ matches_actuales[ip] = {"ports": [], "vulns": []}
600
+ for p in puertos:
601
+ if p not in matches_actuales[ip]["ports"]:
602
+ matches_actuales[ip]["ports"].append(p)
603
+ for c in cves:
604
+ if c not in matches_actuales[ip]["vulns"]:
605
+ matches_actuales[ip]["vulns"].append(c)
606
+
607
+ # Índice base para IDs de hallazgos
608
+ matches_previos = info_target.get("last_matches", {}) or {}
609
+ idx_base = 10
610
+
611
+ for ip, datos in matches_actuales.items():
612
+ puertos_previos = set(matches_previos.get(ip, {}).get("ports", []))
613
+ vulns_previas = set(matches_previos.get(ip, {}).get("vulns", []))
614
+
615
+ # Nuevos puertos detectados
616
+ for puerto in datos["ports"]:
617
+ if puerto in puertos_previos:
618
+ continue
619
+ idx_base += 1
620
+ sev = "HIGH" if puerto in _SHODAN_PUERTOS_SENSIBLES else "MEDIUM"
621
+ findings.append(Finding(
622
+ id = f"EASM-MON-{idx_base:03d}",
623
+ title = f"Shodan Monitor: nuevo puerto {puerto} en {ip}",
624
+ severity = sev,
625
+ description = (
626
+ f"Shodan Monitor alertó de la apertura del puerto {puerto} "
627
+ f"en {ip}. No estaba presente en la última comprobación."
628
+ ),
629
+ evidence = (
630
+ f"IP: {ip}\n"
631
+ f"Puerto nuevo: {puerto}\n"
632
+ f"Puertos previos: "
633
+ f"{', '.join(str(p) for p in sorted(puertos_previos)) or '—'}\n"
634
+ f"Fuente: Shodan Monitor (alerta {alert_id})"
635
+ ),
636
+ affected = f"{ip}:{puerto}",
637
+ remediation = (
638
+ f"Verificar inmediatamente el puerto {puerto} en {ip}. "
639
+ "Si no es un servicio autorizado, aplicar regla de firewall."
640
+ ),
641
+ tags = ["shodan-monitor", "easm", "new-port"],
642
+ ))
643
+
644
+ # Nuevas CVEs detectadas
645
+ vulns_nuevas = [v for v in datos["vulns"] if v not in vulns_previas]
646
+ if vulns_nuevas:
647
+ cves_str = ", ".join(vulns_nuevas[:10])
648
+ idx_base += 1
649
+ sev = "CRITICAL" if len(vulns_nuevas) >= 3 else "HIGH"
650
+ findings.append(Finding(
651
+ id = f"EASM-MON-{idx_base:03d}",
652
+ title = (
653
+ f"Shodan Monitor: {len(vulns_nuevas)} CVE(s) "
654
+ f"nueva(s) en {ip}"
655
+ ),
656
+ severity = sev,
657
+ description = (
658
+ f"Shodan Monitor reporta {len(vulns_nuevas)} nueva(s) "
659
+ f"vulnerabilidad(es) en {ip}: {cves_str[:100]}."
660
+ ),
661
+ evidence = (
662
+ f"IP: {ip}\n"
663
+ f"CVEs nuevas: {cves_str}\n"
664
+ f"CVEs previas: "
665
+ f"{', '.join(sorted(vulns_previas)[:10]) or '—'}\n"
666
+ f"Fuente: Shodan Monitor (alerta {alert_id})"
667
+ ),
668
+ affected = ip,
669
+ remediation = (
670
+ "Consultar NVD para detalles y aplicar parches. "
671
+ "Referencia: https://nvd.nist.gov/"
672
+ ),
673
+ cve = vulns_nuevas[0],
674
+ tags = ["shodan-monitor", "easm", "cve", "new-vuln"],
675
+ ))
676
+
677
+ # IPs que desaparecen de matches (puertos posiblemente cerrados)
678
+ for ip in matches_previos:
679
+ if ip not in matches_actuales and matches_previos[ip].get("ports"):
680
+ idx_base += 1
681
+ findings.append(Finding(
682
+ id = f"EASM-MON-{idx_base:03d}",
683
+ title = f"Shodan Monitor: {ip} sin matches activos",
684
+ severity = "INFO",
685
+ description = (
686
+ f"La IP {ip} ya no aparece en los matches de Shodan Monitor. "
687
+ "Los puertos previamente detectados podrían haberse cerrado."
688
+ ),
689
+ evidence = (
690
+ f"IP: {ip}\n"
691
+ f"Puertos previos: "
692
+ f"{', '.join(str(p) for p in matches_previos[ip].get('ports', []))}\n"
693
+ "Fuente: Shodan Monitor"
694
+ ),
695
+ affected = ip,
696
+ remediation = (
697
+ "Verificar el estado actual del host. "
698
+ "Si es intencional, actualizar el inventario."
699
+ ),
700
+ tags = ["shodan-monitor", "easm", "port-closed"],
701
+ ))
702
+
703
+ # Persistir estado actualizado
704
+ info_target["last_checked"] = datetime.now(timezone.utc).isoformat()
705
+ info_target["last_matches"] = matches_actuales
706
+ estado[target] = info_target
707
+ self._guardar_estado(estado)
708
+
709
+
710
+ # ---------------------------------------------------------------------------
711
+ # Integración Censys (v1.4)
712
+ # ---------------------------------------------------------------------------
713
+
714
+ # URL base de la API Censys v2
715
+ _CENSYS_SEARCH_URL = "https://search.censys.io/api/v2/hosts/search"
716
+
717
+ # Puertos considerados sensibles para enriquecer hallazgos Censys
718
+ _CENSYS_PUERTOS_SENSIBLES = {21, 22, 23, 25, 80, 443, 445, 3389, 1433, 3306, 5432, 5900, 6379, 8080, 8443, 27017, 9200}
719
+
720
+
721
+ class CensysEASMEnricher:
722
+ """
723
+ Enriquecimiento EASM con la API Censys v2.
724
+
725
+ Consulta el endpoint de búsqueda de hosts Censys usando la query
726
+ ``parsed.names:<dominio>`` para detectar hosts indexados por Censys
727
+ que no fueron descubiertos por crt.sh/HackerTarget.
728
+
729
+ Hallazgos emitidos:
730
+ - CENSYS_HOST_EXPOSED: host encontrado por Censys pero no en el
731
+ escaneo activo (superficie no visible para la organización).
732
+ - Puerto sensible abierto según Censys: MEDIUM.
733
+
734
+ Límite: 1 petición por segundo (rate limit de la API gratuita).
735
+ Resultados por página: 100 hosts (máximo permitido por la API).
736
+ """
737
+
738
+ _MAX_PAGINAS = 3 # máximo de páginas (300 hosts) por invocación
739
+
740
+ def __init__(self, censys_id: str, censys_secret: str) -> None:
741
+ self._id = censys_id
742
+ self._secret = censys_secret
743
+
744
+ def _auth_header(self) -> str:
745
+ """Devuelve el valor de la cabecera Authorization (Basic Auth)."""
746
+ import base64 as _b64
747
+ creds = f"{self._id}:{self._secret}".encode()
748
+ return "Basic " + _b64.b64encode(creds).decode()
749
+
750
+ def enriquecer(
751
+ self,
752
+ dominio: str,
753
+ ips_conocidas: set,
754
+ findings: list,
755
+ ) -> None:
756
+ """
757
+ Consulta Censys por el dominio y añade hallazgos a la lista recibida.
758
+
759
+ Parámetros
760
+ ----------
761
+ dominio : str — Dominio objetivo
762
+ ips_conocidas : set[str] — IPs ya descubiertas por el escaneo
763
+ findings : list — Lista de Finding a la que se añaden hallazgos
764
+ """
765
+ import urllib.request as _ureq
766
+ import urllib.error as _uerr
767
+ import json as _json
768
+ import time as _time
769
+
770
+ auth = self._auth_header()
771
+ idx_base = max(
772
+ (int(f.id.split("-")[-1]) for f in findings if "-" in f.id),
773
+ default=0,
774
+ )
775
+
776
+ cursor: str | None = None
777
+ pagina = 0
778
+ hosts_censys: list[dict] = []
779
+
780
+ while pagina < self._MAX_PAGINAS:
781
+ # Construir URL con paginación
782
+ params = f"q=parsed.names%3A{_ureq.quote(dominio)}&per_page=100"
783
+ if cursor:
784
+ params += f"&cursor={_ureq.quote(cursor)}"
785
+ url = f"{_CENSYS_SEARCH_URL}?{params}"
786
+
787
+ try:
788
+ req = _ureq.Request(
789
+ url,
790
+ headers={
791
+ "Authorization": auth,
792
+ "User-Agent": f"vamp-easm/{VERSION}",
793
+ "Accept": "application/json",
794
+ },
795
+ )
796
+ with _ureq.urlopen(req, timeout=20) as resp:
797
+ if resp.status != 200:
798
+ break
799
+ data = _json.loads(resp.read())
800
+ except _uerr.HTTPError as e:
801
+ if e.code in (401, 403):
802
+ findings.append(Finding(
803
+ id = f"EASM-CNS-{idx_base + 1:03d}",
804
+ title = "Credenciales Censys inválidas o sin permisos",
805
+ severity = "INFO",
806
+ description = (
807
+ f"La API Censys devolvió {e.code}. "
808
+ "Verificar --censys-id y --censys-secret."
809
+ ),
810
+ evidence = f"URL: {_CENSYS_SEARCH_URL}",
811
+ affected = dominio,
812
+ remediation = "Comprobar credenciales en https://search.censys.io/account",
813
+ tags = ["censys", "easm"],
814
+ ))
815
+ break
816
+ except Exception:
817
+ break
818
+
819
+ lote = data.get("result", {}).get("hits", [])
820
+ hosts_censys.extend(lote)
821
+ cursor = data.get("result", {}).get("links", {}).get("next")
822
+ pagina += 1
823
+
824
+ if not cursor or not lote:
825
+ break
826
+
827
+ # Respetar el límite de 1 req/seg de la API gratuita
828
+ _time.sleep(1.0)
829
+
830
+ # Procesar cada host devuelto por Censys
831
+ for host in hosts_censys:
832
+ ip = host.get("ip", "")
833
+ nombres = host.get("names", []) or []
834
+ servicios = host.get("services", []) or []
835
+ puertos = [s.get("port") for s in servicios if s.get("port")]
836
+
837
+ # Si la IP no fue encontrada por crt.sh/HackerTarget → hallazgo
838
+ if ip and ip not in ips_conocidas:
839
+ idx_base += 1
840
+ nombres_str = ", ".join(nombres[:5]) or "—"
841
+ puertos_str = ", ".join(str(p) for p in puertos[:10]) or "—"
842
+ findings.append(Finding(
843
+ id = f"EASM-CNS-{idx_base:03d}",
844
+ title = f"Host expuesto detectado por Censys (no en escaneo activo) — {ip}",
845
+ severity = "HIGH",
846
+ description = (
847
+ f"Censys indexa el host {ip} asociado al dominio '{dominio}' "
848
+ "pero no fue descubierto por crt.sh ni HackerTarget. Puede "
849
+ "tratarse de infraestructura shadow, activos olvidados o "
850
+ "exposición no intencionada."
851
+ ),
852
+ evidence = (
853
+ f"IP: {ip}\n"
854
+ f"Nombres DNS (Censys): {nombres_str}\n"
855
+ f"Puertos indexados: {puertos_str}\n"
856
+ f"Dominio consultado: {dominio}\n"
857
+ "Fuente: Censys API v2 (datos históricos — verificar estado actual)"
858
+ ),
859
+ affected = ip,
860
+ remediation = (
861
+ "Verificar si el activo pertenece a la organización. "
862
+ "Si es legítimo, incluirlo en el inventario de superficie de ataque. "
863
+ "Si no, investigar posible shadow IT o activo olvidado."
864
+ ),
865
+ tags = ["censys", "easm", "CENSYS_HOST_EXPOSED"],
866
+ ))
867
+
868
+ # Puertos sensibles abiertos según Censys (para IPs conocidas y nuevas)
869
+ sensibles = [p for p in puertos if p in _CENSYS_PUERTOS_SENSIBLES]
870
+ for puerto in sensibles:
871
+ # Evitar duplicados con hallazgos Shodan
872
+ ya_reportado = any(
873
+ f"{ip}:{puerto}" in (getattr(f, "affected", "") or "")
874
+ for f in findings
875
+ )
876
+ if ya_reportado:
877
+ continue
878
+ idx_base += 1
879
+ findings.append(Finding(
880
+ id = f"EASM-CNS-{idx_base:03d}",
881
+ title = f"Puerto sensible {puerto} abierto según Censys — {ip}",
882
+ severity = "MEDIUM",
883
+ description = (
884
+ f"Censys ha indexado el puerto {puerto} como abierto en "
885
+ f"la IP {ip}. Este puerto corresponde a un servicio de "
886
+ "alto riesgo si está expuesto sin control de acceso."
887
+ ),
888
+ evidence = (
889
+ f"IP: {ip} Puerto: {puerto}\n"
890
+ f"Servicios Censys: {', '.join(s.get('service_name','?') for s in servicios if s.get('port') == puerto) or '—'}\n"
891
+ "Fuente: Censys API v2"
892
+ ),
893
+ affected = f"{ip}:{puerto}",
894
+ remediation = (
895
+ f"Comprobar si el puerto {puerto} debe estar expuesto a internet. "
896
+ "Si no es necesario, restringir acceso con firewall."
897
+ ),
898
+ tags = ["censys", "easm", "port"],
899
+ ))
900
+
901
+
375
902
  # ---------------------------------------------------------------------------
376
903
  # Comando: scan
377
904
  # ---------------------------------------------------------------------------
@@ -477,6 +1004,14 @@ def cmd_scan(args: argparse.Namespace) -> int:
477
1004
  "[dim] ℹ Shodan no activo — usa --shodan-key para enriquecer con datos Shodan[/dim]"
478
1005
  )
479
1006
 
1007
+ # Leer claves Censys (opcional, v1.4)
1008
+ censys_id = getattr(args, "censys_id", None) or __import__("os").environ.get("CENSYS_API_ID", "")
1009
+ censys_secret = getattr(args, "censys_secret", None) or __import__("os").environ.get("CENSYS_API_SECRET", "")
1010
+ if not (censys_id and censys_secret):
1011
+ console.print(
1012
+ "[dim] ℹ Censys no activo — usa --censys-id y --censys-secret para enriquecer con datos Censys[/dim]"
1013
+ )
1014
+
480
1015
  # ── Motor de diffs ────────────────────────────────────────────────────
481
1016
  console.print("\n[cyan]►[/cyan] [bold]Calculando diffs respecto al escaneo anterior…[/bold]")
482
1017
  scan_anterior = storage.ultimo_scan(target)
@@ -521,6 +1056,40 @@ def cmd_scan(args: argparse.Namespace) -> int:
521
1056
  else:
522
1057
  console.print(" [dim]Sin IPs resueltas para consultar Shodan.[/dim]")
523
1058
 
1059
+ # ── Enriquecimiento Censys (v1.4, opcional) ───────────────────────────
1060
+ findings_censys: List[Finding] = []
1061
+ if censys_id and censys_secret:
1062
+ console.print("\n[cyan]►[/cyan] [bold]Enriquecimiento Censys (por dominio)…[/bold]")
1063
+ # Recopilar IPs ya conocidas del escaneo activo
1064
+ ips_conocidas: set = set()
1065
+ for sub in resultado.subdominios:
1066
+ if sub.ips:
1067
+ ips_conocidas.update(sub.ips)
1068
+
1069
+ censys_enricher = CensysEASMEnricher(censys_id, censys_secret)
1070
+ censys_enricher.enriquecer(target, ips_conocidas, findings_censys)
1071
+ nuevos = len([f for f in findings_censys if "CENSYS_HOST_EXPOSED" in (getattr(f, "tags", None) or [])])
1072
+ console.print(
1073
+ f" {nuevos} host(s) nuevos (CENSYS_HOST_EXPOSED) · "
1074
+ f"{len(findings_censys)} hallazgos totales Censys"
1075
+ )
1076
+
1077
+ # ── Shodan Monitor: verificar alertas activas (v1.5) ─────────────────
1078
+ findings_monitor: List[Finding] = []
1079
+ if shodan_key_easm and getattr(args, "shodan_monitor", False):
1080
+ console.print("\n[cyan]►[/cyan] [bold]Shodan Monitor: verificando alertas activas…[/bold]")
1081
+ mon_mgr = ShodanMonitorManager(shodan_key_easm)
1082
+ mon_mgr.check_matches(target, findings_monitor)
1083
+ nuevos_mon = [
1084
+ f for f in findings_monitor
1085
+ if "new-port" in (getattr(f, "tags", None) or [])
1086
+ or "new-vuln" in (getattr(f, "tags", None) or [])
1087
+ ]
1088
+ console.print(
1089
+ f" {len(nuevos_mon)} nueva(s) amenaza(s) Shodan Monitor · "
1090
+ f"{len(findings_monitor)} hallazgos totales"
1091
+ )
1092
+
524
1093
  # ── Mostrar resultados ────────────────────────────────────────────────
525
1094
  if diffs:
526
1095
  console.print(_tabla_diffs(diffs))
@@ -538,6 +1107,10 @@ def cmd_scan(args: argparse.Namespace) -> int:
538
1107
  findings = [d.finding for d in diffs if d.finding is not None]
539
1108
  # Incluir hallazgos Shodan en el informe si los hay
540
1109
  findings.extend(findings_shodan)
1110
+ # Incluir hallazgos Censys en el informe si los hay (v1.4)
1111
+ findings.extend(findings_censys)
1112
+ # Incluir hallazgos Shodan Monitor en el informe si los hay (v1.5)
1113
+ findings.extend(findings_monitor)
541
1114
  if findings:
542
1115
  meta = meta_from_args(args, tool=TOOL, version=VERSION)
543
1116
  meta.scope = target
@@ -898,6 +1471,170 @@ def cmd_diff(args: argparse.Namespace) -> int:
898
1471
  return 0
899
1472
 
900
1473
 
1474
+ # ---------------------------------------------------------------------------
1475
+ # Comando: monitor
1476
+ # ---------------------------------------------------------------------------
1477
+
1478
+ def cmd_monitor(args: argparse.Namespace) -> int:
1479
+ """
1480
+ Gestión de alertas Shodan Monitor para monitorización automática de IPs.
1481
+
1482
+ Acciones disponibles:
1483
+ --setup : crea una alerta Shodan Monitor para las IPs del target
1484
+ --check : consulta la alerta y muestra nuevas amenazas detectadas
1485
+ --remove : elimina la alerta del target
1486
+ --list : lista todas las alertas Shodan Monitor de la cuenta
1487
+ """
1488
+ shodan_key = (
1489
+ getattr(args, "shodan_key", None)
1490
+ or __import__("os").environ.get("SHODAN_API_KEY", "")
1491
+ )
1492
+ if not shodan_key:
1493
+ console.print("[red]Error:[/red] Se requiere --shodan-key o SHODAN_API_KEY")
1494
+ return 1
1495
+
1496
+ mgr = ShodanMonitorManager(shodan_key)
1497
+
1498
+ # ── Listar todas las alertas ──────────────────────────────────────────
1499
+ if getattr(args, "list_alerts", False):
1500
+ alertas = mgr.listar_alertas()
1501
+ if not alertas:
1502
+ console.print("[dim]No hay alertas Shodan Monitor activas.[/dim]")
1503
+ return 0
1504
+ tabla = Table(title="Alertas Shodan Monitor", box=box.SIMPLE_HEAVY)
1505
+ tabla.add_column("ID", style="cyan", no_wrap=True)
1506
+ tabla.add_column("Nombre", style="white")
1507
+ tabla.add_column("IPs", style="dim", max_width=40)
1508
+ tabla.add_column("Creada", style="dim")
1509
+ for a in alertas:
1510
+ tabla.add_row(
1511
+ str(a.get("id", "—")),
1512
+ str(a.get("name", "—")),
1513
+ str(a.get("filters", {}).get("ip", "—"))[:40],
1514
+ str(a.get("created", "—"))[:16],
1515
+ )
1516
+ console.print(tabla)
1517
+ return 0
1518
+
1519
+ # Las demás acciones requieren --target
1520
+ target = getattr(args, "target", None)
1521
+ if not target:
1522
+ console.print(
1523
+ "[red]Error:[/red] --target es obligatorio para "
1524
+ "--setup, --check y --remove"
1525
+ )
1526
+ return 1
1527
+
1528
+ # ── Setup: crear alerta Shodan Monitor ───────────────────────────────
1529
+ if getattr(args, "setup", False):
1530
+ storage.inicializar_db()
1531
+ assets = storage.todos_los_assets(target)
1532
+ ips_set: set = {a["ip"] for a in assets if a.get("ip")}
1533
+
1534
+ if not ips_set:
1535
+ # Fallback: resolución DNS directa del dominio
1536
+ import socket as _sock
1537
+ try:
1538
+ for item in _sock.getaddrinfo(target, None):
1539
+ ips_set.add(item[4][0])
1540
+ except Exception:
1541
+ pass
1542
+
1543
+ if not ips_set:
1544
+ console.print(
1545
+ f"[red]Error:[/red] Sin IPs para {target}. "
1546
+ "Ejecuta primero: vamp-easm scan --target " + target
1547
+ )
1548
+ return 1
1549
+
1550
+ ips = list(ips_set)
1551
+ ips_str = ", ".join(ips[:5]) + ("…" if len(ips) > 5 else "")
1552
+ console.print(
1553
+ f"[cyan]►[/cyan] Creando alerta Shodan Monitor para "
1554
+ f"[bold]{target}[/bold] · {len(ips)} IP(s): [dim]{ips_str}[/dim]"
1555
+ )
1556
+
1557
+ alert_id = mgr.setup_para_target(target, ips)
1558
+ if not alert_id:
1559
+ console.print(
1560
+ "[red]Error:[/red] No se pudo crear la alerta en Shodan Monitor.\n"
1561
+ "[dim]Verifica que la clave API tiene permisos de monitorización.[/dim]"
1562
+ )
1563
+ return 1
1564
+
1565
+ console.print(
1566
+ f"[green]✓ Alerta creada:[/green] ID [bold]{alert_id}[/bold]\n"
1567
+ f" Usa '--check' para consultar los matches."
1568
+ )
1569
+ return 0
1570
+
1571
+ # ── Check: consultar matches y generar findings ───────────────────────
1572
+ if getattr(args, "check", False):
1573
+ console.print(
1574
+ f"[cyan]►[/cyan] Consultando Shodan Monitor para [bold]{target}[/bold]…"
1575
+ )
1576
+ findings: List[Finding] = []
1577
+ mgr.check_matches(target, findings)
1578
+
1579
+ if not findings:
1580
+ console.print("[green]✓ Sin novedades en Shodan Monitor.[/green]")
1581
+ return 0
1582
+
1583
+ tabla = Table(title=f"Shodan Monitor — {target}", box=box.SIMPLE_HEAVY)
1584
+ tabla.add_column("ID", style="dim", no_wrap=True, max_width=16)
1585
+ tabla.add_column("Sev", no_wrap=True, max_width=10)
1586
+ tabla.add_column("Título", style="white", max_width=60)
1587
+ tabla.add_column("Afectado", style="cyan", max_width=25)
1588
+ for f in findings:
1589
+ tabla.add_row(
1590
+ f.id,
1591
+ _badge_sev(f.severity),
1592
+ f.title[:60],
1593
+ f.affected or "—",
1594
+ )
1595
+ console.print(tabla)
1596
+
1597
+ criticos = [f for f in findings if f.severity == "CRITICAL"]
1598
+ highs = [f for f in findings if f.severity == "HIGH"]
1599
+ console.print(
1600
+ f"\n[bold]Resumen:[/bold] {len(findings)} hallazgos — "
1601
+ f"{len(criticos)} CRITICAL · {len(highs)} HIGH"
1602
+ )
1603
+ if criticos:
1604
+ return 2
1605
+ if highs:
1606
+ return 1
1607
+ return 0
1608
+
1609
+ # ── Remove: eliminar alerta ───────────────────────────────────────────
1610
+ if getattr(args, "remove", False):
1611
+ estado = mgr._cargar_estado()
1612
+ if target not in estado:
1613
+ console.print(f"[yellow]⚠[/yellow] Sin alerta registrada para {target}")
1614
+ return 0
1615
+ alert_id = estado[target].get("alert_id")
1616
+ if not alert_id:
1617
+ console.print(f"[yellow]⚠[/yellow] Sin alert_id registrado para {target}")
1618
+ return 0
1619
+ console.print(
1620
+ f"[cyan]►[/cyan] Eliminando alerta Shodan Monitor "
1621
+ f"ID [bold]{alert_id}[/bold]…"
1622
+ )
1623
+ if mgr.eliminar_alerta(alert_id):
1624
+ del estado[target]
1625
+ mgr._guardar_estado(estado)
1626
+ console.print(f"[green]✓ Alerta {alert_id} eliminada.[/green]")
1627
+ return 0
1628
+ console.print(f"[red]Error:[/red] No se pudo eliminar la alerta {alert_id}")
1629
+ return 1
1630
+
1631
+ console.print(
1632
+ "[yellow]⚠[/yellow] Especifica una acción: "
1633
+ "--setup, --check, --remove o --list"
1634
+ )
1635
+ return 1
1636
+
1637
+
901
1638
  # ---------------------------------------------------------------------------
902
1639
  # CLI — definición de argumentos
903
1640
  # ---------------------------------------------------------------------------
@@ -915,11 +1652,15 @@ def construir_parser() -> argparse.ArgumentParser:
915
1652
  "Ejemplos:\n"
916
1653
  " python vamp_easm.py scan --target ejemplo.com\n"
917
1654
  " python vamp_easm.py scan --target ejemplo.com --ports 22,80,443 --html\n"
1655
+ " python vamp_easm.py scan --target ejemplo.com --shodan-key KEY --shodan-monitor\n"
918
1656
  " python vamp_easm.py history --target ejemplo.com --limit 5\n"
919
1657
  " python vamp_easm.py assets --target ejemplo.com\n"
920
1658
  " python vamp_easm.py export --target ejemplo.com --json\n"
921
1659
  " python vamp_easm.py watch --target ejemplo.com --interval 1800\n"
922
1660
  " python vamp_easm.py diff --target ejemplo.com --from 2026-09-01 --to 2026-09-17\n"
1661
+ " python vamp_easm.py monitor --target ejemplo.com --shodan-key KEY --setup\n"
1662
+ " python vamp_easm.py monitor --target ejemplo.com --shodan-key KEY --check\n"
1663
+ " python vamp_easm.py monitor --shodan-key KEY --list\n"
923
1664
  ),
924
1665
  )
925
1666
  parser.add_argument(
@@ -961,6 +1702,22 @@ def construir_parser() -> argparse.ArgumentParser:
961
1702
  help="Clave API Shodan (o var SHODAN_API_KEY) — enriquece IPs descubiertas "
962
1703
  "con puertos, servicios y vulnerabilidades indexadas por Shodan",
963
1704
  )
1705
+ p_scan.add_argument(
1706
+ "--censys-id", metavar="ID", dest="censys_id",
1707
+ help="ID de la API Censys v2 (o var CENSYS_API_ID) — enriquece el escaneo "
1708
+ "con hosts detectados por Censys no visibles en crt.sh/HackerTarget (v1.4)",
1709
+ )
1710
+ p_scan.add_argument(
1711
+ "--censys-secret", metavar="SECRET", dest="censys_secret",
1712
+ help="Secret de la API Censys v2 (o var CENSYS_API_SECRET) — requerido junto "
1713
+ "con --censys-id para activar el enriquecimiento Censys (v1.4)",
1714
+ )
1715
+ p_scan.add_argument(
1716
+ "--shodan-monitor", action="store_true", dest="shodan_monitor",
1717
+ help="Integrar con Shodan Monitor: verificar alertas activas durante el escaneo. "
1718
+ "Requiere --shodan-key y haber ejecutado previamente "
1719
+ "'vamp-easm monitor --setup' (v1.5)",
1720
+ )
964
1721
  add_report_args(p_scan)
965
1722
 
966
1723
  # ── history ───────────────────────────────────────────────────────────
@@ -1029,6 +1786,36 @@ def construir_parser() -> argparse.ArgumentParser:
1029
1786
  help="Fecha de fin del rango (YYYY-MM-DD o YYYY-MM-DDTHH:MM)",
1030
1787
  )
1031
1788
 
1789
+ # ── monitor ───────────────────────────────────────────────────────────
1790
+ p_mon = subparsers.add_parser(
1791
+ "monitor",
1792
+ help="Gestión de alertas Shodan Monitor para monitorización automática de IPs",
1793
+ )
1794
+ p_mon.add_argument(
1795
+ "--target", metavar="DOMINIO",
1796
+ help="Dominio objetivo (requerido para --setup, --check, --remove)",
1797
+ )
1798
+ p_mon.add_argument(
1799
+ "--shodan-key", metavar="API_KEY", dest="shodan_key",
1800
+ help="Clave API Shodan (o var SHODAN_API_KEY)",
1801
+ )
1802
+ p_mon.add_argument(
1803
+ "--setup", action="store_true",
1804
+ help="Crear alerta Shodan Monitor para las IPs del target",
1805
+ )
1806
+ p_mon.add_argument(
1807
+ "--check", action="store_true",
1808
+ help="Consultar matches actuales y mostrar nuevas amenazas detectadas",
1809
+ )
1810
+ p_mon.add_argument(
1811
+ "--remove", action="store_true",
1812
+ help="Eliminar la alerta Shodan Monitor del target",
1813
+ )
1814
+ p_mon.add_argument(
1815
+ "--list", action="store_true", dest="list_alerts",
1816
+ help="Listar todas las alertas Shodan Monitor activas en la cuenta",
1817
+ )
1818
+
1032
1819
  return parser
1033
1820
 
1034
1821
 
@@ -1052,6 +1839,7 @@ def main() -> None:
1052
1839
  "export": cmd_export,
1053
1840
  "watch": cmd_watch,
1054
1841
  "diff": cmd_diff,
1842
+ "monitor": cmd_monitor,
1055
1843
  }
1056
1844
 
1057
1845
  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