wafaHell 0.2.0__tar.gz → 1.0.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wafaHell
3
- Version: 0.2.0
3
+ Version: 1.0.0
4
4
  Summary: Middleware WAF to Flask
5
5
  Author-email: Yago Martins <yagomartins30@gmail.com>
6
6
  License: MIT License
@@ -33,6 +33,10 @@ Requires-Python: >=3.9
33
33
  Description-Content-Type: text/markdown
34
34
  License-File: LICENSE
35
35
  Requires-Dist: Flask>=2.0
36
+ Requires-Dist: sqlalchemy>=2.0.0
37
+ Requires-Dist: requests
38
+ Requires-Dist: diskcache
39
+ Requires-Dist: geoip2
36
40
  Dynamic: license-file
37
41
 
38
42
  # WafHell
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wafaHell"
7
- version = "0.2.0"
7
+ version = "1.0.0"
8
8
  description = "Middleware WAF to Flask"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -19,5 +19,9 @@ classifiers = [
19
19
  "Operating System :: OS Independent"
20
20
  ]
21
21
  dependencies = [
22
- "Flask>=2.0"
22
+ "Flask>=2.0",
23
+ "sqlalchemy>=2.0.0",
24
+ "requests",
25
+ "diskcache",
26
+ "geoip2"
23
27
  ]
