314 lines
13 KiB
Ruby
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 |