Files
baffle-hub/app/controllers/analytics_controller.rb
2025-11-10 14:10:37 +11:00

314 lines
13 KiB
Ruby

# frozen_string_literal: true
# AnalyticsController - Overview dashboard with statistics and charts
class AnalyticsController < ApplicationController
# All actions require authentication
def index
authorize :analytics, :index?
# Time period selector (default: last 24 hours)
@time_period = params[:period]&.to_sym || :day
@start_time = calculate_start_time(@time_period)
# Core statistics
@total_events = Event.where("timestamp >= ?", @start_time).count
@total_rules = Rule.enabled.count
@network_ranges_with_events = NetworkRange.with_events.count
@total_network_ranges = NetworkRange.count
# Event breakdown by action
@event_breakdown = Event.where("timestamp >= ?", @start_time)
.group(:waf_action)
.count
.transform_keys do |action_id|
case action_id
when 0 then 'allow'
when 1 then 'deny'
when 2 then 'redirect'
when 3 then 'challenge'
else 'unknown'
end
end
# Top countries by event count
@top_countries = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.where("timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time)
.group("network_ranges.country")
.count
.sort_by { |_, count| -count }
.first(10)
# Top blocked IPs
@top_blocked_ips = Event.where("timestamp >= ?", @start_time)
.where(waf_action: 1) # deny action in enum
.group(:ip_address)
.count
.sort_by { |_, count| -count }
.first(10)
# Network range intelligence breakdown
@network_intelligence = {
datacenter_ranges: NetworkRange.datacenter.count,
vpn_ranges: NetworkRange.vpn.count,
proxy_ranges: NetworkRange.proxy.count,
total_ranges: NetworkRange.count
}
# Recent activity
@recent_events = Event.recent.limit(10)
@recent_rules = Rule.order(created_at: :desc).limit(5)
# System health indicators
@system_health = {
total_users: User.count,
active_rules: Rule.enabled.count,
disabled_rules: Rule.where(enabled: false).count,
recent_errors: Event.where("timestamp >= ? AND waf_action = ?", @start_time, 1).count # 1 = deny
}
# Prepare data for charts
@chart_data = prepare_chart_data
respond_to do |format|
format.html
format.turbo_stream
end
end
def networks
authorize :analytics, :index?
# Time period selector (default: last 24 hours)
@time_period = params[:period]&.to_sym || :day
@start_time = calculate_start_time(@time_period)
# Top networks by request volume
@top_networks = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? OR events.timestamp IS NULL", @start_time)
.group("network_ranges.id", "network_ranges.network", "network_ranges.company", "network_ranges.asn", "network_ranges.country", "network_ranges.is_datacenter", "network_ranges.is_vpn", "network_ranges.is_proxy")
.select("network_ranges.*, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips")
.order("event_count DESC")
.limit(50)
# Network type breakdown with traffic stats
@network_breakdown = calculate_network_type_stats(@start_time)
# Company breakdown for top traffic sources
@top_companies = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? AND network_ranges.company IS NOT NULL", @start_time)
.group("network_ranges.company")
.select("network_ranges.company, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count")
.order("event_count DESC")
.limit(20)
# ASN breakdown
@top_asns = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? AND network_ranges.asn IS NOT NULL", @start_time)
.group("network_ranges.asn", "network_ranges.asn_org")
.select("network_ranges.asn, network_ranges.asn_org, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count")
.order("event_count DESC")
.limit(15)
# Geographic breakdown
@top_countries = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time)
.group("network_ranges.country")
.select("network_ranges.country, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count")
.order("event_count DESC")
.limit(15)
# Suspicious network activity patterns
@suspicious_patterns = calculate_suspicious_patterns(@start_time)
respond_to do |format|
format.html
format.json { render json: network_analytics_json }
end
end
private
def calculate_start_time(period)
case period
when :hour
1.hour.ago
when :day
24.hours.ago
when :week
1.week.ago
when :month
1.month.ago
else
24.hours.ago
end
end
def prepare_chart_data
# Events over time (hourly buckets for last 24 hours)
events_by_hour = Event.where("timestamp >= ?", 24.hours.ago)
.group("DATE_TRUNC('hour', timestamp)")
.count
# Convert to chart format - keep everything in UTC for consistency
timeline_data = (0..23).map do |hour_ago|
hour_time = hour_ago.hours.ago
hour_key = hour_time.utc.beginning_of_hour
{
# Store as ISO string for JavaScript to handle timezone conversion
time_iso: hour_time.iso8601,
total: events_by_hour[hour_key] || 0
}
end
# Action distribution for pie chart
action_distribution = @event_breakdown.map do |action, count|
{
action: action.humanize,
count: count,
percentage: ((count.to_f / [@total_events, 1].max) * 100).round(1)
}
end
{
timeline: timeline_data,
actions: action_distribution,
countries: @top_countries.map { |country, count| { country: country, count: count } },
network_types: [
{ type: "Datacenter", count: @network_intelligence[:datacenter_ranges] },
{ type: "VPN", count: @network_intelligence[:vpn_ranges] },
{ type: "Proxy", count: @network_intelligence[:proxy_ranges] },
{ type: "Standard", count: @network_intelligence[:total_ranges] - @network_intelligence[:datacenter_ranges] - @network_intelligence[:vpn_ranges] - @network_intelligence[:proxy_ranges] }
]
}
end
def calculate_network_type_stats(start_time)
# Get all network types with their traffic statistics
network_types = [
{ type: 'datacenter', label: 'Datacenter' },
{ type: 'vpn', label: 'VPN' },
{ type: 'proxy', label: 'Proxy' }
]
results = {}
total_events = Event.where("timestamp >= ?", start_time).count
network_types.each do |network_type|
scope = case network_type[:type]
when 'datacenter' then NetworkRange.datacenter
when 'vpn' then NetworkRange.vpn
when 'proxy' then NetworkRange.proxy
end
if scope
network_stats = scope.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? OR events.timestamp IS NULL", start_time)
.select("COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count")
.first
results[network_type[:type]] = {
label: network_type[:label],
networks: network_stats.network_count,
events: network_stats.event_count,
unique_ips: network_stats.unique_ips,
percentage: total_events > 0 ? ((network_stats.event_count.to_f / total_events) * 100).round(1) : 0
}
end
end
# Calculate standard networks (everything else)
standard_stats = NetworkRange.where(is_datacenter: false, is_vpn: false, is_proxy: false)
.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? OR events.timestamp IS NULL", start_time)
.select("COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count")
.first
results['standard'] = {
label: 'Standard',
networks: standard_stats.network_count,
events: standard_stats.event_count,
unique_ips: standard_stats.unique_ips,
percentage: total_events > 0 ? ((standard_stats.event_count.to_f / total_events) * 100).round(1) : 0
}
results
end
def calculate_suspicious_patterns(start_time)
patterns = {}
# High volume networks (top 1% by request count)
total_networks = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ?", start_time)
.distinct.count
high_volume_threshold = [total_networks * 0.01, 1].max
high_volume_networks = NetworkRange.joins("INNER JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ?", start_time)
.group("network_ranges.id")
.having("COUNT(events.id) > ?", Event.where("timestamp >= ?", start_time).count / total_networks)
.count
patterns[:high_volume] = {
count: high_volume_networks.count,
networks: high_volume_networks.keys
}
# Networks with high deny rates (> 50% blocked requests)
high_deny_networks = NetworkRange.joins("INNER JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ?", start_time)
.group("network_ranges.id")
.select("network_ranges.id,
COUNT(CASE WHEN events.waf_action = 1 THEN 1 END) as denied_count,
COUNT(events.id) as total_count")
.having("COUNT(CASE WHEN events.waf_action = 1 THEN 1 END)::float / COUNT(events.id) > 0.5")
.having("COUNT(events.id) >= 10") # minimum threshold
patterns[:high_deny_rate] = {
count: high_deny_networks.count,
network_ids: high_deny_networks.map(&:id)
}
# Networks appearing as multiple subnets (potential botnets)
company_subnets = NetworkRange.where("company IS NOT NULL")
.where("timestamp >= ? OR timestamp IS NULL", start_time)
.group(:company)
.select(:company, "COUNT(DISTINCT network) as subnet_count")
.having("COUNT(DISTINCT network) > 5")
.order("subnet_count DESC")
.limit(10)
patterns[:distributed_companies] = company_subnets.map do |company|
{
company: company.company,
subnets: company.subnet_count
}
end
patterns
end
def network_analytics_json
{
top_networks: @top_networks.map { |network|
{
id: network.id,
cidr: network.cidr,
company: network.company,
asn: network.asn,
country: network.country,
network_type: network.network_type,
event_count: network.event_count,
unique_ips: network.unique_ips
}
},
network_breakdown: @network_breakdown,
top_companies: @top_companies,
top_asns: @top_asns,
top_countries: @top_countries,
suspicious_patterns: @suspicious_patterns
}
end
end