new options

This commit is contained in:
Mateusz Gruszczyński
2025-11-03 10:18:10 +01:00
parent acef7eb610
commit df70118653
5 changed files with 355 additions and 139 deletions

20
app.py
View File

@@ -1,14 +1,15 @@
from flask import Flask, render_template, render_template_string import os
import configparser import sys
import ssl import ssl
import configparser
from flask import Flask, render_template, render_template_string
from routes.main_routes import main_bp from routes.main_routes import main_bp
from routes.edit_routes import edit_bp from routes.edit_routes import edit_bp
from utils.stats_utils import fetch_haproxy_stats, parse_haproxy_stats from utils.stats_utils import fetch_haproxy_stats, parse_haproxy_stats
from auth.auth_middleware import setup_auth from auth.auth_middleware import setup_auth
from log_parser import parse_log_file from log_parser import parse_log_file
import os
import sys
from utils.haproxy_config import update_haproxy_config, is_frontend_exist, count_frontends_and_backends from utils.haproxy_config import update_haproxy_config, is_frontend_exist, count_frontends_and_backends
BASE_DIR = os.path.abspath(os.path.dirname(__file__)) BASE_DIR = os.path.abspath(os.path.dirname(__file__))
@@ -73,23 +74,23 @@ try:
certificate_path = config2.get('ssl', 'certificate_path') certificate_path = config2.get('ssl', 'certificate_path')
private_key_path = config2.get('ssl', 'private_key_path') private_key_path = config2.get('ssl', 'private_key_path')
else: else:
print(f"[APP] No [ssl] section in {SSL_INI}", flush=True) print(f"[APP] No [ssl] section in {SSL_INI}", flush=True)
sys.exit(1) sys.exit(1)
if not os.path.exists(certificate_path): if not os.path.exists(certificate_path):
print(f"[APP] Certificate not found: {certificate_path}", flush=True) print(f"[APP] Certificate not found: {certificate_path}", flush=True)
sys.exit(1) sys.exit(1)
if not os.path.exists(private_key_path): if not os.path.exists(private_key_path):
print(f"[APP] Private key not found: {private_key_path}", flush=True) print(f"[APP] Private key not found: {private_key_path}", flush=True)
sys.exit(1) sys.exit(1)
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
ssl_context.load_cert_chain(certfile=certificate_path, keyfile=private_key_path) ssl_context.load_cert_chain(certfile=certificate_path, keyfile=private_key_path)
print(f"[APP] SSL context loaded", flush=True) print(f"[APP] SSL context loaded", flush=True)
except Exception as e: except Exception as e:
print(f"[APP] SSL error: {e}", flush=True) print(f"[APP] SSL error: {e}", flush=True)
sys.exit(1) sys.exit(1)
@@ -105,7 +106,6 @@ def display_logs():
parsed_entries = parse_log_file(log_file_path) parsed_entries = parse_log_file(log_file_path)
return render_template('logs.html', entries=parsed_entries) return render_template('logs.html', entries=parsed_entries)
@app.route('/home') @app.route('/home')
def home(): def home():
frontend_count, backend_count, acl_count, layer7_count, layer4_count = count_frontends_and_backends() frontend_count, backend_count, acl_count, layer7_count, layer4_count = count_frontends_and_backends()

View File

