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 ADDED
@@ -0,0 +1,5 @@
1
+ # © VampSecure Studios — VampSecure Labs Security Research Division
2
+ """
3
+ easm — Paquete interno de vamp-easm
4
+ Módulos: scanner, differ, storage, alerter
5
+ """
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
+ )