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} |
+
+
+ |
+
+ |
+
'''
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])} |
+
+
+ |
+
+ |
+
'''
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)} |
+
+
+ |
+
+ |
+
'''
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])} |
+
+
+ |
+
+ |
+
'''
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])} |
+
+
+ |
+
+ |
+
'''
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 += '
';
+
+ // Legend
+ html += '
';
+ keys.forEach(key => {{
+ html += '
';
+ html += `
`;
+ html += `
${{labels[key]}}: ${{scores[key]}}%`;
+ html += '
';
+ }});
+ html += '
';
+
+ html += '
';
+ }}
+
+ return html;
+ }}