@@ -1,7 +1,11 @@
import re import re
from collections import defaultdict
from datetime import datetime
def parse_log_file(log_file_path): def parse_log_file(log_file_path):
parsed_entries = [] parsed_entries = []
xss_patterns = [ xss_patterns = [
r'<\s*script\s*', r'<\s*script\s*',
r'javascript:', r'javascript:',
@@ -16,88 +20,170 @@ def parse_log_file(log_file_path):
r'alert', r'alert',
r'onerror', r'onerror',
r'onload', r'onload',
r'javascript'
] ]
sql_patterns = [ sql_patterns = [
r';', r'(union|select|insert|update|delete|drop)\s+(from|into|table)',
r'substring', r';\s*(union|select|insert|update|delete|drop)',
r'extract', r'substring\s*\(',
r'union\s+all', r'extract\s*\(',
r'order\s+by', r'order\s+by\s+\d+',
r'--\+', r'--\+',
r'union', r'1\s*=\s*1',
r'select', r'@@\w+',
r'insert',
r'update',
r'delete',
r'drop',
r'@@',
r'1=1',
r'`1', r'`1',
r'union', r'\|\|\s*chr\(',
r'select',
r'insert',
r'update',
r'delete',
r'drop',
r'@@',
r'1=1',
r'`1'
] ]
webshells_patterns = [ webshells_patterns = [
r'payload', r'eval\s*\(',
r'eval|system|passthru|shell_exec|exec|popen|proc_open|pcntl_exec|cmd|shell|backdoor|webshell|phpspy|c99|kacak|b374k|log4j|log4shell|wsos|madspot|malicious|evil.*\.php.*' r'system\s*\(',
r'passthru\s*\(',
r'shell_exec\s*\(',
r'exec\s*\(',
r'popen\s*\(',
r'proc_open\s*\(',
r'pcntl_exec\s*\(',
r'\.php\?cmd=',
r'\.php\?id=',
r'backdoor|webshell|phpspy|c99|kacak|b374k|wsos|madspot|r57|c100|r57shell',
] ]
combined_xss_pattern = re.compile('|'.join(xss_patterns), re.IGNORECASE) xss_pattern = re.compile('|'.join(xss_patterns), re.IGNORECASE)
combined_sql_pattern = re.compile('|'.join(sql_patterns), re.IGNORECASE) sql_pattern = re.compile('|'.join(sql_patterns), re.IGNORECASE)
combined_webshells_pattern = re.compile('|'.join(webshells_patterns), re.IGNORECASE) webshell_pattern = re.compile('|'.join(webshells_patterns), re.IGNORECASE)
try:
with open(log_file_path, 'r') as log_file: with open(log_file_path, 'r') as log_file:
log_lines = log_file.readlines() log_lines = log_file.readlines()
for line in log_lines: for line in log_lines:
if " 403 " in line: # Check if the line contains " 403 " indicating a 403 status code if not line.strip():
match = re.search(r'(\w+\s+\d+\s\d+:\d+:\d+).*\s(\d+\.\d+\.\d+\.\d+).*"\s*(GET|POST|PUT|DELETE)\s+([^"]+)"', line) continue
if match:
timestamp = match.group(1) # Extract the date and time match = re.search(
r'(\w+\s+\d+\s\d+:\d+:\d+).*\s(\d+\.\d+\.\d+\.\d+).*"?\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([^"\s]+)"?\s+(\d{3})',
line
)
if not match:
continue
timestamp = match.group(1)
ip_address = match.group(2) ip_address = match.group(2)
http_method = match.group(3) http_method = match.group(3)
requested_url = match.group(4) requested_url = match.group(4)
status_code = int(match.group(5))
if combined_xss_pattern.search(line): threats = []
xss_alert = 'Possible XSS Attack Was Identified.' threat_level = 'info'
else:
xss_alert = ''
if combined_sql_pattern.search(line):
sql_alert = 'Possible SQL Injection Attempt Was Made.'
else:
sql_alert = ''
if "PUT" in line:
put_method = 'Possible Remote File Upload Attempt Was Made.'
else:
put_method = ''
if "admin" in line: if xss_pattern.search(line):
illegal_resource = 'Possible Illegal Resource Access Attempt Was Made.' threats.append('XSS Attack')
else: threat_level = 'danger'
illegal_resource = ''
if combined_webshells_pattern.search(line): if sql_pattern.search(line):
webshell_alert = 'Possible WebShell Attack Attempt Was Made.' threats.append('SQL Injection')
else: threat_level = 'danger'
webshell_alert = ''
if webshell_pattern.search(line):
threats.append('Webshell')
threat_level = 'danger'
if http_method == 'PUT':
threats.append('Remote Upload')
threat_level = 'warning'
if 'admin' in requested_url.lower() or 'config' in requested_url.lower():
if status_code == 403:
threats.append('Unauthorized Access')
threat_level = 'warning'
status_category = 'info'
if 200 <= status_code < 300:
status_category = 'success'
elif 300 <= status_code < 400:
status_category = 'secondary'
elif 400 <= status_code < 500:
status_category = 'warning'
elif status_code >= 500:
status_category = 'danger'
parsed_entries.append({ parsed_entries.append({
'timestamp': timestamp, 'timestamp': timestamp,
'ip_address': ip_address, 'ip_address': ip_address,
'http_method': http_method, 'http_method': http_method,
'requested_url': requested_url, 'requested_url': requested_url,
'xss_alert': xss_alert, 'status_code': status_code,
'sql_alert': sql_alert, 'status_category': status_category,
'put_method': put_method, 'threats': threats if threats else ['None'],
'illegal_resource': illegal_resource, 'threat_level': threat_level if threats else 'info',
'webshell_alert': webshell_alert 'is_threat': bool(threats),
}) })
except FileNotFoundError:
return [{'error': f'Log file not found: {log_file_path}'}]
except Exception as e:
return [{'error': f'Error parsing log: {str(e)}'}]
return parsed_entries return parsed_entries
def get_log_statistics(parsed_entries):
stats = {
'total_requests': len(parsed_entries),
'threat_count': sum(1 for e in parsed_entries if e.get('is_threat')),
'status_codes': defaultdict(int),
'http_methods': defaultdict(int),
'top_ips': defaultdict(int),
'threat_types': defaultdict(int),
}
for entry in parsed_entries:
if 'error' in entry:
continue
stats['status_codes'][entry['status_code']] += 1
stats['http_methods'][entry['http_method']] += 1
stats['top_ips'][entry['ip_address']] += 1
for threat in entry.get('threats', []):
if threat != 'None':
stats['threat_types'][threat] += 1
stats['top_ips'] = sorted(
stats['top_ips'].items(),
key=lambda x: x[1],
reverse=True
)[:5]
stats['status_codes'] = dict(stats['status_codes'])
stats['http_methods'] = dict(stats['http_methods'])
stats['threat_types'] = dict(stats['threat_types'])
return stats
def filter_logs(parsed_entries, filters=None):
if not filters:
return parsed_entries
filtered = parsed_entries
if 'status_code' in filters and filters['status_code']:
filtered = [e for e in filtered if e.get('status_code') == int(filters['status_code'])]
if 'threat_level' in filters and filters['threat_level']:
filtered = [e for e in filtered if e.get('threat_level') == filters['threat_level']]
if 'http_method' in filters and filters['http_method']:
filtered = [e for e in filtered if e.get('http_method') == filters['http_method']]
if 'ip_address' in filters and filters['ip_address']:
filtered = [e for e in filtered if e.get('ip_address') == filters['ip_address']]
if 'has_threat' in filters and filters['has_threat']:
filtered = [e for e in filtered if e.get('is_threat')]
return filtered

