vamp-easm 1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- easm/__init__.py +5 -0
- easm/alerter.py +207 -0
- easm/differ.py +374 -0
- easm/scanner.py +517 -0
- easm/storage.py +295 -0
- vamp_easm-1.1.dist-info/METADATA +210 -0
- vamp_easm-1.1.dist-info/RECORD +12 -0
- vamp_easm-1.1.dist-info/WHEEL +5 -0
- vamp_easm-1.1.dist-info/entry_points.txt +2 -0
- vamp_easm-1.1.dist-info/top_level.txt +3 -0
- vamp_easm.py +563 -0
- vampsec_report.py +959 -0
easm/__init__.py
ADDED
easm/alerter.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# © VampSecure Studios — VampSecure Labs Security Research Division
|
|
2
|
+
"""
|
|
3
|
+
alerter.py — Sistema de alertas para vamp-easm
|
|
4
|
+
================================================
|
|
5
|
+
Envía notificaciones cuando se detectan diffs de severidad CRITICAL o HIGH.
|
|
6
|
+
|
|
7
|
+
Métodos de alerta disponibles:
|
|
8
|
+
· Webhook HTTP — POST JSON al endpoint configurado
|
|
9
|
+
· Variable de entorno EASM_ALERT_WEBHOOK como alternativa al flag --alert-webhook
|
|
10
|
+
|
|
11
|
+
El payload enviado al webhook sigue el esquema estándar VSL compatible
|
|
12
|
+
con Slack (incoming webhooks), Discord, Mattermost y cualquier receptor
|
|
13
|
+
que acepte JSON.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import urllib.request
|
|
21
|
+
import urllib.error
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from typing import List, Optional
|
|
24
|
+
|
|
25
|
+
from .differ import Diff
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Severidades que disparan alertas
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
SEVERIDADES_ALERTA = {"CRITICAL", "HIGH"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Construcción del payload de alerta
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def _construir_payload(
|
|
40
|
+
target: str,
|
|
41
|
+
diffs: List[Diff],
|
|
42
|
+
scan_id: str,
|
|
43
|
+
) -> dict:
|
|
44
|
+
"""
|
|
45
|
+
Construye el payload JSON que se envía al webhook.
|
|
46
|
+
|
|
47
|
+
El formato es compatible con Slack incoming webhooks mediante el campo
|
|
48
|
+
'text' y 'attachments', y también incluye un campo estructurado 'data'
|
|
49
|
+
con todos los diffs para integraciones personalizadas.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
target : Dominio escaneado
|
|
54
|
+
diffs : Lista de diffs CRITICAL/HIGH a alertar
|
|
55
|
+
scan_id : UUID del escaneo que generó los diffs
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
dict : Payload listo para serializar a JSON
|
|
60
|
+
"""
|
|
61
|
+
ahora = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
62
|
+
|
|
63
|
+
# Resumen para Slack/Discord (campo 'text')
|
|
64
|
+
resumen_lineas = [f"*[EASM] Alerta de superficie de ataque — {target}*"]
|
|
65
|
+
for d in diffs:
|
|
66
|
+
emoji = "🚨" if d.severidad == "CRITICAL" else "⚠️"
|
|
67
|
+
resumen_lineas.append(f"{emoji} `{d.categoria}` [{d.severidad}] — {d.activo}")
|
|
68
|
+
resumen = "\n".join(resumen_lineas)
|
|
69
|
+
|
|
70
|
+
# Detalles estructurados
|
|
71
|
+
detalles = []
|
|
72
|
+
for d in diffs:
|
|
73
|
+
detalles.append({
|
|
74
|
+
"categoria": d.categoria,
|
|
75
|
+
"severidad": d.severidad,
|
|
76
|
+
"activo": d.activo,
|
|
77
|
+
"descripcion": d.descripcion,
|
|
78
|
+
"evidencia": d.evidencia,
|
|
79
|
+
"finding_id": d.finding.id if d.finding else None,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"text": resumen,
|
|
84
|
+
"username": "vamp-easm",
|
|
85
|
+
"attachments": [
|
|
86
|
+
{
|
|
87
|
+
"color": "#c0392b" if any(d.severidad == "CRITICAL" for d in diffs) else "#d35400",
|
|
88
|
+
"title": f"EASM — {target} — {ahora}",
|
|
89
|
+
"text": f"scan_id: `{scan_id}`\n{len(diffs)} diffs alertables",
|
|
90
|
+
"footer": "VampSecure Labs · vamp-easm",
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"data": {
|
|
94
|
+
"tool": "vamp-easm",
|
|
95
|
+
"version": "1.0",
|
|
96
|
+
"target": target,
|
|
97
|
+
"scan_id": scan_id,
|
|
98
|
+
"ts": ahora,
|
|
99
|
+
"diffs": detalles,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Envío de alertas
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def enviar_webhook(
|
|
109
|
+
url: str,
|
|
110
|
+
target: str,
|
|
111
|
+
diffs: List[Diff],
|
|
112
|
+
scan_id: str,
|
|
113
|
+
timeout: int = 10,
|
|
114
|
+
) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Envía un POST JSON al webhook especificado con los diffs alertables.
|
|
117
|
+
|
|
118
|
+
Solo procesa diffs de severidad CRITICAL o HIGH. Si no hay ninguno,
|
|
119
|
+
no envía nada.
|
|
120
|
+
|
|
121
|
+
Parameters
|
|
122
|
+
----------
|
|
123
|
+
url : URL del webhook receptor
|
|
124
|
+
target : Dominio escaneado
|
|
125
|
+
diffs : Todos los diffs del escaneo (se filtra por severidad)
|
|
126
|
+
scan_id : UUID del escaneo
|
|
127
|
+
timeout : Segundos de timeout para la petición HTTP
|
|
128
|
+
|
|
129
|
+
Returns
|
|
130
|
+
-------
|
|
131
|
+
bool : True si el envío fue exitoso (HTTP 2xx), False si falló
|
|
132
|
+
"""
|
|
133
|
+
diffs_alertables = [d for d in diffs if d.severidad in SEVERIDADES_ALERTA]
|
|
134
|
+
if not diffs_alertables:
|
|
135
|
+
return True # nada que alertar → éxito
|
|
136
|
+
|
|
137
|
+
payload = _construir_payload(target, diffs_alertables, scan_id)
|
|
138
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
139
|
+
|
|
140
|
+
req = urllib.request.Request(
|
|
141
|
+
url,
|
|
142
|
+
data=body,
|
|
143
|
+
headers={
|
|
144
|
+
"Content-Type": "application/json",
|
|
145
|
+
"User-Agent": "vamp-easm/1.0",
|
|
146
|
+
},
|
|
147
|
+
method="POST",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
152
|
+
return 200 <= resp.status < 300
|
|
153
|
+
except urllib.error.HTTPError as exc:
|
|
154
|
+
return False
|
|
155
|
+
except Exception:
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def resolver_webhook_url(flag_url: Optional[str]) -> Optional[str]:
|
|
160
|
+
"""
|
|
161
|
+
Resuelve la URL del webhook: prioriza el flag CLI, luego la variable
|
|
162
|
+
de entorno EASM_ALERT_WEBHOOK.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
flag_url : Valor del flag --alert-webhook (None si no se proporcionó)
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
str | None : URL del webhook, o None si no está configurado
|
|
171
|
+
"""
|
|
172
|
+
if flag_url:
|
|
173
|
+
return flag_url
|
|
174
|
+
return os.environ.get("EASM_ALERT_WEBHOOK")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def gestionar_alertas(
|
|
178
|
+
webhook_url: Optional[str],
|
|
179
|
+
target: str,
|
|
180
|
+
diffs: List[Diff],
|
|
181
|
+
scan_id: str,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""
|
|
184
|
+
Punto de entrada principal del sistema de alertas.
|
|
185
|
+
|
|
186
|
+
Comprueba si hay diffs alertables y, si existe un webhook configurado,
|
|
187
|
+
intenta el envío. Los errores de envío no interrumpen la ejecución
|
|
188
|
+
del programa principal.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
webhook_url : URL del webhook (None → solo log en consola)
|
|
193
|
+
target : Dominio escaneado
|
|
194
|
+
diffs : Lista de diffs del escaneo
|
|
195
|
+
scan_id : UUID del escaneo
|
|
196
|
+
"""
|
|
197
|
+
diffs_criticos = [d for d in diffs if d.severidad == "CRITICAL"]
|
|
198
|
+
diffs_altos = [d for d in diffs if d.severidad == "HIGH"]
|
|
199
|
+
|
|
200
|
+
if not diffs_criticos and not diffs_altos:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
if not webhook_url:
|
|
204
|
+
# Sin webhook configurado: el usuario verá los diffs en la salida estándar
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
enviar_webhook(webhook_url, target, diffs, scan_id)
|
easm/differ.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
# © VampSecure Studios — VampSecure Labs Security Research Division
|
|
2
|
+
"""
|
|
3
|
+
differ.py — Motor de diferencias para vamp-easm
|
|
4
|
+
================================================
|
|
5
|
+
Compara el escaneo actual con el historial en SQLite y genera Findings
|
|
6
|
+
en formato VSL (prefijo EASM-NNN) para cada cambio detectado.
|
|
7
|
+
|
|
8
|
+
Categorías de diff y sus severidades:
|
|
9
|
+
NUEVO_SUBDOMINIO — HIGH (nueva superficie expuesta)
|
|
10
|
+
NUEVO_PUERTO — MEDIUM (nuevo servicio accesible)
|
|
11
|
+
CERT_EXPIRADO — HIGH (cert expira en < 30 días)
|
|
12
|
+
CERT_CAMBIADO — CRITICAL (fingerprint diferente desde último scan)
|
|
13
|
+
SERVICIO_DESAPARECIDO — LOW (puerto que ya no responde)
|
|
14
|
+
IP_CAMBIADA — MEDIUM (cambio de resolución DNS)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sqlite3
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from typing import List, Optional, Set, Tuple
|
|
23
|
+
|
|
24
|
+
from vampsec_report import Finding
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Umbral de alerta de expiración de certificado (días)
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
DIAS_ALERTA_CERT = 30
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Estructura de diff individual
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Diff:
|
|
39
|
+
"""
|
|
40
|
+
Representa un cambio detectado entre el escaneo actual y el anterior.
|
|
41
|
+
|
|
42
|
+
Attributes
|
|
43
|
+
----------
|
|
44
|
+
categoria : Tipo de cambio (ver módulo docstring)
|
|
45
|
+
severidad : CRITICAL | HIGH | MEDIUM | LOW | INFO
|
|
46
|
+
descripcion: Detalle legible del cambio
|
|
47
|
+
activo : Host, subdominio o endpoint afectado
|
|
48
|
+
evidencia : Datos técnicos que soportan el diff
|
|
49
|
+
finding : Finding VSL generado a partir de este diff
|
|
50
|
+
"""
|
|
51
|
+
categoria: str
|
|
52
|
+
severidad: str
|
|
53
|
+
descripcion: str
|
|
54
|
+
activo: str
|
|
55
|
+
evidencia: str
|
|
56
|
+
finding: Optional[Finding] = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Contadores de Finding para numeración secuencial EASM-NNN
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
class _Contador:
|
|
64
|
+
"""Generador de IDs únicos EASM-NNN dentro de una sesión."""
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self._n = 0
|
|
67
|
+
|
|
68
|
+
def siguiente(self) -> str:
|
|
69
|
+
self._n += 1
|
|
70
|
+
return f"EASM-{self._n:03d}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Motor de diffs
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
class MotorDiff:
|
|
78
|
+
"""
|
|
79
|
+
Compara el escaneo actual (almacenado en SQLite) con el anterior
|
|
80
|
+
y genera la lista de Diffs y sus Findings correspondientes.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
target : Dominio objetivo
|
|
85
|
+
scan_id : UUID del escaneo actual
|
|
86
|
+
scan_id_prev : UUID del escaneo anterior (None si es el primero)
|
|
87
|
+
storage : Módulo storage importado (para evitar import circular)
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
target: str,
|
|
93
|
+
scan_id: str,
|
|
94
|
+
scan_id_prev: Optional[str],
|
|
95
|
+
storage,
|
|
96
|
+
) -> None:
|
|
97
|
+
self.target = target
|
|
98
|
+
self.scan_id = scan_id
|
|
99
|
+
self.scan_id_prev = scan_id_prev
|
|
100
|
+
self.storage = storage
|
|
101
|
+
self._contador = _Contador()
|
|
102
|
+
|
|
103
|
+
def calcular(self) -> List[Diff]:
|
|
104
|
+
"""
|
|
105
|
+
Ejecuta todos los comparadores y devuelve la lista de diffs ordenada
|
|
106
|
+
por severidad (CRITICAL → HIGH → MEDIUM → LOW → INFO).
|
|
107
|
+
"""
|
|
108
|
+
diffs: List[Diff] = []
|
|
109
|
+
|
|
110
|
+
if self.scan_id_prev is None:
|
|
111
|
+
# Primer escaneo: no hay base de comparación
|
|
112
|
+
return diffs
|
|
113
|
+
|
|
114
|
+
diffs += self._diff_subdominios()
|
|
115
|
+
diffs += self._diff_puertos()
|
|
116
|
+
diffs += self._diff_servicios_desaparecidos()
|
|
117
|
+
diffs += self._diff_ips()
|
|
118
|
+
diffs += self._diff_certs()
|
|
119
|
+
|
|
120
|
+
# Ordenar por severidad
|
|
121
|
+
_orden = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
|
|
122
|
+
diffs.sort(key=lambda d: _orden.get(d.severidad, 99))
|
|
123
|
+
|
|
124
|
+
# Asignar Findings VSL
|
|
125
|
+
for d in diffs:
|
|
126
|
+
d.finding = self._hacer_finding(d)
|
|
127
|
+
|
|
128
|
+
return diffs
|
|
129
|
+
|
|
130
|
+
# ── Comparadores de subdominios ──────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def _diff_subdominios(self) -> List[Diff]:
|
|
133
|
+
"""Detecta subdominios que aparecen por primera vez en este escaneo."""
|
|
134
|
+
actuales = self._subdominios_actuales()
|
|
135
|
+
anteriores = self._subdominios_anteriores()
|
|
136
|
+
nuevos = actuales - anteriores
|
|
137
|
+
diffs: List[Diff] = []
|
|
138
|
+
for sub in sorted(nuevos):
|
|
139
|
+
diffs.append(Diff(
|
|
140
|
+
categoria="NUEVO_SUBDOMINIO",
|
|
141
|
+
severidad="HIGH",
|
|
142
|
+
activo=sub,
|
|
143
|
+
descripcion=(
|
|
144
|
+
f"Nuevo subdominio descubierto: {sub}. "
|
|
145
|
+
"Este host no aparecía en el escaneo anterior y puede "
|
|
146
|
+
"representar superficie de ataque no auditada."
|
|
147
|
+
),
|
|
148
|
+
evidencia=f"Subdominio: {sub}\nDetectado en escaneo: {self.scan_id}",
|
|
149
|
+
))
|
|
150
|
+
return diffs
|
|
151
|
+
|
|
152
|
+
def _subdominios_actuales(self) -> Set[str]:
|
|
153
|
+
rows = self.storage.assets_del_scan(self.target, self.scan_id)
|
|
154
|
+
return {r["subdominio"] for r in rows}
|
|
155
|
+
|
|
156
|
+
def _subdominios_anteriores(self) -> Set[str]:
|
|
157
|
+
rows = self.storage.assets_del_scan(self.target, self.scan_id_prev)
|
|
158
|
+
return {r["subdominio"] for r in rows}
|
|
159
|
+
|
|
160
|
+
# ── Comparadores de puertos ──────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
def _diff_puertos(self) -> List[Diff]:
|
|
163
|
+
"""Detecta puertos abiertos nuevos que no existían en el escaneo anterior."""
|
|
164
|
+
actuales = self._puertos_abiertos_actuales()
|
|
165
|
+
anteriores = self._puertos_abiertos_anteriores()
|
|
166
|
+
nuevos = actuales - anteriores
|
|
167
|
+
diffs: List[Diff] = []
|
|
168
|
+
for host, puerto in sorted(nuevos):
|
|
169
|
+
diffs.append(Diff(
|
|
170
|
+
categoria="NUEVO_PUERTO",
|
|
171
|
+
severidad="MEDIUM",
|
|
172
|
+
activo=f"{host}:{puerto}",
|
|
173
|
+
descripcion=(
|
|
174
|
+
f"Puerto {puerto}/tcp abierto por primera vez en {host}. "
|
|
175
|
+
"Un nuevo puerto puede indicar un servicio no intencionado o "
|
|
176
|
+
"un cambio de configuración no autorizado."
|
|
177
|
+
),
|
|
178
|
+
evidencia=(
|
|
179
|
+
f"Host: {host}\nPuerto: {puerto}/tcp\n"
|
|
180
|
+
f"Detectado en escaneo: {self.scan_id}"
|
|
181
|
+
),
|
|
182
|
+
))
|
|
183
|
+
return diffs
|
|
184
|
+
|
|
185
|
+
def _puertos_abiertos_actuales(self) -> Set[Tuple[str, int]]:
|
|
186
|
+
rows = self.storage.assets_del_scan(self.target, self.scan_id)
|
|
187
|
+
return {(r["subdominio"], r["puerto"]) for r in rows if r["puerto"] is not None}
|
|
188
|
+
|
|
189
|
+
def _puertos_abiertos_anteriores(self) -> Set[Tuple[str, int]]:
|
|
190
|
+
rows = self.storage.assets_del_scan(self.target, self.scan_id_prev)
|
|
191
|
+
return {(r["subdominio"], r["puerto"]) for r in rows if r["puerto"] is not None}
|
|
192
|
+
|
|
193
|
+
# ── Comparadores de servicios desaparecidos ──────────────────────────
|
|
194
|
+
|
|
195
|
+
def _diff_servicios_desaparecidos(self) -> List[Diff]:
|
|
196
|
+
"""Detecta puertos que estaban abiertos antes y ya no responden."""
|
|
197
|
+
actuales = self._puertos_abiertos_actuales()
|
|
198
|
+
anteriores = self._puertos_abiertos_anteriores()
|
|
199
|
+
desaparecidos = anteriores - actuales
|
|
200
|
+
diffs: List[Diff] = []
|
|
201
|
+
for host, puerto in sorted(desaparecidos):
|
|
202
|
+
diffs.append(Diff(
|
|
203
|
+
categoria="SERVICIO_DESAPARECIDO",
|
|
204
|
+
severidad="LOW",
|
|
205
|
+
activo=f"{host}:{puerto}",
|
|
206
|
+
descripcion=(
|
|
207
|
+
f"El puerto {puerto}/tcp en {host} estaba abierto en el escaneo "
|
|
208
|
+
"anterior y ahora no responde. Puede indicar un cierre intencionado "
|
|
209
|
+
"o un fallo del servicio."
|
|
210
|
+
),
|
|
211
|
+
evidencia=(
|
|
212
|
+
f"Host: {host}\nPuerto: {puerto}/tcp\n"
|
|
213
|
+
f"Desaparecido en escaneo: {self.scan_id}"
|
|
214
|
+
),
|
|
215
|
+
))
|
|
216
|
+
return diffs
|
|
217
|
+
|
|
218
|
+
# ── Comparadores de IPs ──────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
def _diff_ips(self) -> List[Diff]:
|
|
221
|
+
"""Detecta cambios de IP en subdominios ya conocidos."""
|
|
222
|
+
actuales = self._ips_por_subdominio(self.scan_id)
|
|
223
|
+
anteriores = self._ips_por_subdominio(self.scan_id_prev)
|
|
224
|
+
diffs: List[Diff] = []
|
|
225
|
+
for sub in sorted(actuales.keys() & anteriores.keys()):
|
|
226
|
+
ip_nueva = actuales[sub]
|
|
227
|
+
ip_vieja = anteriores[sub]
|
|
228
|
+
if ip_nueva and ip_vieja and ip_nueva != ip_vieja:
|
|
229
|
+
diffs.append(Diff(
|
|
230
|
+
categoria="IP_CAMBIADA",
|
|
231
|
+
severidad="MEDIUM",
|
|
232
|
+
activo=sub,
|
|
233
|
+
descripcion=(
|
|
234
|
+
f"La resolución DNS del subdominio {sub} ha cambiado. "
|
|
235
|
+
"Un cambio de IP no autorizado puede indicar un secuestro "
|
|
236
|
+
"de DNS o una reconfiguración de infraestructura."
|
|
237
|
+
),
|
|
238
|
+
evidencia=(
|
|
239
|
+
f"Subdominio: {sub}\n"
|
|
240
|
+
f"IP anterior: {ip_vieja}\n"
|
|
241
|
+
f"IP actual: {ip_nueva}"
|
|
242
|
+
),
|
|
243
|
+
))
|
|
244
|
+
return diffs
|
|
245
|
+
|
|
246
|
+
def _ips_por_subdominio(self, scan_id: str) -> dict:
|
|
247
|
+
rows = self.storage.assets_del_scan(self.target, scan_id)
|
|
248
|
+
resultado = {}
|
|
249
|
+
for r in rows:
|
|
250
|
+
sub = r["subdominio"]
|
|
251
|
+
if r["ip"] and sub not in resultado:
|
|
252
|
+
resultado[sub] = r["ip"]
|
|
253
|
+
return resultado
|
|
254
|
+
|
|
255
|
+
# ── Comparadores de certificados ─────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
def _diff_certs(self) -> List[Diff]:
|
|
258
|
+
"""
|
|
259
|
+
Detecta certificados expirados (o próximos a expirar) y cambios
|
|
260
|
+
de fingerprint respecto al escaneo anterior.
|
|
261
|
+
"""
|
|
262
|
+
certs_actuales = self.storage.certs_del_scan(self.target, self.scan_id)
|
|
263
|
+
ahora = datetime.now(timezone.utc)
|
|
264
|
+
diffs: List[Diff] = []
|
|
265
|
+
|
|
266
|
+
for cert in certs_actuales:
|
|
267
|
+
host = cert["host"]
|
|
268
|
+
|
|
269
|
+
# Comprobar expiración
|
|
270
|
+
not_after = cert["not_after"]
|
|
271
|
+
if not_after:
|
|
272
|
+
try:
|
|
273
|
+
expira = datetime.fromisoformat(not_after.replace("Z", "+00:00"))
|
|
274
|
+
dias_restantes = (expira - ahora).days
|
|
275
|
+
if dias_restantes < DIAS_ALERTA_CERT:
|
|
276
|
+
severidad = "HIGH" if dias_restantes > 0 else "CRITICAL"
|
|
277
|
+
estado = (
|
|
278
|
+
f"expira en {dias_restantes} días"
|
|
279
|
+
if dias_restantes > 0
|
|
280
|
+
else "EXPIRADO"
|
|
281
|
+
)
|
|
282
|
+
diffs.append(Diff(
|
|
283
|
+
categoria="CERT_EXPIRADO",
|
|
284
|
+
severidad=severidad,
|
|
285
|
+
activo=host,
|
|
286
|
+
descripcion=(
|
|
287
|
+
f"El certificado TLS de {host} {estado}. "
|
|
288
|
+
"Un certificado expirado o próximo a expirar interrumpe "
|
|
289
|
+
"la comunicación cifrada y puede comprometer la confianza "
|
|
290
|
+
"de los usuarios."
|
|
291
|
+
),
|
|
292
|
+
evidencia=(
|
|
293
|
+
f"Host: {host}\n"
|
|
294
|
+
f"Emitido a: {cert['issued_to'] or '—'}\n"
|
|
295
|
+
f"Emisor: {cert['issuer'] or '—'}\n"
|
|
296
|
+
f"Expiración: {not_after}\n"
|
|
297
|
+
f"Días restantes: {dias_restantes}"
|
|
298
|
+
),
|
|
299
|
+
))
|
|
300
|
+
except (ValueError, TypeError):
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
# Comprobar cambio de fingerprint respecto al escaneo anterior
|
|
304
|
+
cert_ant = self.storage.cert_anterior(self.target, host, self.scan_id)
|
|
305
|
+
if cert_ant and cert["fingerprint"] and cert_ant["fingerprint"]:
|
|
306
|
+
if cert["fingerprint"] != cert_ant["fingerprint"]:
|
|
307
|
+
diffs.append(Diff(
|
|
308
|
+
categoria="CERT_CAMBIADO",
|
|
309
|
+
severidad="CRITICAL",
|
|
310
|
+
activo=host,
|
|
311
|
+
descripcion=(
|
|
312
|
+
f"El certificado TLS de {host} ha cambiado desde el último "
|
|
313
|
+
"escaneo. Un cambio de fingerprint puede indicar renovación "
|
|
314
|
+
"legítima o, en el peor caso, un ataque de interposición (MitM)."
|
|
315
|
+
),
|
|
316
|
+
evidencia=(
|
|
317
|
+
f"Host: {host}\n"
|
|
318
|
+
f"Fingerprint anterior: {cert_ant['fingerprint']}\n"
|
|
319
|
+
f"Fingerprint actual: {cert['fingerprint']}\n"
|
|
320
|
+
f"Emisor actual: {cert['issuer'] or '—'}\n"
|
|
321
|
+
f"Expira: {not_after or '—'}"
|
|
322
|
+
),
|
|
323
|
+
))
|
|
324
|
+
|
|
325
|
+
return diffs
|
|
326
|
+
|
|
327
|
+
# ── Generación de Finding VSL ────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
def _hacer_finding(self, diff: Diff) -> Finding:
|
|
330
|
+
"""Convierte un Diff en un Finding normalizado VSL."""
|
|
331
|
+
_remediaciones = {
|
|
332
|
+
"NUEVO_SUBDOMINIO": (
|
|
333
|
+
"Verificar si el subdominio es conocido y gestionado por el equipo. "
|
|
334
|
+
"Si no es intencional, investigar el origen del registro DNS y eliminarlo "
|
|
335
|
+
"si no es necesario. Actualizar el inventario de activos."
|
|
336
|
+
),
|
|
337
|
+
"NUEVO_PUERTO": (
|
|
338
|
+
"Revisar si el servicio en el nuevo puerto está autorizado y correctamente "
|
|
339
|
+
"configurado. Aplicar las reglas de firewall adecuadas para restringir el "
|
|
340
|
+
"acceso solo a los rangos IP necesarios."
|
|
341
|
+
),
|
|
342
|
+
"CERT_EXPIRADO": (
|
|
343
|
+
"Renovar el certificado TLS inmediatamente. Considerar implementar "
|
|
344
|
+
"renovación automática mediante ACME/Let's Encrypt o un gestor de "
|
|
345
|
+
"certificados corporativo. Verificar que los monitores de expiración "
|
|
346
|
+
"están activos."
|
|
347
|
+
),
|
|
348
|
+
"CERT_CAMBIADO": (
|
|
349
|
+
"Verificar con el equipo de operaciones si el cambio de certificado fue "
|
|
350
|
+
"autorizado y documentado. Si no, iniciar protocolo de respuesta ante "
|
|
351
|
+
"incidentes para descartar un ataque de interposición (MitM). "
|
|
352
|
+
"Comparar el nuevo certificado con el registrado en el sistema de gestión."
|
|
353
|
+
),
|
|
354
|
+
"SERVICIO_DESAPARECIDO": (
|
|
355
|
+
"Confirmar si la desaparición del servicio fue intencionada. Si no, "
|
|
356
|
+
"verificar el estado del proceso y los logs del sistema. Actualizar el "
|
|
357
|
+
"inventario de activos si el cierre fue planificado."
|
|
358
|
+
),
|
|
359
|
+
"IP_CAMBIADA": (
|
|
360
|
+
"Confirmar con el equipo de infraestructura si el cambio de IP/DNS fue "
|
|
361
|
+
"autorizado. Si no, investigar posible secuestro de DNS o reconfiguración "
|
|
362
|
+
"no autorizada. Revisar los registros DNS y los logs del registrador."
|
|
363
|
+
),
|
|
364
|
+
}
|
|
365
|
+
return Finding(
|
|
366
|
+
id=self._contador.siguiente(),
|
|
367
|
+
title=f"{diff.categoria.replace('_', ' ').title()} — {diff.activo}",
|
|
368
|
+
severity=diff.severidad,
|
|
369
|
+
description=diff.descripcion,
|
|
370
|
+
evidence=diff.evidencia,
|
|
371
|
+
affected=diff.activo,
|
|
372
|
+
remediation=_remediaciones.get(diff.categoria, "Investigar y resolver el cambio detectado."),
|
|
373
|
+
tags=["easm", "continuous", diff.categoria.lower()],
|
|
374
|
+
)
|