wafaHell 0.1.1__py3-none-any.whl → 1.0.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/__init__.py +1 -1
- wafaHell/app.py +31 -0
- wafaHell/globals.py +5 -0
- wafaHell/logger.py +132 -0
- wafaHell/middleware.py +323 -37
- wafaHell/mock.py +53 -0
- wafaHell/model.py +100 -0
- wafaHell/panel.py +192 -0
- wafaHell/rateLimiter.py +20 -0
- wafaHell/utils.py +312 -0
- {wafahell-0.1.1.dist-info → wafahell-1.0.0.dist-info}/METADATA +8 -4
- wafahell-1.0.0.dist-info/RECORD +15 -0
- {wafahell-0.1.1.dist-info → wafahell-1.0.0.dist-info}/WHEEL +1 -1
- wafahell-0.1.1.dist-info/RECORD +0 -7
- {wafahell-0.1.1.dist-info → wafahell-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {wafahell-0.1.1.dist-info → wafahell-1.0.0.dist-info}/top_level.txt +0 -0
wafaHell/__init__.py
CHANGED
wafaHell/app.py
ADDED
|
@@ -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)
|
wafaHell/globals.py
ADDED
wafaHell/logger.py
ADDED
|
@@ -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)
|
wafaHell/middleware.py
CHANGED
|
@@ -1,65 +1,351 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
import os
|
|
1
3
|
import re
|
|
2
|
-
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from flask import request as req, abort, g
|
|
3
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)
|
|
4
19
|
|
|
5
20
|
class WafaHell:
|
|
6
|
-
def __init__(self, app=None, block_code=403, log_func=None, monitor_mode=False):
|
|
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):
|
|
7
22
|
self.app = app
|
|
8
23
|
self.block_code = block_code
|
|
9
|
-
self.
|
|
24
|
+
self.log = log_func or Logger()
|
|
10
25
|
self.monitor_mode = monitor_mode
|
|
11
|
-
|
|
12
|
-
self.
|
|
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 = [
|
|
13
33
|
r"(\bUNION\b|\bSELECT\b|\bINSERT\b|\bDROP\b)",
|
|
14
|
-
r"' OR '1'='1"
|
|
34
|
+
r"' OR '1'='1"
|
|
35
|
+
]
|
|
36
|
+
self.rules_xss = [
|
|
15
37
|
r"<script.*?>.*?</script>",
|
|
16
|
-
r"javascript:"
|
|
38
|
+
r"javascript:"
|
|
17
39
|
]
|
|
18
40
|
|
|
19
41
|
if app is not None:
|
|
20
42
|
self.init_app(app)
|
|
21
43
|
|
|
22
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
|
+
|
|
23
69
|
@app.before_request
|
|
24
70
|
def waf_check():
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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()
|
|
29
139
|
|
|
30
140
|
def detect_attack(self, data: str) -> bool:
|
|
31
|
-
for pattern in self.
|
|
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:
|
|
32
145
|
if re.search(pattern, data, re.IGNORECASE):
|
|
33
|
-
return
|
|
34
|
-
return
|
|
146
|
+
return "SQLI"
|
|
147
|
+
return None
|
|
35
148
|
|
|
36
|
-
def is_malicious(self, req) ->
|
|
37
|
-
|
|
38
|
-
if
|
|
39
|
-
|
|
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
|
|
40
166
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if
|
|
44
|
-
|
|
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
|
|
45
172
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
49
189
|
|
|
50
|
-
return False
|
|
190
|
+
return False, None, None, None
|
|
51
191
|
|
|
52
|
-
def
|
|
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:
|
|
53
346
|
ip = req.remote_addr
|
|
54
347
|
user_agent = req.headers.get("User-Agent", "unknown")
|
|
55
348
|
path = req.path
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
f"Ataque detectado\n",
|
|
60
|
-
f"IP: {ip}\n"
|
|
61
|
-
f"User-Agent: {user_agent}\n"
|
|
62
|
-
f"Rota: {path}\n"
|
|
63
|
-
f"Query: {unquote(query)}\n"
|
|
64
|
-
)
|
|
65
|
-
self.log_func(msg)
|
|
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}"
|
wafaHell/mock.py
ADDED
|
@@ -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()
|
wafaHell/model.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from sqlalchemy import (
|
|
3
|
+
DateTime, Text, create_engine,
|
|
4
|
+
Column, Integer, String, UniqueConstraint
|
|
5
|
+
)
|
|
6
|
+
from sqlalchemy.orm import declarative_base, sessionmaker, scoped_session
|
|
7
|
+
|
|
8
|
+
# =========================
|
|
9
|
+
# CONFIGURAÇÃO GLOBAL
|
|
10
|
+
# =========================
|
|
11
|
+
|
|
12
|
+
DATABASE_URL = "sqlite:///wafaHell.db"
|
|
13
|
+
|
|
14
|
+
engine = create_engine(
|
|
15
|
+
DATABASE_URL,
|
|
16
|
+
echo=False,
|
|
17
|
+
future=True
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
SessionLocal = scoped_session(
|
|
21
|
+
sessionmaker(
|
|
22
|
+
bind=engine,
|
|
23
|
+
autocommit=False,
|
|
24
|
+
autoflush=False
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
Base = declarative_base()
|
|
29
|
+
|
|
30
|
+
# =========================
|
|
31
|
+
# MODELS
|
|
32
|
+
# =========================
|
|
33
|
+
|
|
34
|
+
class Blocked(Base):
|
|
35
|
+
__tablename__ = "blocks"
|
|
36
|
+
|
|
37
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
38
|
+
ip = Column(String, nullable=True)
|
|
39
|
+
user_agent = Column(String, nullable=True)
|
|
40
|
+
blocked_at = Column(
|
|
41
|
+
String,
|
|
42
|
+
nullable=False,
|
|
43
|
+
default=lambda: datetime.now().strftime("%H:%M:%S")
|
|
44
|
+
)
|
|
45
|
+
blocked_until = Column(DateTime, nullable=False)
|
|
46
|
+
|
|
47
|
+
def __repr__(self):
|
|
48
|
+
return (
|
|
49
|
+
f"<Blocked(ip='{self.ip}', "
|
|
50
|
+
f"user_agent='{self.user_agent}', "
|
|
51
|
+
f"blocked_at='{self.blocked_at}', "
|
|
52
|
+
f"blocked_until='{self.blocked_until}')>"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WafLog(Base):
|
|
57
|
+
__tablename__ = "waf_logs"
|
|
58
|
+
|
|
59
|
+
id = Column(Integer, primary_key=True)
|
|
60
|
+
timestamp = Column(DateTime, default=datetime.utcnow)
|
|
61
|
+
|
|
62
|
+
time_bucket = Column(String(20), index=True)
|
|
63
|
+
|
|
64
|
+
attack_type = Column(String(50))
|
|
65
|
+
ip = Column(String(50))
|
|
66
|
+
path = Column(String(200))
|
|
67
|
+
method = Column(String(10))
|
|
68
|
+
payload = Column(Text)
|
|
69
|
+
attack_local = Column(String(50))
|
|
70
|
+
level = Column(String(20))
|
|
71
|
+
|
|
72
|
+
__table_args__ = (
|
|
73
|
+
UniqueConstraint('ip', 'attack_type', 'time_bucket', name='uix_ip_attack_time'),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class AdminUser(Base):
|
|
78
|
+
__tablename__ = "admin_user"
|
|
79
|
+
|
|
80
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
81
|
+
login = Column(String(50), nullable=False, unique=True)
|
|
82
|
+
password = Column(String(255), nullable=False)
|
|
83
|
+
|
|
84
|
+
def __repr__(self):
|
|
85
|
+
return f"<AdminUser(login='{self.login}')>"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# =========================
|
|
89
|
+
# DB INIT / SESSION
|
|
90
|
+
# =========================
|
|
91
|
+
|
|
92
|
+
def init_db():
|
|
93
|
+
"""Cria as tabelas uma única vez"""
|
|
94
|
+
Base.metadata.create_all(bind=engine)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_session():
|
|
98
|
+
"""Retorna uma sessão reutilizável"""
|
|
99
|
+
return SessionLocal()
|
|
100
|
+
|
wafaHell/panel.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
from flask import render_template_string, request, jsonify, make_response, flash, session, redirect, render_template
|
|
3
|
+
from model import AdminUser, WafLog, get_session, Blocked
|
|
4
|
+
from sqlalchemy import func
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
from utils import Admin, Dashboard, admin
|
|
8
|
+
from werkzeug.security import check_password_hash
|
|
9
|
+
dashboard = Dashboard()
|
|
10
|
+
|
|
11
|
+
def get_logs_and_stats(ip_filter=None, type_filter=None, limit=100):
|
|
12
|
+
session = get_session()
|
|
13
|
+
try:
|
|
14
|
+
log_query = session.query(WafLog).order_by(WafLog.id.desc())
|
|
15
|
+
stat_query = session.query(func.count(WafLog.id))
|
|
16
|
+
|
|
17
|
+
if ip_filter:
|
|
18
|
+
log_query = log_query.filter(WafLog.ip == ip_filter)
|
|
19
|
+
stat_query = stat_query.filter(WafLog.ip == ip_filter)
|
|
20
|
+
if type_filter:
|
|
21
|
+
log_query = log_query.filter(WafLog.attack_type == type_filter)
|
|
22
|
+
stat_query = stat_query.filter(WafLog.attack_type == type_filter)
|
|
23
|
+
|
|
24
|
+
db_logs = log_query.limit(limit).all()
|
|
25
|
+
|
|
26
|
+
# Estatísticas Dinâmicas
|
|
27
|
+
stats = {
|
|
28
|
+
"total": stat_query.scalar() or 0,
|
|
29
|
+
# Agora attacks conta tudo que não é 'Info' ou 'System'
|
|
30
|
+
"attacks": stat_query.filter(WafLog.attack_type.in_(['SQLI', 'XSS', 'RATE LIMIT'])).scalar() or 0,
|
|
31
|
+
"sqli": stat_query.filter(WafLog.attack_type == 'SQLI').scalar() or 0,
|
|
32
|
+
"xss": stat_query.filter(WafLog.attack_type == 'XSS').scalar() or 0,
|
|
33
|
+
"rate_limit": stat_query.filter(WafLog.attack_type == 'RATE LIMIT').scalar() or 0,
|
|
34
|
+
"blocks": stat_query.filter(WafLog.attack_type == 'IP BLOCK').scalar() or 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
formatted_logs = []
|
|
38
|
+
for log in db_logs:
|
|
39
|
+
# Determinamos o "Tipo" visual para o HTML
|
|
40
|
+
display_type = "INFO"
|
|
41
|
+
if log.attack_type in ['SQLI', 'XSS', 'RATE LIMIT']:
|
|
42
|
+
display_type = "ATTACK"
|
|
43
|
+
elif log.attack_type == 'IP BLOCK':
|
|
44
|
+
display_type = "BLOCKED" # Mudança aqui para o dashboard
|
|
45
|
+
|
|
46
|
+
formatted_logs.append({
|
|
47
|
+
"timestamp": log.timestamp.strftime("%H:%M:%S - %d/%m/%Y"),
|
|
48
|
+
"type": display_type,
|
|
49
|
+
"details": {
|
|
50
|
+
"attack_type": log.attack_type,
|
|
51
|
+
"ip": log.ip or "---",
|
|
52
|
+
"path": log.path or "---",
|
|
53
|
+
"method": log.method or "---",
|
|
54
|
+
"payload": log.payload or "---",
|
|
55
|
+
"attack_local": log.attack_local or "---"
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
return formatted_logs, stats
|
|
59
|
+
finally:
|
|
60
|
+
session.close()
|
|
61
|
+
|
|
62
|
+
def setup_dashboard(app, custom_path=None):
|
|
63
|
+
target_path = custom_path or '/admin/dashboard'
|
|
64
|
+
|
|
65
|
+
@app.route(target_path + "/login", methods=["GET", "POST"])
|
|
66
|
+
def login():
|
|
67
|
+
if request.method == "POST":
|
|
68
|
+
username = request.form.get("user")
|
|
69
|
+
password = request.form.get("password")
|
|
70
|
+
db = get_session()
|
|
71
|
+
try:
|
|
72
|
+
admin = db.query(AdminUser).filter(AdminUser.login == username).first()
|
|
73
|
+
|
|
74
|
+
# 3. Valida (Aqui você deveria usar hash, mas vamos focar na lógica)
|
|
75
|
+
if admin and check_password_hash(admin.password, password):
|
|
76
|
+
session["logged_in"] = True
|
|
77
|
+
next_page = request.args.get("next", target_path)
|
|
78
|
+
return redirect(next_page)
|
|
79
|
+
else:
|
|
80
|
+
flash("Acesso Negado: Credenciais Inválidas", "error")
|
|
81
|
+
return redirect(request.url)
|
|
82
|
+
finally:
|
|
83
|
+
db.close()
|
|
84
|
+
|
|
85
|
+
return render_template("login.html")
|
|
86
|
+
|
|
87
|
+
@app.route(target_path + '/data')
|
|
88
|
+
@admin
|
|
89
|
+
def wafahell_data():
|
|
90
|
+
ip_f = request.args.get('ip')
|
|
91
|
+
type_f = request.args.get('type')
|
|
92
|
+
logs, stats = get_logs_and_stats(ip_filter=ip_f, type_filter=type_f)
|
|
93
|
+
return jsonify({"logs": logs, "stats": stats})
|
|
94
|
+
|
|
95
|
+
# Rota 2: Retorna o HTML inicial
|
|
96
|
+
@app.route(target_path)
|
|
97
|
+
@admin
|
|
98
|
+
def wafahell_dashboard():
|
|
99
|
+
ip_f = request.args.get('ip')
|
|
100
|
+
type_f = request.args.get('type')
|
|
101
|
+
logs, stats = get_logs_and_stats(ip_filter=ip_f, type_filter=type_f)
|
|
102
|
+
return render_template('dashboard.html', logs=logs, stats=stats, filters={'ip': ip_f, 'type': type_f})
|
|
103
|
+
|
|
104
|
+
@app.route(target_path + '/export/csv')
|
|
105
|
+
@admin
|
|
106
|
+
def export_csv():
|
|
107
|
+
|
|
108
|
+
ip_filter = request.args.get('ip')
|
|
109
|
+
type_filter = request.args.get('type')
|
|
110
|
+
|
|
111
|
+
session = get_session()
|
|
112
|
+
query = session.query(WafLog)
|
|
113
|
+
|
|
114
|
+
if ip_filter:
|
|
115
|
+
query = query.filter(WafLog.ip == ip_filter)
|
|
116
|
+
if type_filter:
|
|
117
|
+
query = query.filter(WafLog.attack_type == type_filter)
|
|
118
|
+
|
|
119
|
+
logs = query.order_by(WafLog.timestamp.desc()).all()
|
|
120
|
+
session.close()
|
|
121
|
+
|
|
122
|
+
# Criamos o CSV em memória
|
|
123
|
+
output = io.StringIO()
|
|
124
|
+
writer = csv.writer(output)
|
|
125
|
+
|
|
126
|
+
# Cabeçalho
|
|
127
|
+
writer.writerow(['Data/Hora', 'Tipo', 'IP', 'Endpoint', 'Metodo', 'Payload'])
|
|
128
|
+
|
|
129
|
+
for log in logs:
|
|
130
|
+
writer.writerow([
|
|
131
|
+
log.timestamp,
|
|
132
|
+
log.attack_type,
|
|
133
|
+
log.ip,
|
|
134
|
+
log.path,
|
|
135
|
+
log.method,
|
|
136
|
+
log.payload
|
|
137
|
+
])
|
|
138
|
+
|
|
139
|
+
response = make_response(output.getvalue())
|
|
140
|
+
response.headers["Content-Disposition"] = f"attachment; filename=waf_logs_{datetime.now().strftime('%Y%m%d_%H%M')}.csv"
|
|
141
|
+
response.headers["Content-type"] = "text/csv"
|
|
142
|
+
return response
|
|
143
|
+
|
|
144
|
+
@app.route(target_path + '/block_ip', methods=['POST'])
|
|
145
|
+
@admin
|
|
146
|
+
def block_ip() -> bool:
|
|
147
|
+
data = request.get_json()
|
|
148
|
+
ip = data.get('ip')
|
|
149
|
+
block_time = data.get('block_time_minutes', 5) # Pega do JSON ou assume 5
|
|
150
|
+
|
|
151
|
+
if not ip:
|
|
152
|
+
return jsonify({"status": "error", "message": "IP ausente"}), 400
|
|
153
|
+
|
|
154
|
+
session = get_session()
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
exists = session.query(Blocked).filter_by(ip=ip, user_agent="MANUAL_BLOCK").first()
|
|
158
|
+
if exists:
|
|
159
|
+
exists.blocked_until = datetime.now(timezone.utc) + timedelta(minutes=int(block_time))
|
|
160
|
+
session.commit()
|
|
161
|
+
return jsonify({"status": "success", "message": "Tempo de bloqueio atualizado"})
|
|
162
|
+
|
|
163
|
+
now = datetime.now(timezone.utc)
|
|
164
|
+
until = now + timedelta(minutes=int(block_time))
|
|
165
|
+
|
|
166
|
+
new_block = Blocked(
|
|
167
|
+
ip=ip,
|
|
168
|
+
user_agent="MANUAL_BLOCK",
|
|
169
|
+
blocked_until=until
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
session.add(new_block)
|
|
173
|
+
session.commit()
|
|
174
|
+
return jsonify({"status": "success", "message": "IP bloqueado com sucesso"})
|
|
175
|
+
except Exception as e:
|
|
176
|
+
session.rollback()
|
|
177
|
+
return jsonify({"status": "error", "message": str(e)}), 500
|
|
178
|
+
finally:
|
|
179
|
+
session.close()
|
|
180
|
+
|
|
181
|
+
@app.route(target_path + '/graphs', methods=['GET'])
|
|
182
|
+
@admin
|
|
183
|
+
def graphs():
|
|
184
|
+
return render_template('graphs.html')
|
|
185
|
+
|
|
186
|
+
@app.route(target_path + '/stats', methods=['GET'])
|
|
187
|
+
@admin
|
|
188
|
+
def api_stats():
|
|
189
|
+
return jsonify(dashboard.dashboard_setup())
|
|
190
|
+
|
|
191
|
+
print(f" * [WafaHell] Dashboard e API de dados prontos em: {target_path}")
|
|
192
|
+
|
wafaHell/rateLimiter.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
|
|
2
|
+
from collections import defaultdict, deque
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
class RateLimiter:
|
|
6
|
+
def __init__(self, limit=100, window=60):
|
|
7
|
+
self.limit = limit
|
|
8
|
+
self.window = window
|
|
9
|
+
self.requests_log = defaultdict(lambda: deque())
|
|
10
|
+
|
|
11
|
+
def is_rate_limited(self, ip, ua):
|
|
12
|
+
key = (ip, ua)
|
|
13
|
+
now = datetime.now()
|
|
14
|
+
window_start = now - timedelta(seconds=self.window)
|
|
15
|
+
|
|
16
|
+
while self.requests_log[key] and self.requests_log[key][0] < window_start:
|
|
17
|
+
self.requests_log[key].popleft()
|
|
18
|
+
|
|
19
|
+
self.requests_log[key].append(now)
|
|
20
|
+
return len(self.requests_log[key]) >= self.limit
|
wafaHell/utils.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from datetime import datetime, timedelta, timezone
|
|
2
|
+
import secrets
|
|
3
|
+
import string
|
|
4
|
+
import time
|
|
5
|
+
from werkzeug.security import generate_password_hash
|
|
6
|
+
from sqlalchemy.orm import Session
|
|
7
|
+
from sqlalchemy import text, func, case
|
|
8
|
+
from model import WafLog, Blocked
|
|
9
|
+
from model import AdminUser
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from flask import session, redirect, url_for, request
|
|
12
|
+
from model import get_session
|
|
13
|
+
from globals import waf_cache
|
|
14
|
+
import geoip2.database
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
class Admin:
|
|
18
|
+
@staticmethod
|
|
19
|
+
def generate_secure_password(length=64):
|
|
20
|
+
alphabet = string.ascii_letters + string.digits + string.punctuation
|
|
21
|
+
return ''.join(secrets.choice(alphabet) for _ in range(length))
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def create_admin_user(session: Session):
|
|
25
|
+
admin = session.query(AdminUser).filter_by(login="admin").first()
|
|
26
|
+
if admin:
|
|
27
|
+
return
|
|
28
|
+
raw_password = "admin" #Admin.generate_secure_password(64)
|
|
29
|
+
hashed_password = generate_password_hash(raw_password)
|
|
30
|
+
|
|
31
|
+
admin = AdminUser(
|
|
32
|
+
login="admin",
|
|
33
|
+
password=hashed_password
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
session.add(admin)
|
|
37
|
+
session.commit()
|
|
38
|
+
|
|
39
|
+
print("* [WafaHell] Usuario admin criado com sucesso.")
|
|
40
|
+
print("* [WafaHell] Salve essa senha em um lugar seguro, não será mostrada novamente")
|
|
41
|
+
print("Senha: ", raw_password)
|
|
42
|
+
|
|
43
|
+
def admin(fn):
|
|
44
|
+
@wraps(fn)
|
|
45
|
+
def wrapper(*args, **kwargs):
|
|
46
|
+
print(f"DEBUG: Session logged_in status: {session.get('logged_in')}") # Adicione isso
|
|
47
|
+
if not session.get("logged_in"):
|
|
48
|
+
print("DEBUG: Redirecting to login...")
|
|
49
|
+
return redirect(url_for("login", next=request.path))
|
|
50
|
+
return fn(*args, **kwargs)
|
|
51
|
+
return wrapper
|
|
52
|
+
|
|
53
|
+
class Dashboard:
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.db_session = get_session()
|
|
56
|
+
self.geo_db_path = os.path.join(os.path.dirname(__file__), 'GeoLite2-Country.mmdb')
|
|
57
|
+
|
|
58
|
+
def dashboard_setup(self):
|
|
59
|
+
json = {}
|
|
60
|
+
def get_server_info():
|
|
61
|
+
server_time = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
|
|
62
|
+
node_id = "WAF-01"
|
|
63
|
+
avg_latency = waf_cache.get('latency_avg', default=0.0)
|
|
64
|
+
system_status = "critical" if avg_latency > 500 else "degraded" if avg_latency > 200 else "healthy"
|
|
65
|
+
return {
|
|
66
|
+
"server_time": server_time,
|
|
67
|
+
"node_id": node_id,
|
|
68
|
+
"average_latency_ms": round(float(avg_latency), 2),
|
|
69
|
+
"system_status": system_status
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def get_kpis():
|
|
73
|
+
now = datetime.now(timezone.utc)
|
|
74
|
+
last_24h = now - timedelta(hours=24)
|
|
75
|
+
prev_24h = now - timedelta(hours=48)
|
|
76
|
+
|
|
77
|
+
# --- TOTAIS DE HOJE ---
|
|
78
|
+
total_today = self.db_session.query(func.count(WafLog.id)).filter(WafLog.timestamp >= last_24h).scalar() or 0
|
|
79
|
+
blocked_today = self.db_session.query(func.count(WafLog.id)).filter(
|
|
80
|
+
WafLog.timestamp >= last_24h,
|
|
81
|
+
WafLog.attack_type != 'INFO'
|
|
82
|
+
).scalar() or 0
|
|
83
|
+
|
|
84
|
+
# --- TOTAIS DE ONTEM (Para Tendência) ---
|
|
85
|
+
total_yesterday = self.db_session.query(func.count(WafLog.id)).filter(
|
|
86
|
+
WafLog.timestamp >= prev_24h,
|
|
87
|
+
WafLog.timestamp < last_24h
|
|
88
|
+
).scalar() or 0
|
|
89
|
+
|
|
90
|
+
blocked_yesterday = self.db_session.query(func.count(WafLog.id)).filter(
|
|
91
|
+
WafLog.timestamp >= prev_24h,
|
|
92
|
+
WafLog.timestamp < last_24h,
|
|
93
|
+
WafLog.attack_type != 'INFO'
|
|
94
|
+
).scalar() or 0
|
|
95
|
+
|
|
96
|
+
# --- CÁLCULO DE TENDÊNCIA (%) ---
|
|
97
|
+
def calc_trend(current, previous):
|
|
98
|
+
if previous == 0:
|
|
99
|
+
return 100.0 if current > 0 else 0.0
|
|
100
|
+
return round(((current - previous) / previous * 100), 1)
|
|
101
|
+
|
|
102
|
+
trend_total = calc_trend(total_today, total_yesterday)
|
|
103
|
+
trend_attacks = calc_trend(blocked_today, blocked_yesterday)
|
|
104
|
+
|
|
105
|
+
# --- INFOS COMPLEMENTARES ---
|
|
106
|
+
from globals import waf_cache
|
|
107
|
+
# Latência e RPS vindos do Cache Global
|
|
108
|
+
last_second_timestamp = int(time.time()) - 1
|
|
109
|
+
rps_key = f"rps_{last_second_timestamp}"
|
|
110
|
+
|
|
111
|
+
# 2. Lê o valor real do cache para esse segundo específico
|
|
112
|
+
current_rps = waf_cache.get(rps_key, default=0)
|
|
113
|
+
|
|
114
|
+
# 3. Lógica do Pico (Peak)
|
|
115
|
+
# Aqui continuamos usando uma chave fixa 'rps_peak_hour' para guardar o recorde
|
|
116
|
+
peak_rps = waf_cache.get('rps_peak_hour', default=0)
|
|
117
|
+
|
|
118
|
+
if current_rps > peak_rps:
|
|
119
|
+
peak_rps = current_rps
|
|
120
|
+
# Atualiza o recorde no cache por 1 hora
|
|
121
|
+
waf_cache.set('rps_peak_hour', peak_rps, expire=3600)
|
|
122
|
+
|
|
123
|
+
# Dados da Blacklist
|
|
124
|
+
total_blacklist = self.db_session.query(func.count(Blocked.id)).scalar() or 0
|
|
125
|
+
# Como blocked_at é String formatada no seu modelo, comparamos com o horário
|
|
126
|
+
added_today = self.db_session.query(func.count(Blocked.id)).filter(
|
|
127
|
+
Blocked.blocked_until >= now # Um IP "adicionado hoje" é tecnicamente um ainda bloqueado
|
|
128
|
+
).scalar() or 0
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"total_requests_24h": {
|
|
132
|
+
"value": total_today,
|
|
133
|
+
"trend_percent": trend_total
|
|
134
|
+
},
|
|
135
|
+
"attacks_mitigated_24h": {
|
|
136
|
+
"value": blocked_today,
|
|
137
|
+
"trend_percent": trend_attacks # Adicionado aqui
|
|
138
|
+
},
|
|
139
|
+
"throughput": {
|
|
140
|
+
"current_req_per_sec": current_rps,
|
|
141
|
+
"peak_last_hour": peak_rps
|
|
142
|
+
},
|
|
143
|
+
"blacklist_count": {
|
|
144
|
+
"total_ips": total_blacklist,
|
|
145
|
+
"added_today": added_today
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def get_traffic_chart():
|
|
150
|
+
now = datetime.now(timezone.utc)
|
|
151
|
+
start_time = now - timedelta(minutes=40)
|
|
152
|
+
|
|
153
|
+
# Query para agrupar por minuto
|
|
154
|
+
# No SQLite usamos strftime, no Postgres/MySQL seria date_format ou similar
|
|
155
|
+
query = self.db_session.query(
|
|
156
|
+
func.strftime('%H:%M', WafLog.timestamp).label('minute'),
|
|
157
|
+
func.count(WafLog.id).label('total'),
|
|
158
|
+
func.sum(case({WafLog.attack_type == 'INFO': 1}, else_=0)).label('legit'),
|
|
159
|
+
func.sum(case({WafLog.attack_type != 'INFO': 1}, else_=0)).label('attacks')
|
|
160
|
+
).filter(WafLog.timestamp >= start_time)\
|
|
161
|
+
.group_by('minute')\
|
|
162
|
+
.order_by('minute').all()
|
|
163
|
+
|
|
164
|
+
labels = []
|
|
165
|
+
series_legit = []
|
|
166
|
+
series_attacks = []
|
|
167
|
+
|
|
168
|
+
# Preenche os arrays para o gráfico
|
|
169
|
+
for row in query:
|
|
170
|
+
labels.append(row.minute)
|
|
171
|
+
series_legit.append(row.legit or 0)
|
|
172
|
+
series_attacks.append(row.attacks or 0)
|
|
173
|
+
|
|
174
|
+
# Caso não existam dados nos últimos 40 min, retorna arrays vazios para não quebrar o front
|
|
175
|
+
return {
|
|
176
|
+
"labels": labels,
|
|
177
|
+
"series_legit": series_legit,
|
|
178
|
+
"series_attacks": series_attacks
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
def get_distribution_vectors():
|
|
182
|
+
now = datetime.now(timezone.utc)
|
|
183
|
+
last_24h = now - timedelta(hours=24)
|
|
184
|
+
|
|
185
|
+
# 1. Buscamos a contagem agrupada por tipo de ataque
|
|
186
|
+
# Filtramos para não incluir tráfego legítimo (INFO)
|
|
187
|
+
query = self.db_session.query(
|
|
188
|
+
WafLog.attack_type,
|
|
189
|
+
func.count(WafLog.id).label('count')
|
|
190
|
+
).filter(
|
|
191
|
+
WafLog.timestamp >= last_24h,
|
|
192
|
+
WafLog.attack_type != 'INFO'
|
|
193
|
+
).group_by(WafLog.attack_type).all()
|
|
194
|
+
|
|
195
|
+
# 2. Calculamos o total de ataques para obter a porcentagem
|
|
196
|
+
total_attacks = sum(row.count for row in query)
|
|
197
|
+
|
|
198
|
+
distribution = []
|
|
199
|
+
|
|
200
|
+
for row in query:
|
|
201
|
+
percentage = round((row.count / total_attacks * 100), 1) if total_attacks > 0 else 0
|
|
202
|
+
distribution.append({
|
|
203
|
+
"label": row.attack_type,
|
|
204
|
+
"count": row.count,
|
|
205
|
+
"percentage": percentage
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
# Caso não haja ataques, retornamos uma lista vazia ou um placeholder
|
|
209
|
+
return distribution
|
|
210
|
+
|
|
211
|
+
def get_top_geo():
|
|
212
|
+
def resolve_ip(ip):
|
|
213
|
+
try:
|
|
214
|
+
if not os.path.exists(self.geo_db_path):
|
|
215
|
+
print("ERRO: Arquivo GeoLite2-Country.mmdb não encontrado!")
|
|
216
|
+
return "XX", "Unknown"
|
|
217
|
+
|
|
218
|
+
with geoip2.database.Reader(self.geo_db_path) as reader:
|
|
219
|
+
response = reader.country(ip)
|
|
220
|
+
return response.country.iso_code, response.country.name
|
|
221
|
+
except Exception as e:
|
|
222
|
+
print(f"Erro na consulta GeoIP: {e}") # Isso vai te dizer se o banco está corrompido ou o IP é inválido
|
|
223
|
+
return "XX", "Unknown"
|
|
224
|
+
|
|
225
|
+
now = datetime.now(timezone.utc)
|
|
226
|
+
last_24h = now - timedelta(hours=24)
|
|
227
|
+
|
|
228
|
+
# 1. Busca todos os ataques agrupados por IP
|
|
229
|
+
query = self.db_session.query(
|
|
230
|
+
WafLog.ip,
|
|
231
|
+
func.count(WafLog.id).label('count')
|
|
232
|
+
).filter(
|
|
233
|
+
WafLog.timestamp >= last_24h,
|
|
234
|
+
WafLog.attack_type != 'INFO'
|
|
235
|
+
).group_by(WafLog.ip).all()
|
|
236
|
+
|
|
237
|
+
geo_stats = {}
|
|
238
|
+
total_attacks = 0
|
|
239
|
+
|
|
240
|
+
# 2. Processa cada IP real usando o GeoIP2
|
|
241
|
+
for row in query:
|
|
242
|
+
code, name = resolve_ip(row.ip)
|
|
243
|
+
|
|
244
|
+
if code not in geo_stats:
|
|
245
|
+
geo_stats[code] = {"name": name, "count": 0}
|
|
246
|
+
|
|
247
|
+
geo_stats[code]["count"] += row.count
|
|
248
|
+
total_attacks += row.count
|
|
249
|
+
|
|
250
|
+
# 3. Formata o Top 5
|
|
251
|
+
top_geo = []
|
|
252
|
+
sorted_geo = sorted(geo_stats.items(), key=lambda x: x[1]['count'], reverse=True)[:5]
|
|
253
|
+
|
|
254
|
+
for code, data in sorted_geo:
|
|
255
|
+
percentage = round((data["count"] / total_attacks * 100), 1) if total_attacks > 0 else 0
|
|
256
|
+
top_geo.append({
|
|
257
|
+
"country_code": code,
|
|
258
|
+
"country_name": data["name"],
|
|
259
|
+
"count": data["count"],
|
|
260
|
+
"percentage": percentage
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
return top_geo
|
|
264
|
+
|
|
265
|
+
def get_top_offenders():
|
|
266
|
+
now = datetime.now(timezone.utc)
|
|
267
|
+
last_24h = now - timedelta(hours=24)
|
|
268
|
+
|
|
269
|
+
# 1. Agrupamos por IP e contamos os hits e os tipos de ataques diferentes
|
|
270
|
+
# Ignoramos tráfego INFO
|
|
271
|
+
query = self.db_session.query(
|
|
272
|
+
WafLog.ip,
|
|
273
|
+
func.count(WafLog.id).label('hits_count'),
|
|
274
|
+
func.count(func.distinct(WafLog.attack_type)).label('unique_vectors')
|
|
275
|
+
).filter(
|
|
276
|
+
WafLog.timestamp >= last_24h,
|
|
277
|
+
WafLog.attack_type != 'INFO'
|
|
278
|
+
).group_by(WafLog.ip).order_by(text('hits_count DESC')).limit(5).all()
|
|
279
|
+
|
|
280
|
+
offenders = []
|
|
281
|
+
for row in query:
|
|
282
|
+
# 2. Lógica de Risk Score (0 a 100)
|
|
283
|
+
# Baseada em volume e diversidade de ataques
|
|
284
|
+
# Ex: Cada vetor único vale 20 pontos + 1 ponto para cada 50 hits (até o teto de 100)
|
|
285
|
+
vector_points = row.unique_vectors * 20
|
|
286
|
+
hit_points = row.hits_count // 50
|
|
287
|
+
risk_score = min(100, vector_points + hit_points)
|
|
288
|
+
|
|
289
|
+
offenders.append({
|
|
290
|
+
"ip": row.ip,
|
|
291
|
+
"risk_score": int(risk_score),
|
|
292
|
+
"hits_count": row.hits_count
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
return offenders
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
json['meta'] = get_server_info()
|
|
299
|
+
json['kpis'] = get_kpis()
|
|
300
|
+
json['traffic_chart'] = get_traffic_chart()
|
|
301
|
+
json['distribution_vectors'] = get_distribution_vectors()
|
|
302
|
+
json['top_geo'] = get_top_geo()
|
|
303
|
+
json['top_offenders'] = get_top_offenders()
|
|
304
|
+
|
|
305
|
+
return json
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
print(f"Erro no Dashboard: {e}")
|
|
309
|
+
return {"error": str(e)}
|
|
310
|
+
|
|
311
|
+
finally:
|
|
312
|
+
self.db_session.close()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wafaHell
|
|
3
|
-
Version: 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
|
|
@@ -42,14 +46,14 @@ Middleware WAF for Flask, to detect SQLi and XSS.
|
|
|
42
46
|
## Instalation
|
|
43
47
|
|
|
44
48
|
```bash
|
|
45
|
-
pip install
|
|
49
|
+
pip install wafahell
|
|
46
50
|
```
|
|
47
51
|
|
|
48
52
|
## Usage
|
|
49
53
|
```python
|
|
50
54
|
from flask import Flask
|
|
51
|
-
from
|
|
55
|
+
from wafahell import WafaHell
|
|
52
56
|
|
|
53
57
|
app = Flask(__name__)
|
|
54
|
-
waf =
|
|
58
|
+
waf = WafaHell(app)
|
|
55
59
|
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
wafaHell/__init__.py,sha256=bdafr3LRK7_frr-V6umEJZUqt8RMrOiLJy72DSQfs2o,60
|
|
2
|
+
wafaHell/app.py,sha256=mXUCyOVrbU2Cdugh-UIDSQf-6zqvT2aazZtJQp_KKJI,980
|
|
3
|
+
wafaHell/globals.py,sha256=keIe0YCNsb5si0yUhUjd6nlk1s0YgfH5G7lH5-05Zws,160
|
|
4
|
+
wafaHell/logger.py,sha256=keT-Iw6g9w1dtrk0GhldxhgKtmNUVClgFGkSkAWXS5Y,5686
|
|
5
|
+
wafaHell/middleware.py,sha256=cse81wj9ocUV11MPfrmvleD5nYq6asjDx6dqXToIcIs,14348
|
|
6
|
+
wafaHell/mock.py,sha256=EgfSCKYV3rUTerKoEyFQPSf47wrOo-E0yqe7JpQUe3w,2105
|
|
7
|
+
wafaHell/model.py,sha256=OB4sEucMSgO-DBLgLhqh0oBx5fZRug23MDnRc8vS5w4,2501
|
|
8
|
+
wafaHell/panel.py,sha256=NpbvgXy5Regm4dQDoMapr-otW27TvC7Q-vdPrZDD-5k,7465
|
|
9
|
+
wafaHell/rateLimiter.py,sha256=p4IDxha-ZrRPROFf8Fa1gRTbbUTQdbD9TgBTmyMR2hQ,664
|
|
10
|
+
wafaHell/utils.py,sha256=v9NXo94AQiHjjCOgC38vwrlLoQOCZqp_qOjZVz00NEI,12750
|
|
11
|
+
wafahell-1.0.0.dist-info/licenses/LICENSE,sha256=6bv9v4HamenV3rqm3mhaGOecwGFrgxtVTW7JPfFDmeY,1086
|
|
12
|
+
wafahell-1.0.0.dist-info/METADATA,sha256=2EA_4vTwwoXimX_etFdZo_x_Z2_qijnbSjuIHfwE10Q,2087
|
|
13
|
+
wafahell-1.0.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
14
|
+
wafahell-1.0.0.dist-info/top_level.txt,sha256=VGBo2g3pOeTH2qIXfZDJCSblJgijemMHUHmI0bBgrls,9
|
|
15
|
+
wafahell-1.0.0.dist-info/RECORD,,
|
wafahell-0.1.1.dist-info/RECORD
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
wafaHell/__init__.py,sha256=enTIAbStl7o_cDo93-s8nq86BsN3OEGYu9AbtESQVXQ,105
|
|
2
|
-
wafaHell/middleware.py,sha256=UQ5TPcuz6UDyy5FjyUNXSAZqv2IjnOoc3ze2M8He7Tw,2151
|
|
3
|
-
wafahell-0.1.1.dist-info/licenses/LICENSE,sha256=6bv9v4HamenV3rqm3mhaGOecwGFrgxtVTW7JPfFDmeY,1086
|
|
4
|
-
wafahell-0.1.1.dist-info/METADATA,sha256=DKx0AS6ZJ82ATAkBJetN_AG8doVO00xxMI7PwBdcBzQ,1980
|
|
5
|
-
wafahell-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
-
wafahell-0.1.1.dist-info/top_level.txt,sha256=VGBo2g3pOeTH2qIXfZDJCSblJgijemMHUHmI0bBgrls,9
|
|
7
|
-
wafahell-0.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|