wafaHell 1.0.0__py3-none-any.whl → 1.1.0__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.
wafaHell/app.py CHANGED
@@ -1,4 +1,4 @@
1
- from middleware import WafaHell
1
+ from middleware import Wafahell
2
2
  from flask import Flask, render_template, request
3
3
  from werkzeug.middleware.proxy_fix import ProxyFix
4
4
 
@@ -27,5 +27,5 @@ def dashboard():
27
27
  return "<h1>Dashboard Personalizado</h1><p>Este é o painel de controle personalizado.</p>"
28
28
 
29
29
  if __name__ == '__main__':
30
- WafaHell(app, dashboard_path='/hell/dashboard', block_durantion=1, rate_limit=True, block_ip=True)
31
- app.run(debug=True, host='0.0.0.0', port=5000)
30
+ Wafahell(app=app, dashboard_path='/hell/dashboard', block_durantion=15, rate_limit=True, block_ip=False)
31
+ app.run(debug=True, host='0.0.0.0', port=5001)
wafaHell/logger.py CHANGED
@@ -1,6 +1,6 @@
1
+ from .model import WafLog, get_session
1
2
  from datetime import datetime
2
3
  import logging
3
- from model import WafLog, get_session
4
4
  import re
5
5
  # Handler customizado para salvar no SQLite via SQLAlchemy
6
6
  class SQLAlchemyHandler(logging.Handler):
@@ -94,6 +94,7 @@ class Logger:
94
94
  def __init__(self, name="WAF", log_file="waf.log", level=logging.INFO):
95
95
  self.logger = logging.getLogger(name)
96
96
  self.logger.setLevel(level)
97
+ self.logger.propagate = False
97
98
 
98
99
  if not self.logger.handlers:
