From 02aed9e65abffd19361b63067d6936e6a4400c32 Mon Sep 17 00:00:00 2001 From: Patrick Di Fazio Date: Tue, 6 Jan 2026 18:50:36 +0100 Subject: [PATCH] added drop down menu and scoring graph to the dashboard --- src/database.py | 39 ++- src/handler.py | 27 ++ src/templates/dashboard_template.py | 398 +++++++++++++++++++++++++++- src/templates/html/main_page.html | 13 +- 4 files changed, 455 insertions(+), 22 deletions(-) diff --git a/src/database.py b/src/database.py index 9d8e444..e60348a 100644 --- a/src/database.py +++ b/src/database.py @@ -256,7 +256,7 @@ class DatabaseManager: """ session = self.session - + sanitized_ip = sanitize_ip(ip) ip_stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() ip_stats.category = category @@ -439,6 +439,43 @@ class DatabaseManager: finally: self.close_session() + def get_ip_stats_by_ip(self, ip: str) -> Optional[Dict[str, Any]]: + """ + Retrieve IP statistics for a specific IP address. + + Args: + ip: The IP address to look up + + Returns: + Dictionary with IP stats or None if not found + """ + session = self.session + try: + stat = session.query(IpStats).filter(IpStats.ip == ip).first() + + if not stat: + return None + + return { + 'ip': stat.ip, + 'total_requests': stat.total_requests, + 'first_seen': stat.first_seen.isoformat() if stat.first_seen else None, + 'last_seen': stat.last_seen.isoformat() if stat.last_seen else None, + 'country_code': stat.country_code, + 'city': stat.city, + 'asn': stat.asn, + 'asn_org': stat.asn_org, + 'reputation_score': stat.reputation_score, + 'reputation_source': stat.reputation_source, + 'analyzed_metrics': stat.analyzed_metrics or {}, + 'category': stat.category, + 'category_scores': stat.category_scores or {}, + 'manual_category': stat.manual_category, + 'last_analysis': stat.last_analysis.isoformat() if stat.last_analysis else None + } + finally: + self.close_session() + def get_dashboard_counts(self) -> Dict[str, int]: """ Get aggregate statistics for the dashboard. diff --git a/src/handler.py b/src/handler.py index eef528d..2598706 100644 --- a/src/handler.py +++ b/src/handler.py @@ -413,6 +413,33 @@ class Handler(BaseHTTPRequestHandler): except Exception as e: self.app_logger.error(f"Error generating dashboard: {e}") return + + # API endpoint for fetching IP stats + if self.config.dashboard_secret_path and self.path.startswith(f"{self.config.dashboard_secret_path}/api/ip-stats/"): + ip_address = self.path.replace(f"{self.config.dashboard_secret_path}/api/ip-stats/", "") + self.send_response(200) + self.send_header('Content-type', 'application/json') + self.send_header('Access-Control-Allow-Origin', '*') + # Prevent browser caching - force fresh data from database every time + self.send_header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0') + self.send_header('Pragma', 'no-cache') + self.send_header('Expires', '0') + self.end_headers() + try: + from database import get_database + import json + db = get_database() + ip_stats = db.get_ip_stats_by_ip(ip_address) + if ip_stats: + self.wfile.write(json.dumps(ip_stats).encode()) + else: + self.wfile.write(json.dumps({'error': 'IP not found'}).encode()) + except BrokenPipeError: + pass + except Exception as e: + self.app_logger.error(f"Error fetching IP stats: {e}") + self.wfile.write(json.dumps({'error': str(e)}).encode()) + return self.tracker.record_access(client_ip, self.path, user_agent, method='GET') diff --git a/src/templates/dashboard_template.py b/src/templates/dashboard_template.py index dfad3dd..df0378a 100644 --- a/src/templates/dashboard_template.py +++ b/src/templates/dashboard_template.py @@ -27,9 +27,20 @@ def format_timestamp(iso_timestamp: str) -> str: def generate_dashboard(stats: dict) -> str: """Generate dashboard HTML with access statistics""" - # Generate IP rows (IPs are generally safe but escape for consistency) + # Generate IP rows with clickable functionality for dropdown stats top_ips_rows = '\n'.join([ - f'{i+1}{_escape(ip)}{count}' + f''' + {i+1} + {_escape(ip)} + {count} + + + +
+
Loading stats...
+
+ + ''' for i, (ip, count) in enumerate(stats['top_ips']) ]) or 'No data' @@ -45,27 +56,76 @@ def generate_dashboard(stats: dict) -> str: for i, (ua, count) in enumerate(stats['top_user_agents']) ]) or 'No data' - # Generate suspicious accesses rows (CRITICAL: multiple user-controlled fields) + # Generate suspicious accesses rows with clickable IPs suspicious_rows = '\n'.join([ - f'{_escape(log["ip"])}{_escape(log["path"])}{_escape(log["user_agent"][:60])}{_escape(log["timestamp"].split("T")[1][:8])}' + f''' + {_escape(log["ip"])} + {_escape(log["path"])} + {_escape(log["user_agent"][:60])} + {_escape(log["timestamp"].split("T")[1][:8])} + + + +
+
Loading stats...
+
+ + ''' for log in stats['recent_suspicious'][-10:] ]) or 'No suspicious activity detected' - # Generate honeypot triggered IPs rows + # Generate honeypot triggered IPs rows with clickable IPs honeypot_rows = '\n'.join([ - f'{_escape(ip)}{_escape(", ".join(paths))}{len(paths)}' + f''' + {_escape(ip)} + {_escape(", ".join(paths))} + {len(paths)} + + + +
+
Loading stats...
+
+ + ''' for ip, paths in stats.get('honeypot_triggered_ips', []) ]) or 'No honeypot triggers yet' - # Generate attack types rows (CRITICAL: paths and user agents are user-controlled) + # Generate attack types rows with clickable IPs attack_type_rows = '\n'.join([ - f'{_escape(log["ip"])}{_escape(log["path"])}{_escape(", ".join(log["attack_types"]))}{_escape(log["user_agent"][:60])}{_escape(log["timestamp"].split("T")[1][:8])}' + f''' + {_escape(log["ip"])} + {_escape(log["path"])} + {_escape(", ".join(log["attack_types"]))} + {_escape(log["user_agent"][:60])} + {_escape(log["timestamp"].split("T")[1][:8])} + + + +
+
Loading stats...
+
+ + ''' for log in stats.get('attack_types', [])[-10:] ]) or 'No attacks detected' - # Generate credential attempts rows (CRITICAL: usernames and passwords are user-controlled) + # Generate credential attempts rows with clickable IPs credential_rows = '\n'.join([ - f'{_escape(log["ip"])}{_escape(log["username"])}{_escape(log["password"])}{_escape(log["path"])}{_escape(log["timestamp"].split("T")[1][:8])}' + f''' + {_escape(log["ip"])} + {_escape(log["username"])} + {_escape(log["password"])} + {_escape(log["path"])} + {_escape(log["timestamp"].split("T")[1][:8])} + + + +
+
Loading stats...
+
+ + ''' for log in stats.get('credential_attempts', [])[-20:] ]) or 'No credentials captured yet' @@ -180,6 +240,119 @@ def generate_dashboard(stats: dict) -> str: content: '▼'; opacity: 1; }} + .ip-row {{ + transition: background-color 0.2s; + }} + .ip-clickable {{ + cursor: pointer; + color: #58a6ff !important; + font-weight: 500; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 3px; + }} + .ip-clickable:hover {{ + color: #79c0ff !important; + text-decoration-style: solid; + background: #1c2128; + }} + .ip-stats-row {{ + background: #0d1117; + }} + .ip-stats-cell {{ + padding: 0 !important; + }} + .ip-stats-dropdown {{ + margin-top: 10px; + padding: 15px; + background: #0d1117; + border: 1px solid #30363d; + border-radius: 6px; + font-size: 13px; + display: flex; + gap: 20px; + }} + .stats-left {{ + flex: 1; + }} + .stats-right {{ + flex: 0 0 200px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + }} + .radar-chart {{ + position: relative; + width: 180px; + height: 180px; + overflow: visible; + }} + .radar-legend {{ + margin-top: 10px; + font-size: 11px; + }} + .radar-legend-item {{ + display: flex; + align-items: center; + gap: 6px; + margin: 3px 0; + }} + .radar-legend-color {{ + width: 12px; + height: 12px; + border-radius: 2px; + }} + .ip-stats-dropdown .loading {{ + color: #8b949e; + font-style: italic; + }} + .stat-row {{ + display: flex; + justify-content: space-between; + padding: 5px 0; + border-bottom: 1px solid #21262d; + }} + .stat-row:last-child {{ + border-bottom: none; + }} + .stat-label-sm {{ + color: #8b949e; + font-weight: 500; + }} + .stat-value-sm {{ + color: #58a6ff; + font-weight: 600; + }} + .category-badge {{ + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + }} + .category-attacker {{ + background: #f851491a; + color: #f85149; + border: 1px solid #f85149; + }} + .category-good-crawler {{ + background: #3fb9501a; + color: #3fb950; + border: 1px solid #3fb950; + }} + .category-bad-crawler {{ + background: #f0883e1a; + color: #f0883e; + border: 1px solid #f0883e; + }} + .category-regular-user {{ + background: #58a6ff1a; + color: #58a6ff; + border: 1px solid #58a6ff; + }} + @@ -387,6 +560,211 @@ def generate_dashboard(stats: dict) -> str: rows.forEach(row => tbody.appendChild(row)); }}); }}); + + // IP stats dropdown functionality + document.querySelectorAll('.ip-clickable').forEach(cell => {{ + cell.addEventListener('click', async function(e) {{ + const row = e.currentTarget.closest('.ip-row'); + if (!row) return; + + const ip = row.getAttribute('data-ip'); + const statsRow = row.nextElementSibling; + if (!statsRow || !statsRow.classList.contains('ip-stats-row')) return; + + const isVisible = getComputedStyle(statsRow).display !== 'none'; + + document.querySelectorAll('.ip-stats-row').forEach(r => {{ + r.style.display = 'none'; + }}); + + if (isVisible) return; + + statsRow.style.display = 'table-row'; + + const dropdown = statsRow.querySelector('.ip-stats-dropdown'); + + // Always fetch fresh data from database + if (dropdown) {{ + dropdown.innerHTML = '
Loading stats...
'; + try {{ + const response = await fetch(`${{window.location.pathname}}/api/ip-stats/${{ip}}`, {{ + cache: 'no-store', + headers: {{ + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache' + }} + }}); + if (!response.ok) throw new Error(`HTTP ${{response.status}}`); + + const data = await response.json(); + dropdown.innerHTML = data.error + ? `
Error: ${{data.error}}
` + : formatIpStats(data); + }} catch (err) {{ + dropdown.innerHTML = `
Failed to load stats: ${{err.message}}
`; + }} + }} + }}); + }}); + + function formatIpStats(stats) {{ + let html = '
'; + + // Basic info + html += '
'; + html += 'Total Requests:'; + html += `${{stats.total_requests || 0}}`; + html += '
'; + + html += '
'; + html += 'First Seen:'; + html += `${{stats.first_seen ? new Date(stats.first_seen).toLocaleString() : 'N/A'}}`; + html += '
'; + + html += '
'; + html += 'Last Seen:'; + html += `${{stats.last_seen ? new Date(stats.last_seen).toLocaleString() : 'N/A'}}`; + html += '
'; + + // Category + if (stats.category) {{ + html += '
'; + html += 'Category:'; + const categoryClass = 'category-' + stats.category.toLowerCase().replace('_', '-'); + html += `${{stats.category}}`; + html += '
'; + }} + + // GeoIP info if available + if (stats.country_code || stats.city) {{ + html += '
'; + html += 'Location:'; + html += `${{stats.city || ''}}${{stats.city && stats.country_code ? ', ' : ''}}${{stats.country_code || 'Unknown'}}`; + html += '
'; + }} + + if (stats.asn_org) {{ + html += '
'; + html += 'ASN Org:'; + html += `${{stats.asn_org}}`; + html += '
'; + }} + + // Reputation score if available + if (stats.reputation_score !== null && stats.reputation_score !== undefined) {{ + html += '
'; + html += 'Reputation Score:'; + html += `${{stats.reputation_score}} ${{stats.reputation_source ? '(' + stats.reputation_source + ')' : ''}}`; + html += '
'; + }} + + html += '
'; + + // Radar chart on the right + if (stats.category_scores && Object.keys(stats.category_scores).length > 0) {{ + html += '
'; + html += ''; + + const scores = {{ + attacker: stats.category_scores.attacker || 0, + good_crawler: stats.category_scores.good_crawler || 0, + bad_crawler: stats.category_scores.bad_crawler || 0, + regular_user: stats.category_scores.regular_user || 0 + }}; + + // Normalize scores for better visualization + const maxScore = Math.max(...Object.values(scores), 1); + const minVisibleRadius = 0.15; // Minimum 15% visibility even for 0 values + const normalizedScores = {{}}; + + Object.keys(scores).forEach(key => {{ + // Scale values: ensure minimum visibility + proportional to max + normalizedScores[key] = minVisibleRadius + (scores[key] / maxScore) * (1 - minVisibleRadius); + }}); + + const colors = {{ + attacker: '#f85149', + good_crawler: '#3fb950', + bad_crawler: '#f0883e', + regular_user: '#58a6ff' + }}; + + const labels = {{ + attacker: 'Attacker', + good_crawler: 'Good Bot', + bad_crawler: 'Bad Bot', + regular_user: 'User' + }}; + + // Draw radar background grid + const cx = 100, cy = 100, maxRadius = 75; + for (let i = 1; i <= 5; i++) {{ + const r = (maxRadius / 5) * i; + html += ``; + }} + + // Draw axes + const angles = [0, 90, 180, 270]; + const keys = ['attacker', 'good_crawler', 'bad_crawler', 'regular_user']; + + angles.forEach((angle, i) => {{ + const rad = (angle - 90) * Math.PI / 180; + const x2 = cx + maxRadius * Math.cos(rad); + const y2 = cy + maxRadius * Math.sin(rad); + html += ``; + + // Add labels + const labelDist = maxRadius + 30; + const lx = cx + labelDist * Math.cos(rad); + const ly = cy + labelDist * Math.sin(rad); + html += `${{labels[keys[i]]}}`; + }}); + + // Draw filled polygon for scores + let points = []; + angles.forEach((angle, i) => {{ + const normalizedScore = normalizedScores[keys[i]]; + const rad = (angle - 90) * Math.PI / 180; + const r = normalizedScore * maxRadius; + const x = cx + r * Math.cos(rad); + const y = cy + r * Math.sin(rad); + points.push(`${{x}},${{y}}`); + }}); + + // Determine dominant category color + const dominantKey = Object.keys(scores).reduce((a, b) => scores[a] > scores[b] ? a : b); + const dominantColor = colors[dominantKey]; + + // Draw single colored area + html += ``; + + // Draw points + angles.forEach((angle, i) => {{ + const normalizedScore = normalizedScores[keys[i]]; + const rad = (angle - 90) * Math.PI / 180; + const r = normalizedScore * maxRadius; + const x = cx + r * Math.cos(rad); + const y = cy + r * Math.sin(rad); + html += ``; + }}); + + html += ''; + + // Legend + html += '
'; + keys.forEach(key => {{ + html += '
'; + html += `
`; + html += `${{labels[key]}}: ${{scores[key]}}%`; + html += '
'; + }}); + html += '
'; + + html += '
'; + }} + + return html; + }} diff --git a/src/templates/html/main_page.html b/src/templates/html/main_page.html index d0b39de..ac154e8 100644 --- a/src/templates/html/main_page.html +++ b/src/templates/html/main_page.html @@ -46,21 +46,12 @@ gap: 10px; align-items: center; overflow-y: auto; + overflow-x: hidden; flex: 1; padding-top: 10px; }} .links-container::-webkit-scrollbar {{ - width: 8px; - }} - .links-container::-webkit-scrollbar-track {{ - background: #0d1117; - }} - .links-container::-webkit-scrollbar-thumb {{ - background: #30363d; - border-radius: 4px; - }} - .links-container::-webkit-scrollbar-thumb:hover {{ - background: #484f58; + width: 0px; }} .link-box {{ background: #161b22;