GuardianUnivalle-Benito-Yucra 1.1.18__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.
- GuardianUnivalle_Benito_Yucra/__init__.py +25 -0
- GuardianUnivalle_Benito_Yucra/auditoria/registro_auditoria.py +40 -0
- GuardianUnivalle_Benito_Yucra/criptografia/cifrado_aead.py +25 -0
- GuardianUnivalle_Benito_Yucra/criptografia/intercambio_claves.py +23 -0
- GuardianUnivalle_Benito_Yucra/criptografia/kdf.py +23 -0
- GuardianUnivalle_Benito_Yucra/detectores/detector_csrf.py +488 -0
- GuardianUnivalle_Benito_Yucra/detectores/detector_dos.py +352 -0
- GuardianUnivalle_Benito_Yucra/detectores/detector_sql.py +641 -0
- GuardianUnivalle_Benito_Yucra/detectores/detector_xss.py +626 -0
- GuardianUnivalle_Benito_Yucra/middleware_web/middleware_web.py +13 -0
- GuardianUnivalle_Benito_Yucra/mitigacion/limitador_peticion.py +7 -0
- GuardianUnivalle_Benito_Yucra/mitigacion/lista_bloqueo.py +10 -0
- GuardianUnivalle_Benito_Yucra/puntuacion/puntuacion_amenaza.py +15 -0
- GuardianUnivalle_Benito_Yucra/utilidades.py +7 -0
- guardianunivalle_benito_yucra-1.1.18.dist-info/METADATA +279 -0
- guardianunivalle_benito_yucra-1.1.18.dist-info/RECORD +19 -0
- guardianunivalle_benito_yucra-1.1.18.dist-info/WHEEL +5 -0
- guardianunivalle_benito_yucra-1.1.18.dist-info/licenses/LICENSE +1 -0
- guardianunivalle_benito_yucra-1.1.18.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLI Defense Middleware con criptografía (AES-GCM / ChaCha20-Poly1305, HMAC-SHA256, Argon2id)
|
|
3
|
+
- Instala: cryptography, argon2-cffi
|
|
4
|
+
- Configuraciones mínimas en settings.py:
|
|
5
|
+
SQLI_DEFENSE_MASTER_KEY = base64.b64encode(os.urandom(32)).decode() # ejemplo
|
|
6
|
+
SQLI_DEFENSE_AEAD = "AESGCM" # o "CHACHA20"
|
|
7
|
+
SQLI_DEFENSE_ARGON2 = {...} # opcional, parámetros de Argon2id
|
|
8
|
+
SQLI_DEFENSE_P_ATTACK_BLOCK = 0.97
|
|
9
|
+
SQLI_DEFENSE_TRUSTED_IPS = []
|
|
10
|
+
SQLI_DEFENSE_TRUSTED_URLS = []
|
|
11
|
+
SQLI_DEFENSE_USE_CHALLENGE = False
|
|
12
|
+
"""
|
|
13
|
+
import base64
|
|
14
|
+
import os
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
import math
|
|
18
|
+
import hmac
|
|
19
|
+
import logging
|
|
20
|
+
import re
|
|
21
|
+
import html
|
|
22
|
+
import urllib.parse
|
|
23
|
+
import ipaddress
|
|
24
|
+
from typing import List, Tuple, Dict, Any
|
|
25
|
+
|
|
26
|
+
from django.utils.deprecation import MiddlewareMixin
|
|
27
|
+
from django.conf import settings
|
|
28
|
+
from django.http import HttpResponseForbidden, HttpResponse
|
|
29
|
+
from django.core.cache import cache
|
|
30
|
+
|
|
31
|
+
# cryptography & argon2
|
|
32
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
|
|
33
|
+
from cryptography.hazmat.primitives import hashes, hmac as crypto_hmac
|
|
34
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
35
|
+
from cryptography.exceptions import InvalidSignature
|
|
36
|
+
from argon2.low_level import hash_secret_raw, Type as Argon2Type
|
|
37
|
+
|
|
38
|
+
# --- logger
|
|
39
|
+
logger = logging.getLogger("sqlidefense_crypto")
|
|
40
|
+
logger.setLevel(logging.INFO)
|
|
41
|
+
if not logger.handlers:
|
|
42
|
+
handler = logging.StreamHandler()
|
|
43
|
+
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s"))
|
|
44
|
+
logger.addHandler(handler)
|
|
45
|
+
|
|
46
|
+
# ---------------------------
|
|
47
|
+
# IMPORTANTE: Configuraciones
|
|
48
|
+
# ---------------------------
|
|
49
|
+
MASTER_KEY_B64 = getattr(settings, "SQLI_DEFENSE_MASTER_KEY", None)
|
|
50
|
+
if not MASTER_KEY_B64:
|
|
51
|
+
MASTER_KEY = os.urandom(32)
|
|
52
|
+
else:
|
|
53
|
+
try:
|
|
54
|
+
MASTER_KEY = base64.b64decode(MASTER_KEY_B64)
|
|
55
|
+
except Exception:
|
|
56
|
+
MASTER_KEY = MASTER_KEY_B64.encode() if isinstance(MASTER_KEY_B64, str) else MASTER_KEY_B64
|
|
57
|
+
|
|
58
|
+
AEAD_CHOICE = getattr(settings, "SQLI_DEFENSE_AEAD", "AESGCM").upper() # AESGCM o CHACHA20
|
|
59
|
+
|
|
60
|
+
# Argon2id parámetros (ajustables desde settings)
|
|
61
|
+
ARGON2_CONFIG = getattr(settings, "SQLI_DEFENSE_ARGON2", {
|
|
62
|
+
"time_cost": 2,
|
|
63
|
+
"memory_cost": 65536,
|
|
64
|
+
"parallelism": 1,
|
|
65
|
+
"hash_len": 32,
|
|
66
|
+
"type": Argon2Type.ID,
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
# HMAC key derivation salt label
|
|
70
|
+
HMAC_LABEL = b"sqlidefense-hmac"
|
|
71
|
+
AEAD_LABEL = b"sqlidefense-aead"
|
|
72
|
+
|
|
73
|
+
# ---------- reutilizamos tus patrones/constantes ----------
|
|
74
|
+
# (Corta la lista por brevedad en este snippet; asume que SQL_PATTERNS y demás vienen de tu original)
|
|
75
|
+
# Para simplicidad aquí importamos o pegamos tu SQL_PATTERNS y configs (usa el tuyo completo en producción).
|
|
76
|
+
SQL_PATTERNS: List[Tuple[re.Pattern, str, float]] = [
|
|
77
|
+
(re.compile(r"\bunion\b\s+(all\s+)?\bselect\b", re.I), "UNION SELECT (exfiltración)", 0.95),
|
|
78
|
+
(re.compile(r"\bselect\b\s+.*\bfrom\b\s+.+\bwhere\b", re.I | re.S), "SELECT ... FROM ... WHERE (consulta completa)", 0.7),
|
|
79
|
+
(re.compile(r"\binto\s+outfile\b|\binto\s+dumpfile\b", re.I), "INTO OUTFILE / INTO DUMPFILE (volcado a fichero)", 0.98),
|
|
80
|
+
(re.compile(r"\bload_file\s*\(", re.I), "LOAD_FILE() (lectura fichero MySQL)", 0.95),
|
|
81
|
+
(re.compile(r"\b(pg_read_file|pg_read_binary_file|pg_ls_dir)\s*\(", re.I), "pg_read_file / funciones lectura Postgres", 0.95),
|
|
82
|
+
(re.compile(r"\bfile_read\b|\bfile_get_contents\b", re.I), "Indicadores de lectura de fichero en código", 0.85),
|
|
83
|
+
|
|
84
|
+
(re.compile(r"\b(sleep|benchmark|pg_sleep|dbms_lock\.sleep|waitfor\s+delay)\b\s*\(", re.I),
|
|
85
|
+
"SLEEP/pg_sleep/WAITFOR DELAY (time-based blind)", 0.98),
|
|
86
|
+
(re.compile(r"\bbenchmark\s*\(", re.I), "BENCHMARK() MySQL (time/DoS)", 0.9),
|
|
87
|
+
|
|
88
|
+
(re.compile(r"\b(updatexml|extractvalue|xmltype|utl_http\.request|dbms_xmlquery)\b\s*\(", re.I),
|
|
89
|
+
"Funciones que devuelven errores con contenido (error-based)", 0.95),
|
|
90
|
+
(re.compile(r"\bconvert\(\s*.*\s+using\s+.*\)", re.I), "CONVERT ... USING (encoding conversions potenciales)", 0.7),
|
|
91
|
+
|
|
92
|
+
(re.compile(r"\b(nslookup|dnslookup|xp_dirtree|xp_dirtree\(|xp_regread|xp\w+)\b", re.I),
|
|
93
|
+
"Funciones/procs que pueden generar exfiltración OOB (DNS/SMB callbacks)", 0.95),
|
|
94
|
+
(re.compile(r"\b(utl_http\.request|utl_tcp\.socket|http_client|apex_web_service\.make_rest_request)\b", re.I),
|
|
95
|
+
"UTL_HTTP/HTTP callbacks (Oracle/PLSQL HTTP OOB)", 0.95),
|
|
96
|
+
|
|
97
|
+
(re.compile(r"\bxp_cmdshell\b|\bexec\s+xp\w+|\bsp_oacreate\b", re.I), "xp_cmdshell / sp_oacreate (ejecución OS MSSQL/Oracle)", 0.98),
|
|
98
|
+
(re.compile(r"\b(exec\s+master\..*xp\w+|sp_executesql|execute\s+immediate|EXEC\s+UTE)\b", re.I), "Ejecución dinámica / sp_executesql / EXECUTE IMMEDIATE", 0.95),
|
|
99
|
+
|
|
100
|
+
(re.compile(r"\binformation_schema\b", re.I), "INFORMATION_SCHEMA (recon meta-datos)", 0.92),
|
|
101
|
+
(re.compile(r"\b(information_schema\.tables|information_schema\.columns)\b", re.I), "INFORMATION_SCHEMA.tables/columns", 0.92),
|
|
102
|
+
(re.compile(r"\b(sys\.tables|sys\.objects|sys\.databases|pg_catalog|pg_tables|pg_user)\b", re.I), "Catálogos del sistema (MSSQL/Postgres)", 0.9),
|
|
103
|
+
|
|
104
|
+
(re.compile(r"\b(drop\s+table|truncate\s+table|drop\s+database|drop\s+schema)\b", re.I), "DROP/TRUNCATE (DDL destructivo)", 0.95),
|
|
105
|
+
(re.compile(r"\b(delete\s+from|update\s+.+\s+set|insert\s+into)\b", re.I), "DML (DELETE/UPDATE/INSERT potencialmente destructivo)", 0.85),
|
|
106
|
+
|
|
107
|
+
(re.compile(r";\s*(select|insert|update|delete|drop|create|truncate)\b", re.I), "Stacked queries (uso de ';' para apilar)", 0.88),
|
|
108
|
+
|
|
109
|
+
(re.compile(r"\b(or|and)\b\s+(['\"]?\d+['\"]?)\s*=\s*\1", re.I), "Tautología OR/AND 'x'='x' o 1=1", 0.85),
|
|
110
|
+
(re.compile(r"(['\"]).{0,10}\1\s*or\s*['\"][^']*['\"]\s*=\s*['\"][^']*['\"]", re.I), "Tautología clásica en cadenas (OR '1'='1')", 0.8),
|
|
111
|
+
|
|
112
|
+
(re.compile(r"\b(substring|substr|mid|left|right)\b\s*\(", re.I), "SUBSTRING/SUBSTR/LEFT/RIGHT (blind extraction)", 0.82),
|
|
113
|
+
(re.compile(r"\b(ascii|char|chr|nchr)\b\s*\(", re.I), "ASCII/CHAR/CHR (byte/char extraction)", 0.8),
|
|
114
|
+
|
|
115
|
+
(re.compile(r"\b(updatexml|extractvalue|xmltype|xmlelement)\b\s*\(", re.I), "updatexml/extractvalue/xmltype (error/XPath leaks)", 0.93),
|
|
116
|
+
|
|
117
|
+
(re.compile(r"\binto\s+outfile\b|\binto\s+dumpfile\b", re.I), "INTO OUTFILE / DUMPFILE (escritura en servidor)", 0.97),
|
|
118
|
+
(re.compile(r"\bopenrowset\b|\bbulk\s+insert\b|\bcopy\s+to\b", re.I), "OPENROWSET / BULK INSERT / COPY TO (exportación)", 0.92),
|
|
119
|
+
|
|
120
|
+
(re.compile(r"0x[0-9a-fA-F]+", re.I), "Hex literal (0x...) (ofuscación)", 0.6),
|
|
121
|
+
(re.compile(r"\\x[0-9a-fA-F]{2}", re.I), "Escapes hex tipo \\xNN (ofuscación)", 0.6),
|
|
122
|
+
(re.compile(r"&#x[0-9a-fA-F]+;|&#\d+;", re.I), "Entidades HTML / entidades numéricas (ofuscación)", 0.6),
|
|
123
|
+
(re.compile(r"\bchar\s*\(\s*\d+\s*\)", re.I), "CHAR(n) usado para construir cadenas (ofuscación)", 0.65),
|
|
124
|
+
(re.compile(r"\bconcat\(", re.I), "CONCAT() (construcción dinámica de strings)", 0.6),
|
|
125
|
+
|
|
126
|
+
(re.compile(r"%3[dD]|%27|%22|%3C|%3E|%3B", re.I), "URL encoding típico (%27, %3C, etc.)", 0.4),
|
|
127
|
+
|
|
128
|
+
(re.compile(r"(--\s|#\s|/\*[\s\S]*\*/)", re.I), "Comentarios SQL (--) o /* */ o #", 0.45),
|
|
129
|
+
|
|
130
|
+
(re.compile(r"\b\$where\b|\b\$ne\b|\b\$regex\b", re.I), "NoSQL / MongoDB indicators ($where/$ne/$regex)", 0.5),
|
|
131
|
+
|
|
132
|
+
(re.compile(r"sqlmap", re.I), "Indicador de herramienta sqlmap en payload", 0.5),
|
|
133
|
+
(re.compile(r"hydra|nmap|nikto", re.I), "Indicador de herramientas de auditoría/scan", 0.3),
|
|
134
|
+
|
|
135
|
+
(re.compile(r"\bexecute\b\s*\(", re.I), "execute(...) (ejecución dinámica)", 0.7),
|
|
136
|
+
(re.compile(r"\bdeclare\b\s+@?\w+", re.I), "DECLARE variable (MSSQL/PLSQL declarations)", 0.7),
|
|
137
|
+
|
|
138
|
+
(re.compile(r"\bselect\b\s+.*\bfrom\b", re.I), "Estructura SELECT FROM (heurístico)", 0.25),
|
|
139
|
+
(re.compile(r"\binsert\b\s+into\b", re.I), "INSERT INTO (heurístico)", 0.3),
|
|
140
|
+
|
|
141
|
+
(re.compile(r"(['\"]).*?;\s*(drop|truncate|delete|update|insert)\b", re.I | re.S), "Cadena con terminador y DDL/DML (potencial ataque)", 0.9),
|
|
142
|
+
(re.compile(r"\b(or)\b\s+1\s*=\s*1\b", re.I), "OR 1=1 tautology", 0.85),
|
|
143
|
+
|
|
144
|
+
(re.compile(r"\bselect\s+.*\s+from\s+.*\s+where\s+.*\s+in\s*\(.*\)", re.I | re.S), "Subquery anidada (IN subquery)", 0.75),
|
|
145
|
+
|
|
146
|
+
(re.compile(r"\bcase\s+when\s+.*\s+then\s+.*\s+else\b", re.I), "CASE WHEN (blind boolean)", 0.78),
|
|
147
|
+
(re.compile(r"/\*!.+\*/", re.I), "Comentarios condicionales MySQL (/*!...*/)", 0.7),
|
|
148
|
+
(re.compile(r"\bif\s*\(\s*.*\s*,\s*.*\s*,\s*.*\s*\)", re.I), "IF() MySQL (conditional)", 0.72),
|
|
149
|
+
(re.compile(r"\bgroup_concat\s*\(", re.I), "GROUP_CONCAT() (exfiltración en error)", 0.8),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
SENSITIVE_FIELDS = ["password", "csrfmiddlewaretoken", "token", "auth", "email", "username"]
|
|
153
|
+
DEFAULT_THRESHOLDS = getattr(settings, "SQLI_DEFENSE_THRESHOLDS", {"HIGH": 1.8, "MEDIUM": 1.0, "LOW": 0.5})
|
|
154
|
+
BLOCK_TIMEOUT = getattr(settings, "SQLI_DEFENSE_BLOCK_SECONDS", 60 * 60)
|
|
155
|
+
COUNTER_WINDOW = getattr(settings, "SQLI_DEFENSE_COUNTER_WINDOW", 60 * 5)
|
|
156
|
+
COUNTER_THRESHOLD = getattr(settings, "SQLI_DEFENSE_COUNTER_THRESHOLD", 5)
|
|
157
|
+
CACHE_BLOCK_KEY_PREFIX = "sqli_block:"
|
|
158
|
+
CACHE_COUNTER_KEY_PREFIX = "sqli_count:"
|
|
159
|
+
SATURATION_C = getattr(settings, "SQLI_DEFENSE_SATURATION_C", 1.5)
|
|
160
|
+
SATURATION_ALPHA = getattr(settings, "SQLI_DEFENSE_SATURATION_ALPHA", 2.0)
|
|
161
|
+
NORM_THRESHOLDS = {
|
|
162
|
+
"HIGH": getattr(settings, "SQLI_DEFENSE_NORM_HIGH", 0.2),
|
|
163
|
+
"MEDIUM": getattr(settings, "SQLI_DEFENSE_NORM_MED", 0.1),
|
|
164
|
+
"LOW": getattr(settings, "SQLI_DEFENSE_NORM_LOW", 0.05),
|
|
165
|
+
}
|
|
166
|
+
PROB_LAMBDA = getattr(settings, "SQLI_DEFENSE_PROB_LAMBDA", 1.0)
|
|
167
|
+
FIELD_WEIGHTS = getattr(settings, "SQLI_DEFENSE_FIELD_WEIGHTS", {"_query_string": 1.2, "username": 0.6, "password": 1.8, "raw": 1.0})
|
|
168
|
+
DEFAULT_BACKOFF_LEVELS = getattr(settings, "SQLI_DEFENSE_BACKOFF_LEVELS", [0, 60 * 15, 60 * 60, 60 * 60 * 6, 60 * 60 * 24, 60 * 60 * 24 * 7])
|
|
169
|
+
|
|
170
|
+
# -------------------------------------------------------
|
|
171
|
+
# Funciones criptográficas: derivación, AEAD, HMAC, util
|
|
172
|
+
# -------------------------------------------------------
|
|
173
|
+
def derive_key(label: bytes, context: bytes = b"") -> bytes:
|
|
174
|
+
"""
|
|
175
|
+
Deriva una clave simétrica (32 bytes) a partir de MASTER_KEY usando Argon2 raw hash,
|
|
176
|
+
y luego HKDF para estandarizar. Esto permite rotación si cambias MASTER_KEY.
|
|
177
|
+
"""
|
|
178
|
+
# Argon2 raw derivation (sal pseudo-rand: label + timestamp)
|
|
179
|
+
salt = (label + context)[:16].ljust(16, b"\0") # determinista por label/context
|
|
180
|
+
try:
|
|
181
|
+
raw = hash_secret_raw(secret=MASTER_KEY if isinstance(MASTER_KEY, (bytes, bytearray)) else MASTER_KEY.encode(),
|
|
182
|
+
salt=salt,
|
|
183
|
+
time_cost=ARGON2_CONFIG["time_cost"],
|
|
184
|
+
memory_cost=ARGON2_CONFIG["memory_cost"],
|
|
185
|
+
parallelism=ARGON2_CONFIG["parallelism"],
|
|
186
|
+
hash_len=ARGON2_CONFIG["hash_len"],
|
|
187
|
+
type=ARGON2_CONFIG["type"])
|
|
188
|
+
# pulir con HKDF para obtener 32 bytes de alta calidad
|
|
189
|
+
hk = HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=label + context)
|
|
190
|
+
key = hk.derive(raw)
|
|
191
|
+
return key
|
|
192
|
+
except Exception:
|
|
193
|
+
# fallback seguro simple: HKDF desde MASTER_KEY
|
|
194
|
+
hk = HKDF(algorithm=hashes.SHA256(), length=32, salt=salt, info=label + context)
|
|
195
|
+
return hk.derive(MASTER_KEY if isinstance(MASTER_KEY, bytes) else MASTER_KEY.encode())
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def aead_encrypt(plaintext: bytes, aad: bytes = b"", context: bytes = b"") -> Dict[str, bytes]:
|
|
199
|
+
"""
|
|
200
|
+
Cifra con AEAD configurado (AES-GCM o ChaCha20-Poly1305).
|
|
201
|
+
Retorna dict con: ciphertext, nonce, tag (si aplica), alg
|
|
202
|
+
"""
|
|
203
|
+
key = derive_key(AEAD_LABEL, context)
|
|
204
|
+
if AEAD_CHOICE == "CHACHA20":
|
|
205
|
+
aead = ChaCha20Poly1305(key)
|
|
206
|
+
nonce = os.urandom(12)
|
|
207
|
+
ct = aead.encrypt(nonce, plaintext, aad)
|
|
208
|
+
# ChaCha20Poly1305 devuelve ciphertext+tag juntos
|
|
209
|
+
return {"alg": "CHACHA20-POLY1305", "nonce": nonce, "ciphertext": ct}
|
|
210
|
+
else:
|
|
211
|
+
# AES-GCM (recomendado)
|
|
212
|
+
aead = AESGCM(key)
|
|
213
|
+
nonce = os.urandom(12)
|
|
214
|
+
ct = aead.encrypt(nonce, plaintext, aad)
|
|
215
|
+
# AESGCM devuelve ciphertext||tag (16 bytes tag al final)
|
|
216
|
+
return {"alg": "AES-GCM", "nonce": nonce, "ciphertext": ct}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def aead_decrypt(payload: Dict[str, bytes], aad: bytes = b"", context: bytes = b"") -> bytes:
|
|
220
|
+
key = derive_key(AEAD_LABEL, context)
|
|
221
|
+
alg = payload.get("alg", "AES-GCM")
|
|
222
|
+
nonce = payload.get("nonce")
|
|
223
|
+
ct = payload.get("ciphertext")
|
|
224
|
+
if not nonce or not ct:
|
|
225
|
+
raise ValueError("invalid payload for AEAD decrypt")
|
|
226
|
+
if alg.startswith("CHACHA20"):
|
|
227
|
+
aead = ChaCha20Poly1305(key)
|
|
228
|
+
return aead.decrypt(nonce, ct, aad)
|
|
229
|
+
else:
|
|
230
|
+
aead = AESGCM(key)
|
|
231
|
+
return aead.decrypt(nonce, ct, aad)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def compute_hmac(data: bytes, context: bytes = b"") -> bytes:
|
|
235
|
+
key = derive_key(HMAC_LABEL, context)
|
|
236
|
+
h = crypto_hmac.HMAC(key, hashes.SHA256())
|
|
237
|
+
h.update(data)
|
|
238
|
+
return h.finalize()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def verify_hmac(data: bytes, tag: bytes, context: bytes = b"") -> bool:
|
|
242
|
+
key = derive_key(HMAC_LABEL, context)
|
|
243
|
+
h = crypto_hmac.HMAC(key, hashes.SHA256())
|
|
244
|
+
h.update(data)
|
|
245
|
+
try:
|
|
246
|
+
h.verify(tag)
|
|
247
|
+
return True
|
|
248
|
+
except InvalidSignature:
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
# -------------------------
|
|
252
|
+
# Helpers utilitarios
|
|
253
|
+
# -------------------------
|
|
254
|
+
def get_client_ip(request) -> str:
|
|
255
|
+
trusted_proxies = getattr(settings, "SQLI_DEFENSE_TRUSTED_PROXIES", [])
|
|
256
|
+
|
|
257
|
+
def ip_in_trusted(ip_str: str) -> bool:
|
|
258
|
+
try:
|
|
259
|
+
ip_obj = ipaddress.ip_address(ip_str)
|
|
260
|
+
except Exception:
|
|
261
|
+
return False
|
|
262
|
+
for p in trusted_proxies:
|
|
263
|
+
try:
|
|
264
|
+
if '/' in p:
|
|
265
|
+
if ip_obj in ipaddress.ip_network(p, strict=False):
|
|
266
|
+
return True
|
|
267
|
+
else:
|
|
268
|
+
if ip_obj == ipaddress.ip_address(p):
|
|
269
|
+
return True
|
|
270
|
+
except Exception:
|
|
271
|
+
continue
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
xff = request.META.get("HTTP_X_FORWARDED_FOR", "")
|
|
275
|
+
if xff:
|
|
276
|
+
parts = [p.strip() for p in xff.split(",") if p.strip()]
|
|
277
|
+
for ip_candidate in parts:
|
|
278
|
+
try:
|
|
279
|
+
ipaddress.ip_address(ip_candidate)
|
|
280
|
+
except Exception:
|
|
281
|
+
continue
|
|
282
|
+
if not ip_in_trusted(ip_candidate):
|
|
283
|
+
return ip_candidate
|
|
284
|
+
if parts:
|
|
285
|
+
return parts[-1]
|
|
286
|
+
|
|
287
|
+
xr = request.META.get("HTTP_X_REAL_IP", "")
|
|
288
|
+
if xr:
|
|
289
|
+
try:
|
|
290
|
+
ipaddress.ip_address(xr)
|
|
291
|
+
if not ip_in_trusted(xr):
|
|
292
|
+
return xr
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
hcip = request.META.get("HTTP_CLIENT_IP", "")
|
|
297
|
+
if hcip:
|
|
298
|
+
try:
|
|
299
|
+
ipaddress.ip_address(hcip)
|
|
300
|
+
return hcip
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
remote = request.META.get("REMOTE_ADDR", "")
|
|
305
|
+
return remote or ""
|
|
306
|
+
|
|
307
|
+
def normalize_input(s: str) -> str:
|
|
308
|
+
if not s:
|
|
309
|
+
return ""
|
|
310
|
+
try:
|
|
311
|
+
s_dec = urllib.parse.unquote_plus(s)
|
|
312
|
+
except Exception:
|
|
313
|
+
s_dec = s
|
|
314
|
+
try:
|
|
315
|
+
s_dec = html.unescape(s_dec)
|
|
316
|
+
except Exception:
|
|
317
|
+
pass
|
|
318
|
+
s_dec = re.sub(r"\\x([0-9a-fA-F]{2})", r"\\x\g<1>", s_dec)
|
|
319
|
+
s_dec = re.sub(r"\s+", " ", s_dec)
|
|
320
|
+
return s_dec.strip()
|
|
321
|
+
|
|
322
|
+
def weight_to_prob(w: float) -> float:
|
|
323
|
+
try:
|
|
324
|
+
lam = float(PROB_LAMBDA)
|
|
325
|
+
q = 1.0 - math.exp(-max(float(w), 0.0) / lam)
|
|
326
|
+
return min(max(q, 0.0), 0.999999)
|
|
327
|
+
except Exception:
|
|
328
|
+
return min(max(w, 0.0), 0.999999)
|
|
329
|
+
|
|
330
|
+
def combine_probs(qs: List[float]) -> float:
|
|
331
|
+
prod = 1.0
|
|
332
|
+
for q in qs:
|
|
333
|
+
prod *= (1.0 - q)
|
|
334
|
+
return 1.0 - prod
|
|
335
|
+
|
|
336
|
+
def saturate_score(raw_score: float) -> float:
|
|
337
|
+
try:
|
|
338
|
+
x = float(raw_score)
|
|
339
|
+
alpha = float(SATURATION_ALPHA)
|
|
340
|
+
c = float(SATURATION_C)
|
|
341
|
+
return 1.0 / (1.0 + math.exp(-alpha * (x - c)))
|
|
342
|
+
except Exception:
|
|
343
|
+
return 0.0
|
|
344
|
+
|
|
345
|
+
# Reusa tu detector (simplificado aquí; pega tu versión completa)
|
|
346
|
+
def detect_sql_injection(text: str) -> Dict:
|
|
347
|
+
norm = normalize_input(text or "")
|
|
348
|
+
score = 0.0
|
|
349
|
+
matches = []
|
|
350
|
+
pattern_occurrences = {}
|
|
351
|
+
for pattern, desc, weight in SQL_PATTERNS:
|
|
352
|
+
for _ in pattern.finditer(norm):
|
|
353
|
+
pattern_occurrences[pattern.pattern] = pattern_occurrences.get(pattern.pattern, 0) + 1
|
|
354
|
+
prob_list = []
|
|
355
|
+
for pattern, desc, weight in SQL_PATTERNS:
|
|
356
|
+
occ = pattern_occurrences.get(pattern.pattern, 0)
|
|
357
|
+
if occ > 0:
|
|
358
|
+
added = 0.0
|
|
359
|
+
for i in range(occ):
|
|
360
|
+
added += weight * (0.5 ** i)
|
|
361
|
+
score += added
|
|
362
|
+
matches.append((desc, pattern.pattern, weight, occ, round(added, 3)))
|
|
363
|
+
q = weight_to_prob(added)
|
|
364
|
+
prob_list.append(q)
|
|
365
|
+
return {
|
|
366
|
+
"score": round(score, 3),
|
|
367
|
+
"matches": matches,
|
|
368
|
+
"descriptions": list({m[0] for m in matches}),
|
|
369
|
+
"sample": norm[:1200],
|
|
370
|
+
"prob_list": prob_list,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
# Redactar payload summary (ahora encripta snippets antes de loggear)
|
|
374
|
+
def redact_and_encrypt_payload(payload_summary: List[Dict[str, Any]], context: bytes = b"") -> List[Dict[str, Any]]:
|
|
375
|
+
encrypted_list = []
|
|
376
|
+
for p in payload_summary:
|
|
377
|
+
snippet = p.get("snippet", "")
|
|
378
|
+
is_sensitive = p.get("sensitive", False)
|
|
379
|
+
# Decide: si sensible -> cifrar, si no -> truncar + cifrar si score alto
|
|
380
|
+
try:
|
|
381
|
+
enc = aead_encrypt(snippet.encode("utf-8"), aad=b"", context=context)
|
|
382
|
+
htag = compute_hmac(enc["ciphertext"], context=context)
|
|
383
|
+
enc_b64 = {
|
|
384
|
+
"alg": enc["alg"],
|
|
385
|
+
"nonce": base64.b64encode(enc["nonce"]).decode(),
|
|
386
|
+
"ciphertext": base64.b64encode(enc["ciphertext"]).decode(),
|
|
387
|
+
"hmac": base64.b64encode(htag).decode(),
|
|
388
|
+
}
|
|
389
|
+
encrypted_list.append({"field": p.get("field"), "encrypted": enc_b64, "sensitive": is_sensitive})
|
|
390
|
+
|
|
391
|
+
except Exception:
|
|
392
|
+
# fallback: redact
|
|
393
|
+
encrypted_list.append({"field": p.get("field"), "snippet": "<REDACTED>", "sensitive": is_sensitive})
|
|
394
|
+
return encrypted_list
|
|
395
|
+
|
|
396
|
+
# Cache helpers (igual que antes)
|
|
397
|
+
def cache_block_ip_with_backoff(ip: str):
|
|
398
|
+
if not ip:
|
|
399
|
+
return 0, 0
|
|
400
|
+
level_key = f"{CACHE_BLOCK_KEY_PREFIX}{ip}:level"
|
|
401
|
+
level = cache.get(level_key, 0) or 0
|
|
402
|
+
level = int(level) + 1
|
|
403
|
+
cache.set(level_key, level, timeout=60 * 60 * 24 * 7)
|
|
404
|
+
durations = DEFAULT_BACKOFF_LEVELS
|
|
405
|
+
idx = min(level, len(durations) - 1)
|
|
406
|
+
timeout = durations[idx]
|
|
407
|
+
cache.set(f"{CACHE_BLOCK_KEY_PREFIX}{ip}", True, timeout=timeout)
|
|
408
|
+
return level, timeout
|
|
409
|
+
|
|
410
|
+
def is_ip_blocked(ip: str) -> bool:
|
|
411
|
+
if not ip:
|
|
412
|
+
return False
|
|
413
|
+
return bool(cache.get(f"{CACHE_BLOCK_KEY_PREFIX}{ip}"))
|
|
414
|
+
|
|
415
|
+
def incr_ip_counter(ip: str) -> int:
|
|
416
|
+
if not ip:
|
|
417
|
+
return 0
|
|
418
|
+
key = f"{CACHE_COUNTER_KEY_PREFIX}{ip}"
|
|
419
|
+
current = cache.get(key, 0)
|
|
420
|
+
try:
|
|
421
|
+
current = int(current)
|
|
422
|
+
except Exception:
|
|
423
|
+
current = 0
|
|
424
|
+
current += 1
|
|
425
|
+
cache.set(key, current, timeout=COUNTER_WINDOW)
|
|
426
|
+
return current
|
|
427
|
+
|
|
428
|
+
def record_detection_event(event: dict) -> None:
|
|
429
|
+
try:
|
|
430
|
+
ts = int(time.time())
|
|
431
|
+
# cifrar payload si existe
|
|
432
|
+
if "payload" in event and event["payload"]:
|
|
433
|
+
try:
|
|
434
|
+
ctx = f"{event.get('ip','')}-{ts}".encode()
|
|
435
|
+
enc = aead_encrypt(json.dumps(event["payload"], ensure_ascii=False).encode("utf-8"), context=ctx)
|
|
436
|
+
htag = compute_hmac(enc["ciphertext"], context=ctx)
|
|
437
|
+
event["_payload_encrypted"] = {
|
|
438
|
+
"alg": enc["alg"],
|
|
439
|
+
"nonce": base64.b64encode(enc["nonce"]).decode(),
|
|
440
|
+
"ciphertext": base64.b64encode(enc["ciphertext"]).decode(),
|
|
441
|
+
"hmac": base64.b64encode(htag).decode(),
|
|
442
|
+
}
|
|
443
|
+
del event["payload"] # no almacenar plaintext
|
|
444
|
+
except Exception:
|
|
445
|
+
# si falla, simplemente no incluimos payload
|
|
446
|
+
event.pop("payload", None)
|
|
447
|
+
key = f"sqli_event:{ts}:{event.get('ip', '')}"
|
|
448
|
+
cache.set(key, json.dumps(event, ensure_ascii=False), timeout=60 * 60 * 24)
|
|
449
|
+
except Exception:
|
|
450
|
+
logger.exception("record_detection_event failed")
|
|
451
|
+
|
|
452
|
+
# --------------------------
|
|
453
|
+
# Middleware principal
|
|
454
|
+
# --------------------------
|
|
455
|
+
class SQLIDefenseCryptoMiddleware(MiddlewareMixin):
|
|
456
|
+
def process_request(self, request):
|
|
457
|
+
client_ip = get_client_ip(request)
|
|
458
|
+
|
|
459
|
+
# Chequear bloqueo
|
|
460
|
+
if is_ip_blocked(client_ip):
|
|
461
|
+
warning_message = (
|
|
462
|
+
"Acceso denegado. Su dirección IP y actividades han sido registradas y monitoreadas. "
|
|
463
|
+
"Continuar con estos intentos podría resultar en exposición pública, bloqueos permanentes o acciones legales. "
|
|
464
|
+
"Recomendamos detenerse inmediatamente para evitar riesgos mayores."
|
|
465
|
+
)
|
|
466
|
+
logger.warning(f"[SQLiBlock:Persistent] IP={client_ip} - Intento persistente de acceso bloqueado. Mensaje enviado.")
|
|
467
|
+
return HttpResponseForbidden(warning_message)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
trusted_ips = getattr(settings, "SQLI_DEFENSE_TRUSTED_IPS", [])
|
|
471
|
+
if client_ip and client_ip in trusted_ips:
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
trusted_urls = getattr(settings, "SQLI_DEFENSE_TRUSTED_URLS", [])
|
|
475
|
+
referer = request.META.get("HTTP_REFERER", "")
|
|
476
|
+
host = request.get_host()
|
|
477
|
+
if any(url in referer for url in trusted_urls) or any(url in host for url in trusted_urls):
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
# Extraer payload
|
|
481
|
+
data = {}
|
|
482
|
+
try:
|
|
483
|
+
ct = request.META.get("CONTENT_TYPE", "")
|
|
484
|
+
if "application/json" in ct:
|
|
485
|
+
raw = request.body.decode("utf-8") or "{}"
|
|
486
|
+
try:
|
|
487
|
+
parsed = json.loads(raw)
|
|
488
|
+
if isinstance(parsed, dict):
|
|
489
|
+
data = parsed
|
|
490
|
+
else:
|
|
491
|
+
data = {"raw": raw}
|
|
492
|
+
except Exception:
|
|
493
|
+
data = {"raw": raw}
|
|
494
|
+
else:
|
|
495
|
+
try:
|
|
496
|
+
post = request.POST.dict()
|
|
497
|
+
if post:
|
|
498
|
+
data = post
|
|
499
|
+
else:
|
|
500
|
+
raw = request.body.decode("utf-8", errors="ignore")
|
|
501
|
+
data = {"raw": raw} if raw else {}
|
|
502
|
+
except Exception:
|
|
503
|
+
raw = request.body.decode("utf-8", errors="ignore")
|
|
504
|
+
data = {"raw": raw} if raw else {}
|
|
505
|
+
except Exception:
|
|
506
|
+
data = {}
|
|
507
|
+
|
|
508
|
+
qs = request.META.get("QUERY_STRING", "")
|
|
509
|
+
if qs:
|
|
510
|
+
if isinstance(data, dict):
|
|
511
|
+
data["_query_string"] = qs
|
|
512
|
+
else:
|
|
513
|
+
data = {"_query_string": qs, "raw": str(data)}
|
|
514
|
+
|
|
515
|
+
if not data:
|
|
516
|
+
return None
|
|
517
|
+
|
|
518
|
+
# Detectar SQLi por campo
|
|
519
|
+
total_score = 0.0
|
|
520
|
+
all_descriptions = []
|
|
521
|
+
payload_summary = []
|
|
522
|
+
global_prob_list = []
|
|
523
|
+
|
|
524
|
+
if isinstance(data, dict):
|
|
525
|
+
for key, value in data.items():
|
|
526
|
+
if isinstance(value, (dict, list)):
|
|
527
|
+
try:
|
|
528
|
+
vtext = json.dumps(value, ensure_ascii=False)
|
|
529
|
+
except Exception:
|
|
530
|
+
vtext = str(value)
|
|
531
|
+
else:
|
|
532
|
+
vtext = str(value or "")
|
|
533
|
+
|
|
534
|
+
result = detect_sql_injection(vtext)
|
|
535
|
+
field_weight = FIELD_WEIGHTS.get(str(key), 1.0)
|
|
536
|
+
added_score = result.get("score", 0.0) * float(field_weight)
|
|
537
|
+
total_score += added_score
|
|
538
|
+
for q in result.get("prob_list", []):
|
|
539
|
+
q_field = 1.0 - ((1.0 - q) ** float(field_weight))
|
|
540
|
+
global_prob_list.append(q_field)
|
|
541
|
+
all_descriptions.extend(result.get("descriptions", []))
|
|
542
|
+
if result.get("score", 0) > 0:
|
|
543
|
+
is_sensitive = isinstance(key, str) and key.lower() in SENSITIVE_FIELDS
|
|
544
|
+
payload_summary.append({"field": key, "snippet": vtext[:300], "sensitive": is_sensitive})
|
|
545
|
+
else:
|
|
546
|
+
raw = str(data)
|
|
547
|
+
result = detect_sql_injection(raw)
|
|
548
|
+
total_score += result.get("score", 0.0)
|
|
549
|
+
all_descriptions.extend(result.get("descriptions", []))
|
|
550
|
+
for q in result.get("prob_list", []):
|
|
551
|
+
global_prob_list.append(q)
|
|
552
|
+
if result.get("score", 0) > 0:
|
|
553
|
+
payload_summary.append({"field": "raw", "snippet": raw[:500], "sensitive": False})
|
|
554
|
+
|
|
555
|
+
if total_score == 0 and not global_prob_list:
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
# normalización y probabilidad combinada
|
|
559
|
+
p_attack = combine_probs(global_prob_list) if global_prob_list else 0.0
|
|
560
|
+
s_norm = saturate_score(total_score)
|
|
561
|
+
|
|
562
|
+
# Encriptar / redactar payload summaries antes de loggear/almacenar
|
|
563
|
+
ctx = f"{client_ip}-{int(time.time())}".encode()
|
|
564
|
+
try:
|
|
565
|
+
encrypted_payload = redact_and_encrypt_payload(payload_summary, context=ctx)
|
|
566
|
+
except Exception:
|
|
567
|
+
encrypted_payload = [{"field": p.get("field"), "snippet": "<REDACTED>", "sensitive": p.get("sensitive", False)} for p in payload_summary]
|
|
568
|
+
|
|
569
|
+
logger.warning(
|
|
570
|
+
f"[SQLiDetect] IP={client_ip} Host={host} Score={total_score:.2f} S_norm={s_norm:.3f} P_attack={p_attack:.3f} Desc={all_descriptions} Payload_enc_snippets={json.dumps(encrypted_payload)[:1000]}"
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
request.sql_attack_info = {
|
|
574
|
+
"ip": client_ip,
|
|
575
|
+
"tipos": ["SQLi"],
|
|
576
|
+
"descripcion": all_descriptions,
|
|
577
|
+
"payload": json.dumps(encrypted_payload, ensure_ascii=False)[:2000],
|
|
578
|
+
"score": round(total_score, 3),
|
|
579
|
+
"s_norm": round(s_norm, 3),
|
|
580
|
+
"p_attack": round(p_attack, 3),
|
|
581
|
+
"url": request.build_absolute_uri(),
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
# registrar evento cifrado
|
|
585
|
+
try:
|
|
586
|
+
record_detection_event({
|
|
587
|
+
"ts": int(time.time()),
|
|
588
|
+
"ip": client_ip,
|
|
589
|
+
"score": total_score,
|
|
590
|
+
"s_norm": s_norm,
|
|
591
|
+
"p_attack": p_attack,
|
|
592
|
+
"desc": all_descriptions,
|
|
593
|
+
"url": request.build_absolute_uri(),
|
|
594
|
+
"payload": encrypted_payload, # ya viene cifrado dentro de redact_and_encrypt_payload
|
|
595
|
+
})
|
|
596
|
+
except Exception:
|
|
597
|
+
logger.exception("failed to record event")
|
|
598
|
+
|
|
599
|
+
# Políticas de bloqueo (igual que antes, pero preferimos p_attack)
|
|
600
|
+
# Políticas de bloqueo: setea flags en lugar de retornar HttpResponseForbidden
|
|
601
|
+
if p_attack >= getattr(settings, "SQLI_DEFENSE_P_ATTACK_BLOCK", 0.97):
|
|
602
|
+
level, timeout = cache_block_ip_with_backoff(client_ip)
|
|
603
|
+
logger.error(f"[SQLiBlock:P_attack] IP={client_ip} P={p_attack:.3f} -> level={level} timeout={timeout}s")
|
|
604
|
+
request.sql_attack_info.update({"blocked": True, "action": "block_p_attack", "block_timeout": timeout, "block_level": level})
|
|
605
|
+
# Nuevo: setea flag para bloqueo en lugar de retornar
|
|
606
|
+
request.sql_block = True
|
|
607
|
+
request.sql_block_response = HttpResponseForbidden("Request blocked by SQLI defense (probability)")
|
|
608
|
+
return None # No retorna respuesta aquí
|
|
609
|
+
if s_norm >= NORM_THRESHOLDS["HIGH"]:
|
|
610
|
+
level, timeout = cache_block_ip_with_backoff(client_ip)
|
|
611
|
+
logger.error(f"[SQLiBlock] IP={client_ip} Score={total_score:.2f} S_norm={s_norm:.3f} URL={request.path}")
|
|
612
|
+
request.sql_attack_info.update({"blocked": True, "action": "block", "block_timeout": timeout, "block_level": level})
|
|
613
|
+
# Nuevo: setea flag para bloqueo
|
|
614
|
+
request.sql_block = True
|
|
615
|
+
request.sql_block_response = HttpResponseForbidden("Request blocked by SQLI defense")
|
|
616
|
+
return None
|
|
617
|
+
elif s_norm >= NORM_THRESHOLDS["MEDIUM"]:
|
|
618
|
+
logger.warning(f"[SQLiAlert] IP={client_ip} Score={total_score:.2f} S_norm={s_norm:.3f} - applying counter/challenge")
|
|
619
|
+
count = incr_ip_counter(client_ip)
|
|
620
|
+
request.sql_attack_info.update({"blocked": False, "action": "alert", "counter": count})
|
|
621
|
+
if count >= COUNTER_THRESHOLD:
|
|
622
|
+
level, timeout = cache_block_ip_with_backoff(client_ip)
|
|
623
|
+
cache.set(f"{CACHE_COUNTER_KEY_PREFIX}{client_ip}", 0, timeout=COUNTER_WINDOW)
|
|
624
|
+
logger.error(f"[SQLiAutoBlock] IP={client_ip} reached counter={count} -> blocking for {timeout}s")
|
|
625
|
+
request.sql_attack_info.update({"blocked": True, "action": "auto_block", "block_timeout": timeout, "block_level": level})
|
|
626
|
+
# Nuevo: setea flag para bloqueo
|
|
627
|
+
request.sql_block = True
|
|
628
|
+
request.sql_block_response = HttpResponseForbidden("Request blocked by SQLI defense (auto block)")
|
|
629
|
+
return None
|
|
630
|
+
if getattr(settings, "SQLI_DEFENSE_USE_CHALLENGE", False):
|
|
631
|
+
# Nuevo: setea flag para challenge
|
|
632
|
+
request.sql_challenge = True
|
|
633
|
+
request.sql_challenge_response = HttpResponse("Challenge required", status=403)
|
|
634
|
+
request.sql_challenge_response["X-SQLI-Challenge"] = "captcha"
|
|
635
|
+
return None
|
|
636
|
+
return None
|
|
637
|
+
elif s_norm >= NORM_THRESHOLDS["LOW"]:
|
|
638
|
+
logger.info(f"[SQLiMonitor] IP={client_ip} Score={total_score:.2f} S_norm={s_norm:.3f} - monitored")
|
|
639
|
+
request.sql_attack_info.update({"blocked": False, "action": "monitor"})
|
|
640
|
+
return None
|
|
641
|
+
return None
|