View File

@@ -0,0 +1,22 @@
document.getElementById('filter_status')?.addEventListener('change', filterLogs);
document.getElementById('filter_threat')?.addEventListener('change', filterLogs);
document.getElementById('filter_method')?.addEventListener('change', filterLogs);
document.getElementById('filter_threats_only')?.addEventListener('change', filterLogs);
function filterLogs() {
const statusFilter = document.getElementById('filter_status')?.value;
const threatFilter = document.getElementById('filter_threat')?.value;
const methodFilter = document.getElementById('filter_method')?.value;
const threatsOnly = document.getElementById('filter_threats_only')?.checked;
document.querySelectorAll('.log-row').forEach(row => {
let show = true;
if (statusFilter && row.dataset.status !== statusFilter) show = false;
if (threatFilter && row.dataset.threat !== threatFilter) show = false;
if (methodFilter && row.dataset.method !== methodFilter) show = false;
if (threatsOnly && row.dataset.threatCount === '0') show = false;
row.style.display = show ? '' : 'none';
});
}

View File

@@ -1,50 +1,162 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "" %}
{% block title %}HAProxy • Logs{% endblock %} {% set active_page = "logs" %}
{% block breadcrumb %}<nav aria-label="breadcrumb" class="mb-3"><ol class="breadcrumb mb-0"><li class="breadcrumb-item"><a href="{{ url_for('main.index') }}"><i class="bi bi-house"></i></a></li><li class="breadcrumb-item active" aria-current="page">Logi</li></ol></nav>{% endblock %}
{% block title %}HAProxy • Access Logs{% endblock %}
{% block breadcrumb %}Access Logs{% endblock %}
{% block content %} {% block content %}
<h3 class="mb-4" id="status_header">Status 403 Forbidden</h3>
{% if entries %} <div class="card shadow-sm mb-4">
<div class="vstack gap-3"> <div class="card-header bg-info text-white">
{% for entry in entries %} <h5 class="mb-0"><i class="bi bi-file-text me-2"></i>HAProxy Access Logs & Security Analysis</h5>
<div class="card"> </div>
<div class="card-body"> <div class="card-body">
{% if logs %}
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h6 class="text-muted">Total Requests</h6>
<div class="fs-3 fw-bold text-primary">{{ logs|length }}</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h6 class="text-muted">Threats Detected</h6>
<div class="fs-3 fw-bold text-danger">
{{ logs|selectattr('is_threat')|list|length }}
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h6 class="text-muted">Unique IPs</h6>
<div class="fs-3 fw-bold text-warning">
{{ logs|map(attribute='ip_address')|unique|list|length }}
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h6 class="text-muted">Success Rate</h6>
<div class="fs-3 fw-bold text-success">
{% set success_count = logs|selectattr('status_code')|selectattr('status_code', 'ge', 200)|selectattr('status_code', 'lt', 300)|list|length %}
{{ ((success_count / logs|length * 100)|round(1)) if logs else 0 }}%
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card mb-4 bg-light">
<div class="card-body">
<h6 class="mb-3"><i class="bi bi-funnel me-2"></i>Filters</h6>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-3">
<div><strong>Czas:</strong> {{ entry['timestamp'] }}</div> <label class="form-label small">Status Code</label>
<div><strong>IP:</strong> {{ entry['ip_address'] }}</div> <select class="form-select form-select-sm" id="filter_status">
<div><strong>Metoda:</strong> {{ entry['http_method'] }}</div> <option value="">All</option>
<div><strong>URL:</strong> {{ entry['requested_url'] }}</div> {% for log in logs %}
<div><strong>Status:</strong> <span class="badge bg-danger">403</span></div> <option value="{{ log.status_code }}">{{ log.status_code }}</option>
</div>
<div class="col-md-6">
{% if entry['xss_alert'] %}
<p class="mb-1"><button class="btn btn-sm btn-outline-danger" data-bs-toggle="collapse" data-bs-target="#xssCollapse{{ loop.index }}"><i class="bi bi-bug"></i> XSS alert</button></p>
<div id="xssCollapse{{ loop.index }}" class="collapse"><pre class="mb-0 text-danger">{{ entry['xss_alert'] }}</pre></div>
{% endif %}
{% if entry['sql_alert'] %}
<p class="mb-1"><button class="btn btn-sm btn-outline-warning" data-bs-toggle="collapse" data-bs-target="#sqlCollapse{{ loop.index }}"><i class="bi bi-database-exclamation"></i> SQLi alert</button></p>
<div id="sqlCollapse{{ loop.index }}" class="collapse"><pre class="mb-0 text-warning">{{ entry['sql_alert'] }}</pre></div>
{% endif %}
{% if entry['put_method'] %}
<p class="mb-1"><button class="btn btn-sm btn-outline-info" data-bs-toggle="collapse" data-bs-target="#putCollapse{{ loop.index }}"><i class="bi bi-upload"></i> PUT alert</button></p>
<div id="putCollapse{{ loop.index }}" class="collapse"><pre class="mb-0 text-info">{{ entry['put_method'] }}</pre></div>
{% endif %}
{% if entry['illegal_resource'] %}
<p class="mb-1"><button class="btn btn-sm btn-outline-light" data-bs-toggle="collapse" data-bs-target="#illegalCollapse{{ loop.index }}"><i class="bi bi-shield-x"></i> Nielegalny zasób</button></p>
<div id="illegalCollapse{{ loop.index }}" class="collapse"><pre class="mb-0 text-light">{{ entry['illegal_resource'] }}</pre></div>
{% endif %}
{% if entry['webshell_alert'] %}
<p class="mb-1"><button class="btn btn-sm btn-outline-danger" data-bs-toggle="collapse" data-bs-target="#webshellCollapse{{ loop.index }}"><i class="bi bi-file-earmark-code"></i> WebShell alert</button></p>
<div id="webshellCollapse{{ loop.index }}" class="collapse"><pre class="mb-0 text-danger">{{ entry['webshell_alert'] }}</pre></div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %} {% endfor %}
</select>
</div> </div>
<div class="col-md-3">
<label class="form-label small">Threat Level</label>
<select class="form-select form-select-sm" id="filter_threat">
<option value="">All</option>
<option value="danger">Danger</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small">HTTP Method</label>
<select class="form-select form-select-sm" id="filter_method">
<option value="">All</option>
{% set methods = logs|map(attribute='http_method')|unique %}
{% for method in methods %}
<option value="{{ method }}">{{ method }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Show Threats Only</label>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="filter_threats_only">
<label class="form-check-label" for="filter_threats_only">
Threats
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Logs Table -->
<div class="table-responsive">
<table class="table table-striped table-hover table-sm">
<thead class="table-dark">
<tr>
<th>Timestamp</th>
<th>IP Address</th>
<th>Method</th>
<th>URL</th>
<th>Status</th>
<th>Threats</th>
</tr>
</thead>
<tbody id="logs_table_body">
{% for log in logs %}
<tr class="log-row" data-status="{{ log.status_code }}" data-threat="{{ log.threat_level }}" data-method="{{ log.http_method }}" data-threat-count="{{ 1 if log.is_threat else 0 }}">
<td class="small">{{ log.timestamp }}</td>
<td>
<span class="badge bg-secondary">{{ log.ip_address }}</span>
</td>
<td>
<span class="badge bg-primary">{{ log.http_method }}</span>
</td>
<td class="text-truncate" style="max-width: 300px;" title="{{ log.requested_url }}">
{{ log.requested_url }}
</td>
<td>
<span class="badge bg-{{ log.status_category }}">{{ log.status_code }}</span>
</td>
<td>
{% if log.is_threat %}
{% for threat in log.threats %}
<span class="badge bg-{{ log.threat_level }} me-1">{{ threat }}</span>
{% endfor %}
{% else %} {% else %}
<div class="alert alert-info"><i class="bi bi-info-circle me-1"></i>No data.</div> <span class="text-muted small"></span>
{% endif %} {% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>No log entries found.
</div>
{% endif %}
</div>
</div>
<script src="{{ url_for('static', filename='js/logs.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -14,7 +14,6 @@ def fetch_haproxy_stats():
def parse_haproxy_stats(stats_data): def parse_haproxy_stats(stats_data):
data = [] data = []
# Skip empty lines and get header
lines = [line for line in stats_data.splitlines() if line.strip()] lines = [line for line in stats_data.splitlines() if line.strip()]
if not lines: if not lines:
return data return data
@@ -23,17 +22,14 @@ def parse_haproxy_stats(stats_data):
# Parse CSV # Parse CSV
reader = csv.DictReader(lines, fieldnames=header_row.split(',')) reader = csv.DictReader(lines, fieldnames=header_row.split(','))
next(reader) # Skip header next(reader)
for row in reader: for row in reader:
# Only process servers, skip BACKEND summary rows
if row.get('svname') == 'BACKEND': if row.get('svname') == 'BACKEND':
continue continue
# Strip whitespace from values
row = {k: v.strip() if isinstance(v, str) else v for k, v in row.items()} row = {k: v.strip() if isinstance(v, str) else v for k, v in row.items()}
# Safe conversion to int/float
try: try:
conn_tot = int(row.get('conn_tot', 0) or 0) conn_tot = int(row.get('conn_tot', 0) or 0)
except (ValueError, TypeError): except (ValueError, TypeError):
@@ -51,13 +47,13 @@ def parse_haproxy_stats(stats_data):
try: try:
bin_bytes = float(row.get('bin', 0) or 0) bin_bytes = float(row.get('bin', 0) or 0)
bytes_in_mb = bin_bytes / (1024 * 1024) # ✅ FLOAT, nie string! bytes_in_mb = bin_bytes / (1024 * 1024)
except (ValueError, TypeError): except (ValueError, TypeError):
bytes_in_mb = 0.0 bytes_in_mb = 0.0
try: try:
bout_bytes = float(row.get('bout', 0) or 0) bout_bytes = float(row.get('bout', 0) or 0)
bytes_out_mb = bout_bytes / (1024 * 1024) # ✅ FLOAT, nie string! bytes_out_mb = bout_bytes / (1024 * 1024)
except (ValueError, TypeError): except (ValueError, TypeError):
bytes_out_mb = 0.0 bytes_out_mb = 0.0
@@ -66,9 +62,9 @@ def parse_haproxy_stats(stats_data):
'server_name': row.get('svname', 'Unknown'), 'server_name': row.get('svname', 'Unknown'),
'4xx_errors': hrsp_4xx, '4xx_errors': hrsp_4xx,
'5xx_errors': hrsp_5xx, '5xx_errors': hrsp_5xx,
'bytes_in_mb': bytes_in_mb, # ✅ Float 'bytes_in_mb': bytes_in_mb,
'bytes_out_mb': bytes_out_mb, # ✅ Float 'bytes_out_mb': bytes_out_mb,
'conn_tot': conn_tot, # ✅ Int 'conn_tot': conn_tot,
}) })
return data return data