99
100
  formatter = logging.Formatter(
@@ -111,10 +112,10 @@ class Logger:
111
112
  file_handler.setFormatter(formatter)
112
113
  self.logger.addHandler(file_handler)
113
114
 
114
- # Handler 3: Banco de Dados (A Mágica acontece aqui)
115
- db_handler = SQLAlchemyHandler()
116
- db_handler.setLevel(logging.INFO) # Salva INFO, WARNING e acima no DB
117
- self.logger.addHandler(db_handler)
115
+ # # Handler 3: Banco de Dados (A Mágica acontece aqui)
116
+ # db_handler = SQLAlchemyHandler()
117
+ # db_handler.setLevel(logging.INFO) # Salva INFO, WARNING e acima no DB
118
+ # self.logger.addHandler(db_handler)
118
119
 
119
120
  def info(self, msg):
120
121
  self.logger.info(msg)
wafaHell/middleware.py CHANGED
@@ -1,53 +1,72 @@
1
+ from .model import Base, Blocked, WafLog, Whitelist, get_session, engine
2
+ from .logger import Logger
3
+ from .rateLimiter import RateLimiter
4
+ from .panel import setup_dashboard
5
+ from .utils import Admin, seed_default_whitelist
6
+ from .globals import waf_cache
1
7
  from datetime import datetime, timedelta, timezone
2
- import os
3
8
  import re
4
- import subprocess
5
9
  import time
6
10
  from flask import request as req, abort, g
7
11
  from urllib.parse import unquote
8
- from model import Base, Blocked, WafLog, get_session, engine
9
- from logger import Logger
10
- from rateLimiter import RateLimiter
11
12
  from sqlalchemy.exc import OperationalError
12
- from panel import setup_dashboard
13
- from utils import Admin
14
13
  from sqlalchemy import text
15
- from globals import waf_cache
14
+ import hashlib
15
+ import uuid
16
+ import socket
17
+ import ipaddress
16
18
 
17
19
  # Inicializa o RateLimiter
18
20
  limiter = RateLimiter(limit=100, window=60)
19
21
 
20
- class WafaHell:
22
+ class Wafahell:
23
+ _instance = None
24
+
25
+ def __new__(cls, *args, **kwargs):
26
+ if not cls._instance:
27
+ cls._instance = super(Wafahell, cls).__new__(cls)
28
+ return cls._instance
29
+
21
30
  def __init__(self, app=None, block_code=403, block_durantion=5, block_ip=False, log_func=None, monitor_mode=False, rate_limit=False, dashboard_path=None):
22
- self.app = app
23
- self.block_code = block_code
24
- self.log = log_func or Logger()
25
- self.monitor_mode = monitor_mode
26
- self.block_ip = block_ip
27
- self.rate_limit = rate_limit
28
- self.dashboard_path = dashboard_path
29
- self.block_durantion = block_durantion
30
- self.recent_blocks_cache = {}
31
-
32
- self.rules_sqli = [
33
- r"(\bUNION\b|\bSELECT\b|\bINSERT\b|\bDROP\b)",
34
- r"' OR '1'='1"
35
- ]
36
- self.rules_xss = [
37
- r"<script.*?>.*?</script>",
38
- r"javascript:"
39
- ]
40
-
41
- if app is not None:
42
- self.init_app(app)
31
+ if not hasattr(self, 'initialized'):
32
+ self.initialized = True
33
+
34
+ self.app = app
35
+ self.block_code = block_code
36
+ self.log = log_func or Logger()
37
+ self.monitor_mode = monitor_mode
38
+ self.block_ip = block_ip
39
+ self.rate_limit = rate_limit
40
+ self.dashboard_path = dashboard_path
41
+ self.block_durantion = block_durantion
42
+ self.recent_blocks_cache = {}
43
+
44
+ self.rules_sqli = [
45
+ r"(\bUNION\b|\bSELECT\b|\bINSERT\b|\bDROP\b)",
46
+ r"' OR '1'='1"
47
+ ]
48
+ self.rules_xss = [
49
+ r"<script.*?>.*?</script>",
50
+ r"javascript:"
51
+ ]
52
+
53
+ if app is not None:
54
+ self.init_app(app)
43
55
 
44
56
  def init_app(self, app):
45
57
  Base.metadata.create_all(engine)
46
58
  setup_dashboard(app, self.dashboard_path)
59
+ seed_default_whitelist()
47
60
  Admin.create_admin_user(get_session())
48
61
 
49
62
  if not app.secret_key:
50
- app.secret_key = os.urandom(24)
63
+ def create_secret_key():
64
+ mac_address = str(uuid.getnode())
65
+ hostname = socket.gethostname()
66
+ project_salt = "wafahell-security-core-v1"
67
+ fingerprint = f"{mac_address}-{hostname}-{project_salt}"
68
+ return hashlib.sha256(fingerprint.encode()).hexdigest()
69
+ app.secret_key = create_secret_key()
51
70
 
52
71
  @app.before_request
53
72
  def create_session():
@@ -68,6 +87,8 @@ class WafaHell:
68
87
 
69
88
  @app.before_request
70
89
  def waf_check():
90
+ if self.check_whitelist(req):
91
+ return
71
92
  self.verify_client_blocked(req)
72
93
  self.verify_rate_limit(req)
73
94
  is_malicious, attack_local, payload, attack_type = self.is_malicious(req)
@@ -75,6 +96,8 @@ class WafaHell:
75
96
  if not is_malicious:
76
97
  return
77
98
 
99
+ self.log_attack(req, attack_type, payload, attack_local)
100
+
78
101
  if not self.monitor_mode:
79
102
  self.log.warning(self.parse_req(req, payload, attack_local, attack_type))
80
103
  self.block_ip_address(req.remote_addr, req.headers.get("User-Agent", "unknown"))
@@ -119,23 +142,91 @@ class WafaHell:
119
142
  return response
120
143
 
121
144
  def log_legit_access(self, req):
122
- session = get_session()
123
- try:
124
- new_log = WafLog(
125
- timestamp=datetime.now(timezone.utc),
126
- attack_type='INFO', # Identificador de tráfego limpo
127
- ip=req.remote_addr,
128
- path=req.path,
129
- method=req.method,
130
- level='INFO'
131
- )
132
- session.add(new_log)
133
- session.commit()
134
- except Exception as e:
135
- session.rollback()
136
- self.log.error(f"Error logging legit access: {e}")
137
- finally:
138
- session.close()
145
+ entry = {
146
+ "timestamp": datetime.now(timezone.utc),
147
+ "attack_type": 'INFO',
148
+ "ip": req.remote_addr,
149
+ "path": req.path,
150
+ "method": req.method,
151
+ "level": 'INFO',
152
+ "payload": None, # INFO não tem payload
153
+ "attack_local": None # INFO não tem local de ataque
154
+ }
155
+ # Manda para o gerenciador de lote
156
+ self._push_to_batch(entry)
157
+
158
+ def log_attack(self, req, attack_type, payload, attack_local):
159
+ # Decodifica o payload para ficar legível no banco
160
+ safe_payload = unquote(payload) if payload else "---"
161
+
162
+ entry = {
163
+ "timestamp": datetime.now(timezone.utc),
164
+ "attack_type": attack_type, # Ex: SQLI, XSS
165
+ "ip": req.remote_addr,
166
+ "path": req.path,
167
+ "method": req.method,
168
+ "level": 'WARNING',
169
+ "payload": safe_payload,
170
+ "attack_local": attack_local # Ex: URL, BODY, HEADER
171
+ }
172
+ self.log_block(req)
173
+ self._push_to_batch(entry)
174
+
175
+ def log_block(self, req):
176
+ """
177
+ Registra especificamente bloqueios de conexão (Blacklist/Manual Block).
178
+ """
179
+ entry = {
180
+ "timestamp": datetime.now(timezone.utc),
181
+ "attack_type": "IP BLOCK",
182
+ "ip": req.remote_addr,
183
+ "path": req.path,
184
+ "method": req.method,
185
+ "level": 'INFO',
186
+ "payload": "---",
187
+ "attack_local": "WAF"
188
+ }
189
+
190
+ # Envia para o mesmo lote que os ataques normais
191
+ self._push_to_batch(entry)
192
+
193
+ def _push_to_batch(self, log_entry):
194
+ """
195
+ Função central que gerencia o Buffer de Logs (Memória -> Banco).
196
+ Usada tanto por logs legítimos quanto por ataques.
197
+ """
198
+ # 1. Recupera os logs pendentes do cache global
199
+ pending_logs = waf_cache.get('pending_logs_batch', default=[])
200
+ pending_logs.append(log_entry)
201
+
202
+ # 2. Verifica Gatilhos: 50 logs OU 3 segundos (reduzi de 10 pra 3 pra ficar mais "real time")
203
+ current_time = time.time()
204
+ last_flush = waf_cache.get('last_log_flush_time', default=0)
205
+
206
+ if len(pending_logs) >= 50 or (current_time - last_flush) > 3:
207
+
208
+ # Função interna de flush (Abre sessão dedicada para o lote)
209
+ session = get_session()
210
+ try:
211
+ # bulk_insert_mappings é OTIMIZADO para grandes volumes
212
+ session.bulk_insert_mappings(WafLog, pending_logs)
213
+ session.commit()
214
+
215
+ # Sucesso: Limpa o cache
216
+ waf_cache.set('pending_logs_batch', [], expire=60)
217
+ waf_cache.set('last_log_flush_time', current_time, expire=60)
218
+ except Exception as e:
219
+ session.rollback()
220
+ # Não usamos self.log.error aqui para não criar loop infinito se o erro for no logger
221
+ print(f" [ERRO CRÍTICO] Falha no Batch Insert do WAF: {e}")
222
+
223
+ # Mantém os dados no cache para tentar na próxima requisição
224
+ waf_cache.set('pending_logs_batch', pending_logs, expire=60)
225
+ finally:
226
+ session.close()
227
+ else:
228
+ # Apenas atualiza a lista no cache esperando o gatilho
229
+ waf_cache.set('pending_logs_batch', pending_logs, expire=60)
139
230
 
140
231
  def detect_attack(self, data: str) -> bool:
141
232
  for pattern in self.rules_xss:
@@ -190,89 +281,57 @@ class WafaHell:
190
281
  return False, None, None, None
191
282
 
192
283
  def verify_client_blocked(self, req) -> None:
193
- session = req.session
284
+ ip = req.remote_addr
285
+
286
+ # ---------------------------------------------------------
287
+ # 1. FAST PATH: Cache Check (Memória/Disco)
288
+ # ---------------------------------------------------------
289
+ # Se o cache diz que está bloqueado, abortamos imediatamente.
290
+ # Isso economiza 99% das queries de SELECT durante um ataque (fuzzing).
291
+ if waf_cache.get(f"blocked_{ip}"):
292
+ abort(self.block_code)
293
+
294
+ # ---------------------------------------------------------
295
+ # 2. SLOW PATH: Database Check
296
+ # ---------------------------------------------------------
297
+ # Só chegamos aqui se o IP não estiver no cache.
298
+ # Pode ser um IP limpo OU um IP bloqueado cujo cache expirou (TTL).
299
+
300
+ session = req.session # Reutiliza a sessão da request (Fundamental!)
301
+
194
302
  try:
195
- client_blocked = session.query(Blocked).filter_by(
196
- ip=req.remote_addr,
197
- ).first()
303
+ client_blocked = session.query(Blocked).filter_by(ip=ip).first()
198
304
 
199
305
  if client_blocked:
200
-
306
+ # Normalização de fuso horário
201
307
  now = datetime.now(timezone.utc) if client_blocked.blocked_until.tzinfo else datetime.utcnow()
202
308
 
203
- if client_blocked.blocked_until <= now:
204
- # --- TRAVA DE DESBLOQUEIO (Anti-Race Condition) ---
205
- # Usamos um marcador no cache para saber se alguém já está desbloqueando este IP
206
- cache_key = f"unblocking_{req.remote_addr}"
207
- if cache_key in self.recent_blocks_cache:
208
- return # Outra thread já está limpando este IP, apenas saia
209
-
210
- self.recent_blocks_cache[cache_key] = True
211
- # --------------------------------------------------
212
-
309
+ # Caso 1: Ainda está bloqueado
310
+ if client_blocked.blocked_until > now:
311
+ # RE-AQUECIMENTO DO CACHE:
312
+ # O bloqueio ainda é válido no banco, então renovamos o cache por mais 60s.
313
+ # Assim, as próximas requisições desse IP vão cair no Fast Path acima.
314
+ waf_cache.set(f"blocked_{ip}", True, expire=60)
315
+ abort(self.block_code)
316
+
317
+ # Caso 2: O bloqueio expirou (Desbloqueio)
318
+ else:
213
319
  try:
214
320
  session.delete(client_blocked)
215
321
  session.commit()
322
+ self.log.info(f"[UNBLOCKED] Bloqueio do IP {ip} expirou.")
216
323
 
217
- # Limpa os caches de controle deste IP
218
- self.recent_blocks_cache.pop(req.remote_addr, None)
219
- self.recent_blocks_cache.pop(cache_key, None)
324
+ # Garante que não sobrou lixo no cache
325
+ waf_cache.delete(f"blocked_{ip}")
326
+ # Remove travas de bloqueio antigas se existirem
327
+ waf_cache.delete(f"blocking_lock_{ip}")
220
328
 
221
- self.log.info(f"[UNBLOCKED] IP {req.remote_addr} bloqueio expirou.")
222
329
  except Exception as e:
330
+ # Se der erro de concorrência (outro thread já deletou), apenas ignora
223
331
  session.rollback()
224
- self.recent_blocks_cache.pop(cache_key, None)
225
- raise e
226
- return
227
-
228
- # Se chegou aqui, ainda está bloqueado
229
- abort(self.block_code)
230
-
231
- except OperationalError:
232
- session.rollback()
233
- abort(self.block_code)
234
-
235
- try:
236
-
237
- client_blocked = session.query(Blocked).filter_by(
238
- ip=req.remote_addr,
239
- user_agent=req.headers.get("User-Agent")
240
- ).first()
241
-
242
- if not client_blocked:
243
- return
244
-
245
- # Normaliza o tempo para comparação
246
- now = datetime.now(timezone.utc) if client_blocked.blocked_until.tzinfo else datetime.utcnow()
247
-
248
- if client_blocked.blocked_until <= now:
249
- # --- TRAVA DE DESBLOQUEIO (Anti-Race Condition) ---
250
- # Usamos um marcador no cache para saber se alguém já está desbloqueando este IP
251
- cache_key = f"unblocking_{req.remote_addr}"
252
- if cache_key in self.recent_blocks_cache:
253
- return # Outra thread já está limpando este IP, apenas saia
254
-
255
- self.recent_blocks_cache[cache_key] = True
256
- # --------------------------------------------------
257
-
258
- try:
259
- session.delete(client_blocked)
260
- session.commit()
261
-
262
- # Limpa os caches de controle deste IP
263
- self.recent_blocks_cache.pop(req.remote_addr, None)
264
- self.recent_blocks_cache.pop(cache_key, None)
265
-
266
- self.log.info(f"[UNBLOCKED] IP {req.remote_addr} bloqueio expirou.")
267
- except Exception as e:
268
- session.rollback()
269
- self.recent_blocks_cache.pop(cache_key, None)
270
- raise e
271
- return
272
-
273
- abort(self.block_code)
274
332
 
275
333
  except OperationalError:
334
+ # Se o banco estiver travado/ocupado, abortamos por segurança (Fail Closed)
276
335
  session.rollback()
277
336
  abort(self.block_code)
278
337
 
@@ -280,49 +339,58 @@ class WafaHell:
280
339
  if not self.block_ip:
281
340
  return
282
341
 
283
- # 1. Trava de Memória (ajuda, mas não resolve 100% em multi-processo)
284
- now_ts = datetime.now().timestamp()
285
- if ip in self.recent_blocks_cache:
286
- if now_ts - self.recent_blocks_cache[ip] < 5:
287
- return
288
- self.recent_blocks_cache[ip] = now_ts
342
+ # 1. TRAVA DE CACHE (A Salvação do Fuzzing)
343
+ # Verifica se já existe um processo de bloqueio rodando para este IP.
344
+ # Isso impede que 50 threads do ffuf tentem fazer INSERT ao mesmo tempo.
345
+ cache_key = f"blocking_lock_{ip}"
346
+
347
+ if waf_cache.get(cache_key):
348
+ return # Já está sendo bloqueado por outra thread, aborta.
349
+
350
+ # Cria a trava por 5 segundos (tempo mais que suficiente para o insert ocorrer)
351
+ waf_cache.set(cache_key, True, expire=5)
289
352
 
290
- session = get_session()
353
+ # 2. REUTILIZAÇÃO DE SESSÃO
354
+ # Usamos a sessão que já está aberta na requisição atual.
355
+ session = req.session
356
+
291
357
  try:
292
- # 2. TRAVA DE BANCO: Verifica se já houve um log desse IP nos últimos 2 segundos
293
- # Isso evita que as 6 threads do ffuf que passaram pela trava de memória gravem no banco
294
-
295
- time_threshold = datetime.now(timezone.utc) - timedelta(seconds=2)
296
-
297
- # Buscamos na tabela de LOGS (WafLog) se já existe um registro recente
298
-
299
- exists_recent_log = session.query(WafLog).filter(
300
- WafLog.ip == ip,
301
- WafLog.attack_type.in_(['RATE LIMIT', 'IP BLOCK']),
302
- WafLog.timestamp >= time_threshold
303
- ).first()
304
-
305
- if not exists_recent_log:
306
- # Só prossegue se não houver log recente
307
- exists_block = session.query(Blocked).filter_by(ip=ip).first()
308
- if not exists_block:
309
- now = datetime.now(timezone.utc)
310
- until = now + timedelta(minutes=self.block_durantion)
311
-
312
- new_block = Blocked(
313
- ip=ip, user_agent=user_agent,
314
- blocked_at=now, blocked_until=until
315
- )
316
- session.add(new_block)
317
- session.commit()
318
-
319
-
320
-
358
+ # Verifica se já existe na tabela (Query leve)
359
+ exists_block = session.query(Blocked).filter_by(ip=ip).first()
360
+
361
+ if not exists_block:
362
+ now = datetime.now(timezone.utc)
363
+ until = now + timedelta(minutes=self.block_durantion)
364
+
365
+ # Atenção ao formato do blocked_at se o seu Model esperar String
366
+ # Se no model for DateTime, use 'now'. Se for String, use 'now.strftime...'
367
+ new_block = Blocked(
368
+ ip=ip,
369
+ user_agent=user_agent or "unknown",
370
+ blocked_at=now.strftime("%H:%M:%S"),
371
+ blocked_until=until
372
+ )
373
+
374
+ session.add(new_block)
375
+ session.commit()
376
+
377
+ # Log no arquivo/console
378
+ self.log.warning(f"[BLOCKED] IP: {ip} bloqueado por {self.block_durantion} min.")
379
+
380
+ # 3. PRÉ-AQUECIMENTO DE CACHE
381
+ # Já avisa o cache que este IP está bloqueado.
382
+ # A próxima requisição vai bater no verify_client_blocked, ler o cache e ser barrada sem tocar no banco.
383
+ waf_cache.set(f"blocked_{ip}", True, expire=60)
384
+
321
385
  except Exception as e:
322
386
  session.rollback()
323
387
  self.log.error(f"Erro ao persistir bloqueio: {e}")
324
- finally:
325
- session.close()
388
+ # Se deu erro, removemos a trava para tentar novamente na próxima
389
+ waf_cache.delete(cache_key)
390
+
391
+ # IMPORTANTE:
392
+ # Não usamos 'finally: session.close()' aqui!
393
+ # Quem fecha é o @app.teardown_request no final do ciclo.
326
394
 
327
395
  def verify_rate_limit(self, req) -> None:
328
396
  if self.rate_limit:
@@ -330,17 +398,66 @@ class WafaHell:
330
398
  ua = req.headers.get("User-Agent", "unknown")
331
399
 
332
400
  if limiter.is_rate_limited(ip, ua):
401
+ self.log_attack(
402
+ req=req,
403
+ attack_type="RATE LIMIT",
404
+ payload="Too Many Requests",
405
+ attack_local="Rate Limiter"
406
+ )
407
+
408
+ self.log.warning(f"[RATE LIMIT] IP: {ip} exceeded limit.")
409
+
333
410
  if self.monitor_mode:
334
411
  return
335
412
 
336
-
337
413
  if self.block_ip:
338
414
  self.block_ip_address(ip, ua)
339
- self.log.warning(f"[RATE LIMIT] IP: {ip} exceeded limit.")
340
- self.log.warning(f"[BLOCKED] IP: {ip}, UA: {ua}")
341
415
  abort(self.block_code)
342
416
 
343
- self.log.warning(f"[RATE LIMIT] IP: {ip} exceeded limit.")
417
+
418
+ def check_whitelist(self, req) -> bool:
419
+ ip_str = req.remote_addr
420
+
421
+ # 1. CACHE (Velocidade Extrema)
422
+ # Se esse IP já foi validado antes (seja por faixa ou exato), libera.
423
+ if waf_cache.get(f"whitelist_{ip_str}"):
424
+ return True
425
+
426
+ session = req.session
427
+ try:
428
+ # 2. Busca Exata (Para IPs unitários como 8.8.8.8)
429
+ # É muito rápido.
430
+ if session.query(Whitelist).filter_by(ip=ip_str).first():
431
+ waf_cache.set(f"whitelist_{ip_str}", True, expire=3600)
432
+ return True
433
+
434
+ # 3. Busca por Faixas (CIDR)
435
+ # Só executamos isso se não achou match exato.
436
+ # Trazemos apenas as faixas que contêm "/" para não trazer IPs soltos
437
+ cidr_ranges = session.query(Whitelist.ip).filter(Whitelist.ip.like('%/%')).all()
438
+
439
+ if not cidr_ranges:
440
+ return False
441
+
442
+ user_ip = ipaddress.ip_address(ip_str)
443
+
444
+ for row in cidr_ranges:
445
+ try:
446
+ # Verifica matematicamente se o IP está na rede
447
+ network = ipaddress.ip_network(row.ip, strict=False)
448
+ if user_ip in network:
449
+ # ACHOU!
450
+ # Salva o IP DO USUÁRIO no cache.
451
+ # Na próxima requisição, ele cai no passo 1 e nem passa por aqui.
452
+ waf_cache.set(f"whitelist_{ip_str}", True, expire=3600)
453
+ return True
454
+ except ValueError:
455
+ continue
456
+
457
+ except Exception as e:
458
+ return False
459
+
460
+ return False
344
461
 
345
462
  def parse_req(self, req, payload, attack_local=None, attack_type=None) -> str:
346
463
  ip = req.remote_addr
wafaHell/model.py CHANGED
@@ -52,6 +52,17 @@ class Blocked(Base):
52
52
  f"blocked_until='{self.blocked_until}')>"
53
53
  )
54
54
 
55
+ class Whitelist(Base):
56
+ __tablename__ = "whitelist"
57
+ id = Column(Integer, primary_key=True, autoincrement=True)
58
+ ip = Column(String, nullable=False, unique=True)
59
+ added_at = Column(
60
+ String,
61
+ nullable=False,
62
+ default=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S")
63
+ )
64
+ def __repr__(self):
65
+ return f"<Whitelist(ip='{self.ip}', added_at='{self.added_at}')>"
55
66
 
56
67
  class WafLog(Base):
57
68
  __tablename__ = "waf_logs"