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.
@@ -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