@@ -0,0 +1,31 @@
1
+ from middleware import WafaHell
2
+ from flask import Flask, render_template, request
3
+ from werkzeug.middleware.proxy_fix import ProxyFix
4
+
5
+ app = Flask(__name__)
6
+
7
+
8
+ app.wsgi_app = ProxyFix(
9
+ app.wsgi_app,
10
+ x_for=1,
11
+ x_proto=1,
12
+ x_host=1,
13
+ x_port=1
14
+ )
15
+
16
+ @app.route('/', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
17
+ def home():
18
+ return "<h1>Bem-vindo à minha aplicação Flask!</h1><p>Acesse /hello?nome=test</p>"
19
+
20
+ @app.route('/hello', methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'])
21
+ def hello():
22
+ nome = request.args.get('nome', 'Visitante')
23
+ return f"<h1>Olá, {nome}!</h1>"
24
+
25
+ @app.route('/admin/dashboard')
26
+ def dashboard():
27
+ return "<h1>Dashboard Personalizado</h1><p>Este é o painel de controle personalizado.</p>"
28
+
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)
@@ -0,0 +1,5 @@
1
+ import os
2
+ from diskcache import Cache
3
+
4
+ cache_path = '/dev/shm/waf_cache' if os.path.exists('/dev/shm') else './waf_cache_temp'
5
+ waf_cache = Cache(cache_path)
@@ -0,0 +1,132 @@
1
+ from datetime import datetime
2
+ import logging
3
+ from model import WafLog, get_session
4
+ import re
5
+ # Handler customizado para salvar no SQLite via SQLAlchemy
6
+ class SQLAlchemyHandler(logging.Handler):
7
+ def __init__(self):
8
+ super().__init__()
9
+ self.session = get_session()
10
+ # Regex para extrair dados da string formatada pelo parse_req
11
+ self.attr_pattern = re.compile(
12
+ r"Attack_type: (?P<type>.*?), IP: (?P<ip>.*?), .*?Path: (?P<path>.*?), Method: (?P<method>.*?), Payload: (?P<payload>.*?), attack_local: (?P<local>.*)"
13
+ )
14
+
15
+ def emit(self, record):
16
+ session = self.session # Certifique-se de instanciar a sessão
17
+ try:
18
+ msg = record.getMessage()
19
+
20
+ # Valores padrão
21
+ ip, path, method, payload, local, attack_type = (None, None, None, msg, None, "Info")
22
+
23
+ # Expandimos a condição para incluir [BLOCKED]
24
+ if any(tag in msg for tag in ["[ATTACK]", "[RATE LIMIT]", "[BLOCKED]"]):
25
+
26
+ def extract(key, text):
27
+ # Regex ajustada para capturar até a vírgula ou fim da tag, permitindo espaços internos
28
+ match = re.search(rf"{key}:?\s*([^,\]]+)", text)
29
+ return match.group(1).strip() if match else None
30
+
31
+ ip = extract("IP", msg)
32
+
33
+ if "[BLOCKED]" in msg:
34
+ attack_type = "IP BLOCK"
35
+ ua = extract("UA", msg)
36
+ payload = f"IP Bloqueado. User-Agent: {ua}" if ua else "IP Bloqueado"
37
+ path = "---"
38
+ method = "---"
39
+ elif "[RATE LIMIT]" in msg:
40
+ attack_type = "RATE LIMIT"
41
+ payload = "Exceeded request limit"
42
+ path = extract("Path", msg)
43
+ method = extract("Method", msg)
44
+
45
+ bucket = datetime.utcnow().strftime('%Y-%m-%d %H:%M')
46
+ log_entry = WafLog(
47
+ level=record.levelname,
48
+ attack_type=attack_type,
49
+ ip=ip,
50
+ path=path,
51
+ method=method,
52
+ payload=payload,
53
+ attack_local=local,
54
+ time_bucket=bucket
55
+ )
56
+
57
+ session.add(log_entry)
58
+ session.commit()
59
+ else:
60
+ # Lógica para [ATTACK]
61
+ attack_type = extract("Attack_type", msg) or "Unknown"
62
+ path = extract("Path", msg)
63
+ method = extract("Method", msg)
64
+
65
+ # Captura o local completo (ex: HEADER 'User-Agent')
66
+ local_match = re.search(r"attack_local:\s*(.+)$", msg)
67
+ local = local_match.group(1).strip() if local_match else extract("attack_local", msg)
68
+
69
+ # Regex específica para payloads que podem conter vírgulas
70
+ payload_match = re.search(r"Payload: (.*?), attack_local:", msg)
71
+ payload = payload_match.group(1) if payload_match else extract("Payload", msg)
72
+
73
+ if not "[RATE LIMIT]" in msg:
74
+ log_entry = WafLog(
75
+ level=record.levelname,
76
+ attack_type=attack_type,
77
+ ip=ip,
78
+ path=path,
79
+ method=method,
80
+ payload=payload,
81
+ attack_local=local,
82
+ # time_bucket fica None aqui para não filtrar ataques normais no banco
83
+ )
84
+
85
+ session.add(log_entry)
86
+ session.commit()
87
+
88
+ except Exception as e:
89
+ session.rollback()
90
+ finally:
91
+ session.close()
92
+
93
+ class Logger:
94
+ def __init__(self, name="WAF", log_file="waf.log", level=logging.INFO):
95
+ self.logger = logging.getLogger(name)
96
+ self.logger.setLevel(level)
97
+
98
+ if not self.logger.handlers:
99
+ formatter = logging.Formatter(
100
+ "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
101
+ datefmt="%H:%M:%S - %d/%m/%Y"
102
+ )
103
+
104
+ # Handler 1: Console
105
+ console_handler = logging.StreamHandler()
106
+ console_handler.setFormatter(formatter)
107
+ self.logger.addHandler(console_handler)
108
+
109
+ # Handler 2: Arquivo
110
+ file_handler = logging.FileHandler(log_file)
111
+ file_handler.setFormatter(formatter)
112
+ self.logger.addHandler(file_handler)
113
+
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)
118
+
119
+ def info(self, msg):
120
+ self.logger.info(msg)
121
+
122
+ def warning(self, msg):
123
+ self.logger.warning(msg)
124
+
125
+ def error(self, msg):
126
+ self.logger.error(msg)
127
+
128
+ def critical(self, msg):
129
+ self.logger.critical(msg)
130
+
131
+ def debug(self, msg):
132
+ self.logger.debug(msg)
@@ -0,0 +1,351 @@
1
+ from datetime import datetime, timedelta, timezone
2
+ import os
3
+ import re
4
+ import subprocess
5
+ import time
6
+ from flask import request as req, abort, g
7
+ 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
+ from sqlalchemy.exc import OperationalError
12
+ from panel import setup_dashboard
13
+ from utils import Admin
14
+ from sqlalchemy import text
15
+ from globals import waf_cache
16
+
17
+ # Inicializa o RateLimiter
18
+ limiter = RateLimiter(limit=100, window=60)
19
+
20
+ class WafaHell:
21
+ 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)
43
+
44
+ def init_app(self, app):
45
+ Base.metadata.create_all(engine)
46
+ setup_dashboard(app, self.dashboard_path)
47
+ Admin.create_admin_user(get_session())
48
+
49
+ if not app.secret_key:
50
+ app.secret_key = os.urandom(24)
51
+
52
+ @app.before_request
53
+ def create_session():
54
+ try:
55
+ req.session = get_session()
56
+ except Exception as e:
57
+ self.log.error(f"Erro ao criar sessão para requisição: {e}")
58
+ abort(self.block_code)
59
+
60
+ @app.teardown_request
61
+ def close_session(exc=None):
62
+ if hasattr(req, 'session'):
63
+ try:
64
+ req.session.close()
65
+ except Exception as e:
66
+ self.log.error(f"Erro ao fechar sessão: {e}")
67
+
68
+
69
+ @app.before_request
70
+ def waf_check():
71
+ self.verify_client_blocked(req)
72
+ self.verify_rate_limit(req)
73
+ is_malicious, attack_local, payload, attack_type = self.is_malicious(req)
74
+
75
+ if not is_malicious:
76
+ return
77
+
78
+ if not self.monitor_mode:
79
+ self.log.warning(self.parse_req(req, payload, attack_local, attack_type))
80
+ self.block_ip_address(req.remote_addr, req.headers.get("User-Agent", "unknown"))
81
+ abort(self.block_code)
82
+ else:
83
+ self.log.info(self.parse_req(req, payload, attack_local, attack_type))
84
+
85
+ @app.before_request
86
+ def start_timer():
87
+ g.waf_start_time = time.time()
88
+
89
+ @app.after_request
90
+ def stop_timer(response):
91
+ ignored_paths = [self.dashboard_path, f'{self.dashboard_path}/stats', '/static']
92
+
93
+ # 1. Ignora rotas do próprio painel para não sujar os logs e métricas
94
+ if any(req.path.startswith(path) for path in ignored_paths):
95
+ return response
96
+
97
+ # 2. LOG DE TRÁFEGO LEGÍTIMO
98
+ # Se o status_code for menor que 400, significa que o WAF não deu abort()
99
+ # e a requisição seguiu o fluxo normal.
100
+ if response.status_code != self.block_code:
101
+ self.log_legit_access(req)
102
+
103
+ # 3. RPS Logic (Bucketing by second)
104
+ current_timestamp = int(time.time())
105
+ rps_key = f"rps_{current_timestamp}"
106
+ waf_cache.incr(rps_key, default=0)
107
+ waf_cache.expire(rps_key, 10)
108
+
109
+ # 4. Latency Logic
110
+ if hasattr(g, 'waf_start_time'):
111
+ latency = (time.time() - g.waf_start_time) * 1000
112
+
113
+ # Exponential Moving Average
114
+ old_avg = waf_cache.get('latency_avg', default=0.0)
115
+ new_avg = (old_avg * 0.95) + (latency * 0.05) if old_avg > 0 else latency
116
+
117
+ waf_cache.set('latency_avg', new_avg, expire=3600)
118
+
119
+ return response
120
+
121
+ 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()
139
+
140
+ def detect_attack(self, data: str) -> bool:
141
+ for pattern in self.rules_xss:
142
+ if re.search(pattern, data, re.IGNORECASE):
143
+ return "XSS"
144
+ for pattern in self.rules_sqli:
145
+ if re.search(pattern, data, re.IGNORECASE):
146
+ return "SQLI"
147
+ return None
148
+
149
+ def is_malicious(self, req) -> tuple:
150
+ attack = self.detect_attack(req.base_url)
151
+ if attack:
152
+ print(f"[DEBUG] Attack detected in URL: {attack}")
153
+ return True, "URL", req.base_url, attack
154
+
155
+ for key, value in req.form.items():
156
+ attack = self.detect_attack(value)
157
+ if attack:
158
+ print(f"[DEBUG] Attack detected in FORM '{key}': {attack}")
159
+ return True, f"FORM '{key}'", value, attack
160
+
161
+ for key, value in req.args.items():
162
+ attack = self.detect_attack(value)
163
+ if attack:
164
+ print(f"[DEBUG] Attack detected in QUERY '{key}': {attack}")
165
+ return True, f"QUERY '{key}'", value, attack
166
+
167
+ for key, value in req.headers.items():
168
+ attack = self.detect_attack(value)
169
+ if attack:
170
+ print(f"[DEBUG] Attack detected in HEADER '{key}': {attack}")
171
+ return True, f"HEADER '{key}'", value, attack
172
+
173
+ if req.data:
174
+ body_content = req.data.decode(errors="ignore")
175
+ attack = self.detect_attack(body_content)
176
+ if attack:
177
+ print(f"[DEBUG] Attack detected in BODY: {attack}")
178
+ return True, "BODY", body_content, attack
179
+
180
+ if req.is_json:
181
+ json_data = req.get_json(silent=True)
182
+ if json_data:
183
+ import json
184
+ json_str = json.dumps(json_data)
185
+ attack = self.detect_attack(json_str)
186
+ if attack:
187
+ print(f"[DEBUG] Attack detected in JSON BODY: {attack}")
188
+ return True, "JSON BODY", json_str, attack
189
+
190
+ return False, None, None, None
191
+
192
+ def verify_client_blocked(self, req) -> None:
193
+ session = req.session
194
+ try:
195
+ client_blocked = session.query(Blocked).filter_by(
196
+ ip=req.remote_addr,
197
+ ).first()
198
+
199
+ if client_blocked:
200
+
201
+ now = datetime.now(timezone.utc) if client_blocked.blocked_until.tzinfo else datetime.utcnow()
202
+
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
+
213
+ try:
214
+ session.delete(client_blocked)
215
+ session.commit()
216
+
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)
220
+
221
+ self.log.info(f"[UNBLOCKED] IP {req.remote_addr} bloqueio expirou.")
222
+ except Exception as e:
223
+ 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
+
275
+ except OperationalError:
276
+ session.rollback()
277
+ abort(self.block_code)
278
+
279
+ def block_ip_address(self, ip, user_agent=None):
280
+ if not self.block_ip:
281
+ return
282
+
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
289
+
290
+ session = get_session()
291
+ 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
+
321
+ except Exception as e:
322
+ session.rollback()
323
+ self.log.error(f"Erro ao persistir bloqueio: {e}")
324
+ finally:
325
+ session.close()
326
+
327
+ def verify_rate_limit(self, req) -> None:
328
+ if self.rate_limit:
329
+ ip = req.remote_addr
330
+ ua = req.headers.get("User-Agent", "unknown")
331
+
332
+ if limiter.is_rate_limited(ip, ua):
333
+ if self.monitor_mode:
334
+ return
335
+
336
+
337
+ if self.block_ip:
338
+ 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
+ abort(self.block_code)
342
+
343
+ self.log.warning(f"[RATE LIMIT] IP: {ip} exceeded limit.")
344
+
345
+ def parse_req(self, req, payload, attack_local=None, attack_type=None) -> str:
346
+ ip = req.remote_addr
347
+ user_agent = req.headers.get("User-Agent", "unknown")
348
+ path = req.path
349
+ method = req.method
350
+ attack_local = attack_local or "unknown"
351
+ return f"[ATTACK] Attack_type: {attack_type}, IP: {ip}, User-Agent: {user_agent}, Path: {path}, Method: {method}, Payload: {unquote(payload)}, attack_local: {attack_local}"
@@ -0,0 +1,53 @@
1
+ import requests
2
+
3
+ # Configuração do alvo
4
+ BASE_URL = "http://127.0.0.1:5000/hello"
5
+
6
+ # Definição dos payloads de teste (SQLi e XSS clássicos)
7
+ payloads = {
8
+ "URL Query": {"url": f"{BASE_URL}?id=1' OR '1'='1"},
9
+ "Form Data": {"method": "POST", "data": {"user": "<script>alert(1)</script>"}},
10
+ "JSON Body": {"method": "POST", "json": {"search": "SELECT * FROM users"}},
11
+ "Cookies": {"method": "POST", "cookies": {"session": "UNION SELECT NULL,NULL--"}},
12
+ "Custom Header": {"method": "POST", "headers": {"User-Agent": "'; DROP TABLE logs;--"}},
13
+ "Multipart File": {"method": "POST", "files": {"file": ("README.md", "payload: <script>alert('XSS')</script>")}},
14
+ }
15
+
16
+ def run_mock():
17
+ print(f"🚀 Iniciando Mock de Testes WafaHell no alvo: {BASE_URL}\n")
18
+ print(f"{'VETOR DE TESTE':<20} | {'STATUS':<10} | {'RESULTADO'}")
19
+ print("-" * 55)
20
+
21
+ for test_name, config in payloads.items():
22
+ try:
23
+ # Prepara a requisição
24
+ url = config.get("url", BASE_URL)
25
+ method = config.get("method", "GET")
26
+
27
+ # Executa a requisição com os parâmetros dinâmicos
28
+ response = requests.request(
29
+ method=method,
30
+ url=url,
31
+ data=config.get("data"),
32
+ json=config.get("json"),
33
+ cookies=config.get("cookies"),
34
+ headers=config.get("headers"),
35
+ files=config.get("files"),
36
+ timeout=5
37
+ )
38
+
39
+ # Validação: 403 significa que o WAF bloqueou (Sucesso no teste)
40
+ if response.status_code == 403:
41
+ status_txt = "✅ BLOQUEADO"
42
+ result_txt = "Sucesso (WAF Ativo)"
43
+ else:
44
+ status_txt = f"❌ {response.status_code}"
45
+ result_txt = "Falha (Vulnerável!)"
46
+
47
+ print(f"{test_name:<20} | {status_txt:<10} | {result_txt}")
48
+
49
+ except Exception as e:
50
+ print(f"{test_name:<20} | ⚠️ ERRO | {str(e)}")
51
+
52
+ if __name__ == "__main__":
53
+ run_mock()