Add WafPolicies
This commit is contained in:
3
Gemfile
3
Gemfile
@@ -57,6 +57,9 @@ gem "maxmind-db"
|
|||||||
# HTTP client for database downloads
|
# HTTP client for database downloads
|
||||||
gem "httparty"
|
gem "httparty"
|
||||||
|
|
||||||
|
# Country data and ISO code utilities
|
||||||
|
gem "countries"
|
||||||
|
|
||||||
# Authorization library
|
# Authorization library
|
||||||
gem "pundit"
|
gem "pundit"
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,57 @@ class AnalyticsController < ApplicationController
|
|||||||
end
|
end
|
||||||
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
|
private
|
||||||
|
|
||||||
def calculate_start_time(period)
|
def calculate_start_time(period)
|
||||||
@@ -132,4 +183,132 @@ class AnalyticsController < ApplicationController
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
end
|
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
|
end
|
||||||
@@ -11,6 +11,12 @@ class EventsController < ApplicationController
|
|||||||
@events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].present?
|
@events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].present?
|
||||||
@events = @events.where(country_code: params[:country]) if params[:country].present?
|
@events = @events.where(country_code: params[:country]) if params[:country].present?
|
||||||
|
|
||||||
|
# Network-based filters
|
||||||
|
@events = @events.by_company(params[:company]) if params[:company].present?
|
||||||
|
@events = @events.by_network_type(params[:network_type]) if params[:network_type].present?
|
||||||
|
@events = @events.by_asn(params[:asn]) if params[:asn].present?
|
||||||
|
@events = @events.by_network_cidr(params[:network_cidr]) if params[:network_cidr].present?
|
||||||
|
|
||||||
Rails.logger.debug "Events count after filtering: #{@events.count}"
|
Rails.logger.debug "Events count after filtering: #{@events.count}"
|
||||||
|
|
||||||
# Debug info
|
# Debug info
|
||||||
@@ -19,7 +25,24 @@ class EventsController < ApplicationController
|
|||||||
# Paginate
|
# Paginate
|
||||||
@pagy, @events = pagy(@events, items: 50)
|
@pagy, @events = pagy(@events, items: 50)
|
||||||
|
|
||||||
|
# Preload network ranges for all unique IPs to avoid N+1 queries
|
||||||
|
unique_ips = @events.pluck(:ip_address).uniq.compact
|
||||||
|
@network_ranges_by_ip = {}
|
||||||
|
unique_ips.each do |ip|
|
||||||
|
ip_string = ip.to_s # IPAddr objects can be converted to string
|
||||||
|
range = NetworkRange.contains_ip(ip_string).first
|
||||||
|
|
||||||
|
# Auto-generate network range if no match found
|
||||||
|
unless range
|
||||||
|
range = NetworkRangeGenerator.find_or_create_for_ip(ip)
|
||||||
|
Rails.logger.debug "Auto-generated network range #{range&.cidr} for IP #{ip_string}" if range
|
||||||
|
end
|
||||||
|
|
||||||
|
@network_ranges_by_ip[ip_string] = range if range
|
||||||
|
end
|
||||||
|
|
||||||
Rails.logger.debug "Events count after pagination: #{@events.count}"
|
Rails.logger.debug "Events count after pagination: #{@events.count}"
|
||||||
Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages"
|
Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages"
|
||||||
|
Rails.logger.debug "Preloaded network ranges for #{@network_ranges_by_ip.count} unique IPs"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -14,17 +14,20 @@ class NetworkRangesController < ApplicationController
|
|||||||
|
|
||||||
# GET /network_ranges
|
# GET /network_ranges
|
||||||
def index
|
def index
|
||||||
@pagy, @network_ranges = pagy(policy_scope(NetworkRange.includes(:rules))
|
# Start with base scope
|
||||||
.order(updated_at: :desc))
|
base_scope = policy_scope(NetworkRange.includes(:rules)).order(updated_at: :desc)
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters BEFORE pagination
|
||||||
@network_ranges = apply_filters(@network_ranges)
|
base_scope = apply_filters(base_scope)
|
||||||
|
|
||||||
# Apply search
|
# Apply search BEFORE pagination
|
||||||
if params[:search].present?
|
if params[:search].present?
|
||||||
@network_ranges = search_network_ranges(@network_ranges, params[:search])
|
base_scope = search_network_ranges(base_scope, params[:search])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Apply pagination to the filtered scope
|
||||||
|
@pagy, @network_ranges = pagy(base_scope)
|
||||||
|
|
||||||
# Statistics
|
# Statistics
|
||||||
@total_ranges = NetworkRange.count
|
@total_ranges = NetworkRange.count
|
||||||
@ranges_with_intelligence = NetworkRange.where.not(asn: nil).or(NetworkRange.where.not(company: nil)).count
|
@ranges_with_intelligence = NetworkRange.where.not(asn: nil).or(NetworkRange.where.not(company: nil)).count
|
||||||
@@ -41,14 +44,23 @@ class NetworkRangesController < ApplicationController
|
|||||||
# GET /network_ranges/:id
|
# GET /network_ranges/:id
|
||||||
def show
|
def show
|
||||||
authorize @network_range
|
authorize @network_range
|
||||||
@related_events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
|
||||||
.where("network_ranges.id = ?", @network_range.id)
|
if @network_range.persisted?
|
||||||
.recent
|
# Real network - use existing logic
|
||||||
.limit(100)
|
@related_events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
|
.where("network_ranges.id = ?", @network_range.id)
|
||||||
|
.recent
|
||||||
|
.limit(100)
|
||||||
|
else
|
||||||
|
# Virtual network - find events by IP range containment
|
||||||
|
@related_events = Event.where("ip_address <<= ?::inet", @network_range.to_s)
|
||||||
|
.recent
|
||||||
|
.limit(100)
|
||||||
|
end
|
||||||
|
|
||||||
@child_ranges = @network_range.child_ranges.limit(20)
|
@child_ranges = @network_range.child_ranges.limit(20)
|
||||||
@parent_ranges = @network_range.parent_ranges.limit(10)
|
@parent_ranges = @network_range.parent_ranges.limit(10)
|
||||||
@associated_rules = @network_range.rules.includes(:user).order(created_at: :desc)
|
@associated_rules = @network_range.persisted? ? @network_range.rules.includes(:user).order(created_at: :desc) : []
|
||||||
|
|
||||||
# Traffic analytics (if we have events)
|
# Traffic analytics (if we have events)
|
||||||
@traffic_stats = calculate_traffic_stats(@network_range)
|
@traffic_stats = calculate_traffic_stats(@network_range)
|
||||||
@@ -57,7 +69,7 @@ class NetworkRangesController < ApplicationController
|
|||||||
# GET /network_ranges/new
|
# GET /network_ranges/new
|
||||||
def new
|
def new
|
||||||
authorize NetworkRange
|
authorize NetworkRange
|
||||||
@network_range = NetworkRange.new
|
@network_range = NetworkRange.new(network: params[:network])
|
||||||
end
|
end
|
||||||
|
|
||||||
# POST /network_ranges
|
# POST /network_ranges
|
||||||
@@ -154,7 +166,12 @@ class NetworkRangesController < ApplicationController
|
|||||||
def set_network_range
|
def set_network_range
|
||||||
# Handle CIDR slugs (e.g., "40.77.167.100_32" -> "40.77.167.100/32")
|
# Handle CIDR slugs (e.g., "40.77.167.100_32" -> "40.77.167.100/32")
|
||||||
cidr = params[:id].gsub('_', '/')
|
cidr = params[:id].gsub('_', '/')
|
||||||
@network_range = NetworkRange.find_by!(network: cidr)
|
@network_range = NetworkRange.find_by(network: cidr)
|
||||||
|
|
||||||
|
# If network doesn't exist, create a virtual (unsaved) instance
|
||||||
|
if @network_range.nil?
|
||||||
|
@network_range = NetworkRange.new(network: cidr)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def network_range_params
|
def network_range_params
|
||||||
@@ -194,15 +211,43 @@ class NetworkRangesController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def calculate_traffic_stats(network_range)
|
def calculate_traffic_stats(network_range)
|
||||||
# Use the cached events_count for total requests (much more performant)
|
if network_range.persisted?
|
||||||
# For detailed breakdown, we still need to query but we can optimize with a limit
|
# Real network - use cached events_count for total requests (much more performant)
|
||||||
if network_range.events_count > 0
|
if network_range.events_count > 0
|
||||||
events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
.where("network_ranges.id = ?", network_range.id)
|
.where("network_ranges.id = ?", network_range.id)
|
||||||
.limit(1000) # Limit the sample for performance
|
.limit(1000) # Limit the sample for performance
|
||||||
|
|
||||||
|
{
|
||||||
|
total_requests: network_range.events_count, # Use cached count
|
||||||
|
unique_ips: events.distinct.count(:ip_address),
|
||||||
|
blocked_requests: events.blocked.count,
|
||||||
|
allowed_requests: events.allowed.count,
|
||||||
|
top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10),
|
||||||
|
top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
|
||||||
|
recent_activity: events.recent.limit(20)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
# No events - return empty stats
|
||||||
|
{
|
||||||
|
total_requests: 0,
|
||||||
|
unique_ips: 0,
|
||||||
|
blocked_requests: 0,
|
||||||
|
allowed_requests: 0,
|
||||||
|
top_paths: {},
|
||||||
|
top_user_agents: {},
|
||||||
|
recent_activity: []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
# Virtual network - calculate stats from events within range
|
||||||
|
events = Event.where("ip_address <<= ?::inet", network_range.to_s)
|
||||||
|
.limit(1000) # Limit the sample for performance
|
||||||
|
|
||||||
|
total_events = Event.where("ip_address <<= ?::inet", network_range.to_s).count
|
||||||
|
|
||||||
{
|
{
|
||||||
total_requests: network_range.events_count, # Use cached count
|
total_requests: total_events,
|
||||||
unique_ips: events.distinct.count(:ip_address),
|
unique_ips: events.distinct.count(:ip_address),
|
||||||
blocked_requests: events.blocked.count,
|
blocked_requests: events.blocked.count,
|
||||||
allowed_requests: events.allowed.count,
|
allowed_requests: events.allowed.count,
|
||||||
@@ -210,17 +255,6 @@ class NetworkRangesController < ApplicationController
|
|||||||
top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
|
top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
|
||||||
recent_activity: events.recent.limit(20)
|
recent_activity: events.recent.limit(20)
|
||||||
}
|
}
|
||||||
else
|
|
||||||
# No events - return empty stats
|
|
||||||
{
|
|
||||||
total_requests: 0,
|
|
||||||
unique_ips: 0,
|
|
||||||
blocked_requests: 0,
|
|
||||||
allowed_requests: 0,
|
|
||||||
top_paths: {},
|
|
||||||
top_user_agents: {},
|
|
||||||
recent_activity: []
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
165
app/controllers/waf_policies_controller.rb
Normal file
165
app/controllers/waf_policies_controller.rb
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class WafPoliciesController < ApplicationController
|
||||||
|
# Follow proper before_action order:
|
||||||
|
# 1. Authentication/Authorization
|
||||||
|
# All actions require authentication
|
||||||
|
|
||||||
|
# 2. Resource loading
|
||||||
|
before_action :set_waf_policy, only: [:show, :edit, :update, :destroy, :activate, :deactivate]
|
||||||
|
|
||||||
|
# GET /waf_policies
|
||||||
|
def index
|
||||||
|
@pagy, @waf_policies = pagy(policy_scope(WafPolicy).includes(:user, :generated_rules).order(created_at: :desc))
|
||||||
|
@policy_types = WafPolicy::POLICY_TYPES
|
||||||
|
@actions = WafPolicy::ACTIONS
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /waf_policies/new
|
||||||
|
def new
|
||||||
|
authorize WafPolicy
|
||||||
|
@waf_policy = WafPolicy.new
|
||||||
|
@policy_types = WafPolicy::POLICY_TYPES
|
||||||
|
@actions = WafPolicy::ACTIONS
|
||||||
|
|
||||||
|
# Set default values from URL parameters
|
||||||
|
@waf_policy.policy_type = params[:policy_type] if params[:policy_type].present?
|
||||||
|
@waf_policy.action = params[:action] if params[:action].present?
|
||||||
|
@waf_policy.targets = params[:targets] if params[:targets].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /waf_policies
|
||||||
|
def create
|
||||||
|
authorize WafPolicy
|
||||||
|
@waf_policy = WafPolicy.new(waf_policy_params)
|
||||||
|
@waf_policy.user = Current.user
|
||||||
|
@policy_types = WafPolicy::POLICY_TYPES
|
||||||
|
@actions = WafPolicy::ACTIONS
|
||||||
|
|
||||||
|
if @waf_policy.save
|
||||||
|
# Trigger policy processing for existing network ranges
|
||||||
|
ProcessWafPoliciesJob.perform_later(waf_policy_id: @waf_policy.id)
|
||||||
|
|
||||||
|
redirect_to @waf_policy, notice: 'WAF policy was successfully created.'
|
||||||
|
else
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /waf_policies/:id
|
||||||
|
def show
|
||||||
|
@generated_rules = @waf_policy.generated_rules.includes(:network_range).order(created_at: :desc).limit(20)
|
||||||
|
@effectiveness_stats = @waf_policy.effectiveness_stats
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /waf_policies/:id/edit
|
||||||
|
def edit
|
||||||
|
@policy_types = WafPolicy::POLICY_TYPES
|
||||||
|
@actions = WafPolicy::ACTIONS
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /waf_policies/:id
|
||||||
|
def update
|
||||||
|
@policy_types = WafPolicy::POLICY_TYPES
|
||||||
|
@actions = WafPolicy::ACTIONS
|
||||||
|
|
||||||
|
if @waf_policy.update(waf_policy_params)
|
||||||
|
# Re-process policies for existing network ranges if policy was changed
|
||||||
|
if @waf_policy.saved_change_to_targets? || @waf_policy.saved_change_to_action?
|
||||||
|
ProcessWafPoliciesJob.reprocess_for_policy(@waf_policy)
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to @waf_policy, notice: 'WAF policy was successfully updated.'
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /waf_policies/:id
|
||||||
|
def destroy
|
||||||
|
policy_name = @waf_policy.name
|
||||||
|
|
||||||
|
# Soft delete by disabling and expiring the policy
|
||||||
|
@waf_policy.update!(enabled: false, expires_at: Time.current)
|
||||||
|
|
||||||
|
redirect_to waf_policies_url, notice: "WAF policy '#{policy_name}' was disabled."
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /waf_policies/:id/activate
|
||||||
|
def activate
|
||||||
|
@waf_policy.activate!
|
||||||
|
|
||||||
|
# Re-process policies for existing network ranges
|
||||||
|
ProcessWafPoliciesJob.reprocess_for_policy(@waf_policy)
|
||||||
|
|
||||||
|
redirect_to @waf_policy, notice: 'WAF policy was activated.'
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /waf_policies/:id/deactivate
|
||||||
|
def deactivate
|
||||||
|
@waf_policy.deactivate!
|
||||||
|
|
||||||
|
redirect_to @waf_policy, notice: 'WAF policy was deactivated.'
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /waf_policies/new_country
|
||||||
|
def new_country
|
||||||
|
authorize WafPolicy
|
||||||
|
@waf_policy = WafPolicy.new(policy_type: 'country', action: 'deny')
|
||||||
|
@policy_types = WafPolicy::POLICY_TYPES
|
||||||
|
@actions = WafPolicy::ACTIONS
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /waf_policies/create_country
|
||||||
|
def create_country
|
||||||
|
authorize WafPolicy
|
||||||
|
|
||||||
|
countries = params[:countries]&.reject(&:blank?) || []
|
||||||
|
action = params[:action] || 'deny'
|
||||||
|
|
||||||
|
if countries.empty?
|
||||||
|
redirect_to new_country_waf_policies_path, alert: 'Please select at least one country.'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@waf_policy = WafPolicy.create_country_policy(
|
||||||
|
countries,
|
||||||
|
action: action,
|
||||||
|
user: Current.user,
|
||||||
|
description: params[:description]
|
||||||
|
)
|
||||||
|
|
||||||
|
if @waf_policy.persisted?
|
||||||
|
# Trigger policy processing for existing network ranges
|
||||||
|
ProcessWafPoliciesJob.reprocess_for_policy(@waf_policy)
|
||||||
|
|
||||||
|
redirect_to @waf_policy, notice: "Country blocking policy was successfully created for #{countries.join(', ')}."
|
||||||
|
else
|
||||||
|
@policy_types = WafPolicy::POLICY_TYPES
|
||||||
|
@actions = WafPolicy::ACTIONS
|
||||||
|
render :new_country, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_waf_policy
|
||||||
|
@waf_policy = WafPolicy.find(params[:id])
|
||||||
|
authorize @waf_policy
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
redirect_to waf_policies_path, alert: 'WAF policy not found.'
|
||||||
|
end
|
||||||
|
|
||||||
|
def waf_policy_params
|
||||||
|
params.require(:waf_policy).permit(
|
||||||
|
:name,
|
||||||
|
:description,
|
||||||
|
:policy_type,
|
||||||
|
:action,
|
||||||
|
:enabled,
|
||||||
|
:expires_at,
|
||||||
|
targets: [],
|
||||||
|
additional_data: {}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -35,6 +35,23 @@ class ProcessWafEventJob < ApplicationJob
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Ensure network range exists for this IP and process policies
|
||||||
|
if event.ip_address.present?
|
||||||
|
begin
|
||||||
|
existing_range = NetworkRange.contains_ip(event.ip_address.to_s).first
|
||||||
|
network_range = existing_range || NetworkRangeGenerator.find_or_create_for_ip(event.ip_address)
|
||||||
|
|
||||||
|
if network_range
|
||||||
|
Rails.logger.debug "Network range #{network_range.cidr} for event IP #{event.ip_address}"
|
||||||
|
|
||||||
|
# Process WAF policies for this network range
|
||||||
|
ProcessWafPoliciesJob.perform_later(network_range_id: network_range.id, event_id: event.id)
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.warn "Failed to create network range for event #{event.id}: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Trigger analytics processing
|
# Trigger analytics processing
|
||||||
ProcessWafAnalyticsJob.perform_later(event_id: event.id)
|
ProcessWafAnalyticsJob.perform_later(event_id: event.id)
|
||||||
|
|
||||||
|
|||||||
101
app/jobs/process_waf_policies_job.rb
Normal file
101
app/jobs/process_waf_policies_job.rb
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# ProcessWafPoliciesJob - Process firewall policies for a network range
|
||||||
|
#
|
||||||
|
# This job checks network ranges against active WAF policies and generates
|
||||||
|
# specific rules when matches are found.
|
||||||
|
class ProcessWafPoliciesJob < ApplicationJob
|
||||||
|
queue_as :waf_policies
|
||||||
|
|
||||||
|
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||||
|
|
||||||
|
def perform(network_range_id:, event_id: nil)
|
||||||
|
# Find the network range
|
||||||
|
network_range = NetworkRange.find_by(id: network_range_id)
|
||||||
|
return if network_range.nil?
|
||||||
|
|
||||||
|
Rails.logger.debug "Processing WAF policies for network range #{network_range.cidr}"
|
||||||
|
|
||||||
|
# Use WafPolicyMatcher to find and generate rules
|
||||||
|
matcher = WafPolicyMatcher.new(network_range: network_range)
|
||||||
|
result = matcher.match_and_generate_rules
|
||||||
|
|
||||||
|
# Log results
|
||||||
|
if result[:matching_policies].any?
|
||||||
|
Rails.logger.info "Network range #{network_range.cidr} matched #{result[:matching_policies].length} policies"
|
||||||
|
|
||||||
|
result[:matching_policies].each do |policy|
|
||||||
|
Rails.logger.info " - Matched policy: #{policy.name} (#{policy.policy_type}: #{policy.action})"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if result[:generated_rules].any?
|
||||||
|
Rails.logger.info "Generated #{result[:generated_rules].length} rules for network range #{network_range.cidr}"
|
||||||
|
|
||||||
|
result[:generated_rules].each do |rule|
|
||||||
|
Rails.logger.info " - Rule: #{rule.rule_type} #{rule.action} for #{rule.network_range&.cidr} (ID: #{rule.id})"
|
||||||
|
|
||||||
|
# Log if this is a redirect or challenge rule
|
||||||
|
if rule.redirect_action?
|
||||||
|
Rails.logger.info " Redirect to: #{rule.redirect_url} (#{rule.redirect_status})"
|
||||||
|
elsif rule.challenge_action?
|
||||||
|
Rails.logger.info " Challenge type: #{rule.challenge_type}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Trigger agent sync for new rules if there are any
|
||||||
|
if result[:generated_rules].any?
|
||||||
|
RulesSyncJob.perform_later
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Rails.logger.debug "No matching policies found for network range #{network_range.cidr}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update event record if provided
|
||||||
|
if event_id.present?
|
||||||
|
event = Event.find_by(id: event_id)
|
||||||
|
if event.present?
|
||||||
|
# Add policy match information to event metadata
|
||||||
|
event.update!(payload: event.payload.merge({
|
||||||
|
policy_matches: {
|
||||||
|
matching_policies_count: result[:matching_policies].length,
|
||||||
|
generated_rules_count: result[:generated_rules].length,
|
||||||
|
processed_at: Time.current.iso8601
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Class method for batch processing multiple network ranges
|
||||||
|
def self.process_network_ranges(network_range_ids)
|
||||||
|
network_range_ids.each do |network_range_id|
|
||||||
|
perform_later(network_range_id: network_range_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Class method to reprocess all network ranges for a specific policy
|
||||||
|
def self.reprocess_for_policy(waf_policy)
|
||||||
|
waf_policy_id = waf_policy.is_a?(WafPolicy) ? waf_policy.id : waf_policy
|
||||||
|
|
||||||
|
# Find all network ranges that could match this policy type
|
||||||
|
network_ranges = case waf_policy.policy_type
|
||||||
|
when 'country'
|
||||||
|
NetworkRange.where.not(country: nil)
|
||||||
|
when 'asn'
|
||||||
|
NetworkRange.where.not(asn: nil)
|
||||||
|
when 'company'
|
||||||
|
NetworkRange.where.not(company: nil)
|
||||||
|
when 'network_type'
|
||||||
|
NetworkRange.where("is_datacenter = ? OR is_proxy = ? OR is_vpn = ?", true, true, true)
|
||||||
|
else
|
||||||
|
NetworkRange.none
|
||||||
|
end
|
||||||
|
|
||||||
|
Rails.logger.info "Reprocessing #{network_ranges.count} network ranges for policy #{waf_policy_id}"
|
||||||
|
|
||||||
|
network_ranges.find_each do |network_range|
|
||||||
|
perform_later(network_range_id: network_range.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -32,10 +32,36 @@ class Event < ApplicationRecord
|
|||||||
scope :by_ip, ->(ip) { where(ip_address: ip) }
|
scope :by_ip, ->(ip) { where(ip_address: ip) }
|
||||||
scope :by_user_agent, ->(user_agent) { where(user_agent: user_agent) }
|
scope :by_user_agent, ->(user_agent) { where(user_agent: user_agent) }
|
||||||
scope :by_waf_action, ->(waf_action) { where(waf_action: waf_action) }
|
scope :by_waf_action, ->(waf_action) { where(waf_action: waf_action) }
|
||||||
scope :blocked, -> { where(waf_action: ['block', 'deny']) }
|
scope :blocked, -> { where(waf_action: :deny) }
|
||||||
scope :allowed, -> { where(waf_action: ['allow', 'pass']) }
|
scope :allowed, -> { where(waf_action: :allow) }
|
||||||
scope :rate_limited, -> { where(waf_action: 'rate_limit') }
|
scope :rate_limited, -> { where(waf_action: 'rate_limit') }
|
||||||
|
|
||||||
|
# Network-based filtering scopes
|
||||||
|
scope :by_company, ->(company) {
|
||||||
|
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
|
.where("network_ranges.company ILIKE ?", "%#{company}%")
|
||||||
|
}
|
||||||
|
|
||||||
|
scope :by_network_type, ->(type) {
|
||||||
|
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
|
.case(type)
|
||||||
|
.when("datacenter") { where("network_ranges.is_datacenter = ?", true) }
|
||||||
|
.when("vpn") { where("network_ranges.is_vpn = ?", true) }
|
||||||
|
.when("proxy") { where("network_ranges.is_proxy = ?", true) }
|
||||||
|
.when("standard") { where("network_ranges.is_datacenter = ? AND network_ranges.is_vpn = ? AND network_ranges.is_proxy = ?", false, false, false) }
|
||||||
|
.else { none }
|
||||||
|
}
|
||||||
|
|
||||||
|
scope :by_asn, ->(asn) {
|
||||||
|
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
|
.where("network_ranges.asn = ?", asn.to_i)
|
||||||
|
}
|
||||||
|
|
||||||
|
scope :by_network_cidr, ->(cidr) {
|
||||||
|
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
|
.where("network_ranges.network = ?", cidr)
|
||||||
|
}
|
||||||
|
|
||||||
# Path prefix matching using range queries (uses B-tree index efficiently)
|
# Path prefix matching using range queries (uses B-tree index efficiently)
|
||||||
scope :with_path_prefix, ->(prefix_segment_ids) {
|
scope :with_path_prefix, ->(prefix_segment_ids) {
|
||||||
return none if prefix_segment_ids.blank?
|
return none if prefix_segment_ids.blank?
|
||||||
@@ -112,10 +138,7 @@ class Event < ApplicationRecord
|
|||||||
server_name: normalized_payload["server_name"],
|
server_name: normalized_payload["server_name"],
|
||||||
environment: normalized_payload["environment"],
|
environment: normalized_payload["environment"],
|
||||||
|
|
||||||
# Geographic data
|
|
||||||
country_code: normalized_payload.dig("geo", "country_code"),
|
|
||||||
city: normalized_payload.dig("geo", "city"),
|
|
||||||
|
|
||||||
# WAF agent info
|
# WAF agent info
|
||||||
agent_version: normalized_payload.dig("agent", "version"),
|
agent_version: normalized_payload.dig("agent", "version"),
|
||||||
agent_name: normalized_payload.dig("agent", "name")
|
agent_name: normalized_payload.dig("agent", "name")
|
||||||
@@ -269,7 +292,7 @@ class Event < ApplicationRecord
|
|||||||
def matching_network_ranges
|
def matching_network_ranges
|
||||||
return [] unless ip_address.present?
|
return [] unless ip_address.present?
|
||||||
|
|
||||||
NetworkRange.contains_ip(ip_address).map do |range|
|
NetworkRange.contains_ip(ip_address.to_s).map do |range|
|
||||||
{
|
{
|
||||||
range: range,
|
range: range,
|
||||||
cidr: range.cidr,
|
cidr: range.cidr,
|
||||||
@@ -360,86 +383,34 @@ class Event < ApplicationRecord
|
|||||||
active_blocking_rules.exists?
|
active_blocking_rules.exists?
|
||||||
end
|
end
|
||||||
|
|
||||||
# GeoIP enrichment methods (now uses network range data when available)
|
# Get full geo location details from network range
|
||||||
def enrich_geo_location!
|
|
||||||
return if ip_address.blank?
|
|
||||||
return if country_code.present? # Already has geo data
|
|
||||||
|
|
||||||
# First try to get from network range
|
|
||||||
network_info = network_intelligence
|
|
||||||
if network_info[:country].present?
|
|
||||||
update!(country_code: network_info[:country])
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Fallback to direct lookup
|
|
||||||
country = GeoIpService.lookup_country(ip_address)
|
|
||||||
update!(country_code: country) if country.present?
|
|
||||||
rescue => e
|
|
||||||
Rails.logger.error "Failed to enrich geo location for event #{id}: #{e.message}"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Class method to enrich multiple events
|
|
||||||
def self.enrich_geo_location_batch(events = nil)
|
|
||||||
events ||= where(country_code: [nil, '']).where.not(ip_address: [nil, ''])
|
|
||||||
updated_count = 0
|
|
||||||
|
|
||||||
events.find_each do |event|
|
|
||||||
next if event.country_code.present?
|
|
||||||
|
|
||||||
# Try network range first
|
|
||||||
network_info = event.network_intelligence
|
|
||||||
if network_info[:country].present?
|
|
||||||
event.update!(country_code: network_info[:country])
|
|
||||||
updated_count += 1
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
# Fallback to direct lookup
|
|
||||||
country = GeoIpService.lookup_country(event.ip_address)
|
|
||||||
if country.present?
|
|
||||||
event.update!(country_code: country)
|
|
||||||
updated_count += 1
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
updated_count
|
|
||||||
end
|
|
||||||
|
|
||||||
# Lookup country code for this event's IP
|
|
||||||
def lookup_country
|
|
||||||
return country_code if country_code.present?
|
|
||||||
return nil if ip_address.blank?
|
|
||||||
|
|
||||||
# First try network range
|
|
||||||
network_info = network_intelligence
|
|
||||||
return network_info[:country] if network_info[:country].present?
|
|
||||||
|
|
||||||
# Fallback to direct lookup
|
|
||||||
GeoIpService.lookup_country(ip_address)
|
|
||||||
rescue => e
|
|
||||||
Rails.logger.error "GeoIP lookup failed for #{ip_address}: #{e.message}"
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if event has valid geo location data
|
|
||||||
def has_geo_data?
|
|
||||||
country_code.present? || city.present? || network_intelligence[:country].present?
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get full geo location details
|
|
||||||
def geo_location
|
def geo_location
|
||||||
network_info = network_intelligence
|
network_info = network_intelligence
|
||||||
|
|
||||||
{
|
{
|
||||||
country_code: country_code || network_info[:country],
|
country_code: network_info[:country],
|
||||||
city: city,
|
|
||||||
ip_address: ip_address,
|
ip_address: ip_address,
|
||||||
has_data: has_geo_data?,
|
has_data: network_info[:country].present?,
|
||||||
network_intelligence: network_info
|
network_intelligence: network_info
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if event has valid geo location data via network range
|
||||||
|
def has_geo_data?
|
||||||
|
network_intelligence[:country].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Lookup country code for this event's IP via network range
|
||||||
|
def lookup_country
|
||||||
|
return nil if ip_address.blank?
|
||||||
|
|
||||||
|
network_info = network_intelligence
|
||||||
|
network_info[:country]
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Network lookup failed for #{ip_address}: #{e.message}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def should_normalize?
|
def should_normalize?
|
||||||
@@ -483,11 +454,7 @@ class Event < ApplicationRecord
|
|||||||
self.server_name = payload["server_name"]
|
self.server_name = payload["server_name"]
|
||||||
self.environment = payload["environment"]
|
self.environment = payload["environment"]
|
||||||
|
|
||||||
# Extract geographic data
|
|
||||||
geo_data = payload.dig("geo") || {}
|
|
||||||
self.country_code = geo_data["country_code"]
|
|
||||||
self.city = geo_data["city"]
|
|
||||||
|
|
||||||
# Extract agent info
|
# Extract agent info
|
||||||
agent_data = payload.dig("agent") || {}
|
agent_data = payload.dig("agent") || {}
|
||||||
self.agent_version = agent_data["version"]
|
self.agent_version = agent_data["version"]
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ class NetworkRange < ApplicationRecord
|
|||||||
addr.include?(':') ? 6 : 4
|
addr.include?(':') ? 6 : 4
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def virtual?
|
||||||
|
# Virtual networks are unsaved instances (not persisted to database)
|
||||||
|
!persisted?
|
||||||
|
end
|
||||||
|
|
||||||
def ipv4?
|
def ipv4?
|
||||||
family == 4
|
family == 4
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
class Rule < ApplicationRecord
|
class Rule < ApplicationRecord
|
||||||
# Rule types and actions
|
# Rule types and actions
|
||||||
RULE_TYPES = %w[network rate_limit path_pattern].freeze
|
RULE_TYPES = %w[network rate_limit path_pattern].freeze
|
||||||
ACTIONS = %w[allow deny rate_limit redirect log].freeze
|
ACTIONS = %w[allow deny rate_limit redirect log challenge].freeze
|
||||||
SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default manual:surgical_block manual:surgical_exception].freeze
|
SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default manual:surgical_block manual:surgical_exception policy].freeze
|
||||||
|
|
||||||
# Associations
|
# Associations
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :network_range, optional: true
|
belongs_to :network_range, optional: true
|
||||||
|
belongs_to :waf_policy, optional: true
|
||||||
|
|
||||||
# Validations
|
# Validations
|
||||||
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
|
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
|
||||||
@@ -39,6 +40,8 @@ class Rule < ApplicationRecord
|
|||||||
scope :by_source, ->(source) { where(source: source) }
|
scope :by_source, ->(source) { where(source: source) }
|
||||||
scope :surgical_blocks, -> { where(source: "manual:surgical_block") }
|
scope :surgical_blocks, -> { where(source: "manual:surgical_block") }
|
||||||
scope :surgical_exceptions, -> { where(source: "manual:surgical_exception") }
|
scope :surgical_exceptions, -> { where(source: "manual:surgical_exception") }
|
||||||
|
scope :policy_generated, -> { where(source: "policy") }
|
||||||
|
scope :from_waf_policy, ->(waf_policy) { where(waf_policy: waf_policy) }
|
||||||
|
|
||||||
# Sync queries
|
# Sync queries
|
||||||
scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) }
|
scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) }
|
||||||
@@ -94,6 +97,37 @@ class Rule < ApplicationRecord
|
|||||||
source == "manual:surgical_exception"
|
source == "manual:surgical_exception"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Policy-generated rule methods
|
||||||
|
def policy_generated?
|
||||||
|
source == "policy"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Action-specific methods
|
||||||
|
def redirect_action?
|
||||||
|
action == "redirect"
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_action?
|
||||||
|
action == "challenge"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Redirect/challenge convenience methods
|
||||||
|
def redirect_url
|
||||||
|
metadata&.dig('redirect_url')
|
||||||
|
end
|
||||||
|
|
||||||
|
def redirect_status
|
||||||
|
metadata&.dig('redirect_status') || 302
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_type
|
||||||
|
metadata&.dig('challenge_type') || 'captcha'
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_message
|
||||||
|
metadata&.dig('challenge_message')
|
||||||
|
end
|
||||||
|
|
||||||
def related_surgical_rules
|
def related_surgical_rules
|
||||||
if surgical_block?
|
if surgical_block?
|
||||||
# Find the corresponding exception rule
|
# Find the corresponding exception rule
|
||||||
@@ -365,6 +399,12 @@ class Rule < ApplicationRecord
|
|||||||
unless metadata&.dig("redirect_url").present?
|
unless metadata&.dig("redirect_url").present?
|
||||||
errors.add(:metadata, "must include 'redirect_url' for redirect action")
|
errors.add(:metadata, "must include 'redirect_url' for redirect action")
|
||||||
end
|
end
|
||||||
|
when "challenge"
|
||||||
|
# Challenge is flexible - can use defaults
|
||||||
|
challenge_type_value = metadata&.dig("challenge_type")
|
||||||
|
if challenge_type_value && !%w[captcha javascript proof_of_work].include?(challenge_type_value)
|
||||||
|
errors.add(:metadata, "challenge_type must be one of: captcha, javascript, proof_of_work")
|
||||||
|
end
|
||||||
when "rate_limit"
|
when "rate_limit"
|
||||||
unless metadata&.dig("limit").present? && metadata&.dig("window").present?
|
unless metadata&.dig("limit").present? && metadata&.dig("window").present?
|
||||||
errors.add(:metadata, "must include 'limit' and 'window' for rate_limit action")
|
errors.add(:metadata, "must include 'limit' and 'window' for rate_limit action")
|
||||||
|
|||||||
399
app/models/waf_policy.rb
Normal file
399
app/models/waf_policy.rb
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# WafPolicy - High-level firewall policies that generate specific Rules
|
||||||
|
#
|
||||||
|
# WafPolicies contain strategic decisions like "Block Brazil" that automatically
|
||||||
|
# generate specific Rules when matching network ranges are discovered.
|
||||||
|
class WafPolicy < ApplicationRecord
|
||||||
|
# Policy types - different categories of blocking rules
|
||||||
|
POLICY_TYPES = %w[country asn company network_type].freeze
|
||||||
|
|
||||||
|
# Actions - what to do when traffic matches this policy
|
||||||
|
ACTIONS = %w[allow deny redirect challenge].freeze
|
||||||
|
|
||||||
|
# Associations
|
||||||
|
belongs_to :user
|
||||||
|
has_many :generated_rules, class_name: 'Rule', dependent: :destroy
|
||||||
|
|
||||||
|
# Validations
|
||||||
|
validates :name, presence: true, uniqueness: true
|
||||||
|
validates :policy_type, presence: true, inclusion: { in: POLICY_TYPES }
|
||||||
|
validates :action, presence: true, inclusion: { in: ACTIONS }
|
||||||
|
validates :targets, presence: true
|
||||||
|
validate :targets_must_be_array
|
||||||
|
validates :user, presence: true
|
||||||
|
validate :validate_targets_by_type
|
||||||
|
validate :validate_redirect_configuration, if: :redirect_action?
|
||||||
|
validate :validate_challenge_configuration, if: :challenge_action?
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
scope :enabled, -> { where(enabled: true) }
|
||||||
|
scope :disabled, -> { where(enabled: false) }
|
||||||
|
scope :active, -> { enabled.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||||
|
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
|
||||||
|
scope :by_type, ->(type) { where(policy_type: type) }
|
||||||
|
scope :country, -> { by_type('country') }
|
||||||
|
scope :asn, -> { by_type('asn') }
|
||||||
|
scope :company, -> { by_type('company') }
|
||||||
|
scope :network_type, -> { by_type('network_type') }
|
||||||
|
|
||||||
|
# Callbacks
|
||||||
|
before_validation :set_defaults
|
||||||
|
|
||||||
|
# Policy type methods
|
||||||
|
def country_policy?
|
||||||
|
policy_type == 'country'
|
||||||
|
end
|
||||||
|
|
||||||
|
def asn_policy?
|
||||||
|
policy_type == 'asn'
|
||||||
|
end
|
||||||
|
|
||||||
|
def company_policy?
|
||||||
|
policy_type == 'company'
|
||||||
|
end
|
||||||
|
|
||||||
|
def network_type_policy?
|
||||||
|
policy_type == 'network_type'
|
||||||
|
end
|
||||||
|
|
||||||
|
# Action methods
|
||||||
|
def allow_action?
|
||||||
|
action == 'allow'
|
||||||
|
end
|
||||||
|
|
||||||
|
def deny_action?
|
||||||
|
action == 'deny'
|
||||||
|
end
|
||||||
|
|
||||||
|
def redirect_action?
|
||||||
|
action == 'redirect'
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_action?
|
||||||
|
action == 'challenge'
|
||||||
|
end
|
||||||
|
|
||||||
|
# Lifecycle methods
|
||||||
|
def active?
|
||||||
|
enabled? && !expired?
|
||||||
|
end
|
||||||
|
|
||||||
|
def expired?
|
||||||
|
expires_at.present? && expires_at <= Time.current
|
||||||
|
end
|
||||||
|
|
||||||
|
def activate!
|
||||||
|
update!(enabled: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def deactivate!
|
||||||
|
update!(enabled: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def expire!
|
||||||
|
update!(expires_at: Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Network range matching methods
|
||||||
|
def matches_network_range?(network_range)
|
||||||
|
return false unless active?
|
||||||
|
|
||||||
|
case policy_type
|
||||||
|
when 'country'
|
||||||
|
matches_country?(network_range)
|
||||||
|
when 'asn'
|
||||||
|
matches_asn?(network_range)
|
||||||
|
when 'company'
|
||||||
|
matches_company?(network_range)
|
||||||
|
when 'network_type'
|
||||||
|
matches_network_type?(network_range)
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_rule_for_network_range(network_range)
|
||||||
|
return nil unless matches_network_range?(network_range)
|
||||||
|
|
||||||
|
rule = Rule.create!(
|
||||||
|
rule_type: 'network',
|
||||||
|
action: action,
|
||||||
|
network_range: network_range,
|
||||||
|
waf_policy: self,
|
||||||
|
user: user,
|
||||||
|
source: "policy:#{name}",
|
||||||
|
metadata: build_rule_metadata(network_range),
|
||||||
|
priority: network_range.prefix_length
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle redirect/challenge specific data
|
||||||
|
if redirect_action? && additional_data['redirect_url']
|
||||||
|
rule.update!(
|
||||||
|
metadata: rule.metadata.merge(
|
||||||
|
redirect_url: additional_data['redirect_url'],
|
||||||
|
redirect_status: additional_data['redirect_status'] || 302
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elsif challenge_action?
|
||||||
|
rule.update!(
|
||||||
|
metadata: rule.metadata.merge(
|
||||||
|
challenge_type: additional_data['challenge_type'] || 'captcha',
|
||||||
|
challenge_message: additional_data['challenge_message']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
rule
|
||||||
|
end
|
||||||
|
|
||||||
|
# Class methods for creating common policies
|
||||||
|
def self.create_country_policy(countries, action: 'deny', user:, **options)
|
||||||
|
create!(
|
||||||
|
name: "#{action.capitalize} #{countries.join(', ')}",
|
||||||
|
policy_type: 'country',
|
||||||
|
targets: Array(countries),
|
||||||
|
action: action,
|
||||||
|
user: user,
|
||||||
|
**options
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.create_asn_policy(asns, action: 'deny', user:, **options)
|
||||||
|
create!(
|
||||||
|
name: "#{action.capitalize} ASNs #{asns.join(', ')}",
|
||||||
|
policy_type: 'asn',
|
||||||
|
targets: Array(asns).map(&:to_i),
|
||||||
|
action: action,
|
||||||
|
user: user,
|
||||||
|
**options
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.create_company_policy(companies, action: 'deny', user:, **options)
|
||||||
|
create!(
|
||||||
|
name: "#{action.capitalize} #{companies.join(', ')}",
|
||||||
|
policy_type: 'company',
|
||||||
|
targets: Array(companies),
|
||||||
|
action: action,
|
||||||
|
user: user,
|
||||||
|
**options
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.create_network_type_policy(types, action: 'deny', user:, **options)
|
||||||
|
create!(
|
||||||
|
name: "#{action.capitalize} #{types.join(', ')}",
|
||||||
|
policy_type: 'network_type',
|
||||||
|
targets: Array(types),
|
||||||
|
action: action,
|
||||||
|
user: user,
|
||||||
|
**options
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Redirect/challenge specific methods
|
||||||
|
def redirect_url
|
||||||
|
additional_data&.dig('redirect_url')
|
||||||
|
end
|
||||||
|
|
||||||
|
def redirect_status
|
||||||
|
additional_data&.dig('redirect_status') || 302
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_type
|
||||||
|
additional_data&.dig('challenge_type') || 'captcha'
|
||||||
|
end
|
||||||
|
|
||||||
|
def challenge_message
|
||||||
|
additional_data&.dig('challenge_message')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Statistics and analytics
|
||||||
|
def generated_rules_count
|
||||||
|
generated_rules.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_rules_count
|
||||||
|
generated_rules.active.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def effectiveness_stats
|
||||||
|
recent_rules = generated_rules.where('created_at > ?', 7.days.ago)
|
||||||
|
|
||||||
|
{
|
||||||
|
total_rules_generated: generated_rules_count,
|
||||||
|
active_rules: active_rules_count,
|
||||||
|
rules_last_7_days: recent_rules.count,
|
||||||
|
policy_type: policy_type,
|
||||||
|
action: action,
|
||||||
|
targets_count: targets&.length || 0
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# String representations
|
||||||
|
def to_s
|
||||||
|
name
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_param
|
||||||
|
name.parameterize
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_defaults
|
||||||
|
self.targets ||= []
|
||||||
|
self.additional_data ||= {}
|
||||||
|
self.enabled = true if enabled.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def targets_must_be_array
|
||||||
|
unless targets.is_a?(Array)
|
||||||
|
errors.add(:targets, "must be an array")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_targets_by_type
|
||||||
|
return if targets.blank?
|
||||||
|
|
||||||
|
case policy_type
|
||||||
|
when 'country'
|
||||||
|
validate_country_targets
|
||||||
|
when 'asn'
|
||||||
|
validate_asn_targets
|
||||||
|
when 'company'
|
||||||
|
validate_company_targets
|
||||||
|
when 'network_type'
|
||||||
|
validate_network_type_targets
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_country_targets
|
||||||
|
unless targets.all? { |target| target.is_a?(String) && target.match?(/\A[A-Z]{2}\z/) }
|
||||||
|
errors.add(:targets, "must be valid ISO country codes (e.g., 'BR', 'US')")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_asn_targets
|
||||||
|
unless targets.all? { |target| target.is_a?(Integer) && target > 0 }
|
||||||
|
errors.add(:targets, "must be valid ASNs (positive integers)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_company_targets
|
||||||
|
unless targets.all? { |target| target.is_a?(String) && target.present? }
|
||||||
|
errors.add(:targets, "must be valid company names")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_network_type_targets
|
||||||
|
valid_types = %w[datacenter proxy vpn standard]
|
||||||
|
unless targets.all? { |target| valid_types.include?(target) }
|
||||||
|
errors.add(:targets, "must be one of: #{valid_types.join(', ')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_redirect_configuration
|
||||||
|
if additional_data['redirect_url'].blank?
|
||||||
|
errors.add(:additional_data, "must include 'redirect_url' for redirect action")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_challenge_configuration
|
||||||
|
# Challenge is flexible - can use defaults if not specified
|
||||||
|
valid_challenge_types = %w[captcha javascript proof_of_work]
|
||||||
|
challenge_type_value = additional_data&.dig('challenge_type')
|
||||||
|
|
||||||
|
if challenge_type_value && !valid_challenge_types.include?(challenge_type_value)
|
||||||
|
errors.add(:additional_data, "challenge_type must be one of: #{valid_challenge_types.join(', ')}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Matching logic for different policy types
|
||||||
|
def matches_country?(network_range)
|
||||||
|
country = network_range.country || network_range.inherited_intelligence[:country]
|
||||||
|
targets.include?(country)
|
||||||
|
end
|
||||||
|
|
||||||
|
def matches_asn?(network_range)
|
||||||
|
asn = network_range.asn || network_range.inherited_intelligence[:asn]
|
||||||
|
targets.include?(asn)
|
||||||
|
end
|
||||||
|
|
||||||
|
def matches_company?(network_range)
|
||||||
|
company = network_range.company || network_range.inherited_intelligence[:company]
|
||||||
|
return false if company.blank?
|
||||||
|
|
||||||
|
targets.any? do |target_company|
|
||||||
|
company.downcase.include?(target_company.downcase) ||
|
||||||
|
target_company.downcase.include?(company.downcase)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def matches_network_type?(network_range)
|
||||||
|
intelligence = network_range.inherited_intelligence
|
||||||
|
|
||||||
|
targets.any? do |target_type|
|
||||||
|
case target_type
|
||||||
|
when 'datacenter'
|
||||||
|
intelligence[:is_datacenter] == true
|
||||||
|
when 'proxy'
|
||||||
|
intelligence[:is_proxy] == true
|
||||||
|
when 'vpn'
|
||||||
|
intelligence[:is_vpn] == true
|
||||||
|
when 'standard'
|
||||||
|
intelligence[:is_datacenter] == false &&
|
||||||
|
intelligence[:is_proxy] == false &&
|
||||||
|
intelligence[:is_vpn] == false
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_rule_metadata(network_range)
|
||||||
|
base_metadata = {
|
||||||
|
generated_by_policy: id,
|
||||||
|
policy_name: name,
|
||||||
|
policy_type: policy_type,
|
||||||
|
matched_field: matched_field(network_range),
|
||||||
|
matched_value: matched_value(network_range)
|
||||||
|
}
|
||||||
|
|
||||||
|
base_metadata.merge!(additional_data || {})
|
||||||
|
end
|
||||||
|
|
||||||
|
def matched_field(network_range)
|
||||||
|
case policy_type
|
||||||
|
when 'country'
|
||||||
|
'country'
|
||||||
|
when 'asn'
|
||||||
|
'asn'
|
||||||
|
when 'company'
|
||||||
|
'company'
|
||||||
|
when 'network_type'
|
||||||
|
'network_type'
|
||||||
|
else
|
||||||
|
'unknown'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def matched_value(network_range)
|
||||||
|
case policy_type
|
||||||
|
when 'country'
|
||||||
|
network_range.country || network_range.inherited_intelligence[:country]
|
||||||
|
when 'asn'
|
||||||
|
network_range.asn || network_range.inherited_intelligence[:asn]
|
||||||
|
when 'company'
|
||||||
|
network_range.company || network_range.inherited_intelligence[:company]
|
||||||
|
when 'network_type'
|
||||||
|
intelligence = network_range.inherited_intelligence
|
||||||
|
types = []
|
||||||
|
types << 'datacenter' if intelligence[:is_datacenter]
|
||||||
|
types << 'proxy' if intelligence[:is_proxy]
|
||||||
|
types << 'vpn' if intelligence[:is_vpn]
|
||||||
|
types.join(',') || 'standard'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -239,7 +239,11 @@
|
|||||||
<!-- Network Intelligence -->
|
<!-- Network Intelligence -->
|
||||||
<div class="bg-white shadow rounded-lg">
|
<div class="bg-white shadow rounded-lg">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<h3 class="text-lg font-medium text-gray-900">Network Intelligence</h3>
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Network Intelligence</h3>
|
||||||
|
<%= link_to "Detailed Network Analytics →", analytics_networks_path,
|
||||||
|
class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
|||||||
283
app/views/analytics/networks.html.erb
Normal file
283
app/views/analytics/networks.html.erb
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<% content_for :title, "Network Analytics - Baffle Hub" %>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Network Analytics</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Detailed traffic analysis and network intelligence insights</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time Period Selector -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Time Period</h3>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<% [:hour, :day, :week, :month].each do |period| %>
|
||||||
|
<%= link_to period.to_s.humanize, analytics_networks_path(period: period),
|
||||||
|
class: "px-3 py-1 rounded-md text-sm font-medium #{ @time_period == period ? 'bg-blue-100 text-blue-800' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100' }" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Type Overview -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Standard Networks</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">
|
||||||
|
<%= number_with_delimiter(@network_breakdown.dig('standard', :networks) || 0) %>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
<%= number_with_delimiter(@network_breakdown.dig('standard', :events) || 0) %> events
|
||||||
|
(<%= @network_breakdown.dig('standard', :percentage) || 0 %>%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Datacenter Networks</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">
|
||||||
|
<%= number_with_delimiter(@network_breakdown.dig('datacenter', :networks) || 0) %>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
<%= number_with_delimiter(@network_breakdown.dig('datacenter', :events) || 0) %> events
|
||||||
|
(<%= @network_breakdown.dig('datacenter', :percentage) || 0 %>%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">VPN Networks</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">
|
||||||
|
<%= number_with_delimiter(@network_breakdown.dig('vpn', :networks) || 0) %>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
<%= number_with_delimiter(@network_breakdown.dig('vpn', :events) || 0) %> events
|
||||||
|
(<%= @network_breakdown.dig('vpn', :percentage) || 0 %>%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-8 w-8 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<p class="text-sm font-medium text-gray-500">Proxy Networks</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900">
|
||||||
|
<%= number_with_delimiter(@network_breakdown.dig('proxy', :networks) || 0) %>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
<%= number_with_delimiter(@network_breakdown.dig('proxy', :events) || 0) %> events
|
||||||
|
(<%= @network_breakdown.dig('proxy', :percentage) || 0 %>%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Networks by Traffic -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Top Networks by Traffic Volume</h3>
|
||||||
|
<p class="text-sm text-gray-500">Networks with the most requests in the selected time period</p>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<% if @top_networks.any? %>
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Network</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Company</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Events</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Unique IPs</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<% @top_networks.each do |network| %>
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<div>
|
||||||
|
<%= link_to network.cidr, network_range_path(network),
|
||||||
|
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono font-medium" %>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<% if network.country.present? %>
|
||||||
|
🏳️ <%= network.country %>
|
||||||
|
<% end %>
|
||||||
|
<% if network.asn.present? %>
|
||||||
|
• ASN <%= network.asn %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<%= network.company || 'Unknown' %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<% if network.is_datacenter? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">Datacenter</span>
|
||||||
|
<% elsif network.is_vpn? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">VPN</span>
|
||||||
|
<% elsif network.is_proxy? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Proxy</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Standard</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<%= number_with_delimiter(network.event_count) %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<%= number_with_delimiter(network.unique_ips) %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<%= link_to "View Events", events_path(network_cidr: network.cidr),
|
||||||
|
class: "text-blue-600 hover:text-blue-800 text-sm" %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% else %>
|
||||||
|
<div class="px-6 py-12 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No network traffic</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">No network activity found in the selected time period.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Analytics Sections -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Top Companies -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Top Companies by Traffic</h3>
|
||||||
|
<p class="text-sm text-gray-500">Companies generating the most traffic</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<% if @top_companies.any? %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% @top_companies.each do |company| %>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="font-medium text-gray-900"><%= company.company %></div>
|
||||||
|
<div class="ml-2 text-sm text-gray-500">
|
||||||
|
<%= number_with_delimiter(company.network_count) %> networks
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
<div class="flex items-center text-sm text-gray-600">
|
||||||
|
<span><%= number_with_delimiter(company.event_count) %> events</span>
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<span><%= number_with_delimiter(company.unique_ips) %> unique IPs</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<%= link_to "Filter Events", events_path(company: company.company),
|
||||||
|
class: "text-blue-600 hover:text-blue-800 text-sm font-medium" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500">No company data available for this time period.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top ASNs -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Top Autonomous Systems</h3>
|
||||||
|
<p class="text-sm text-gray-500">ASNs with the most traffic</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<% if @top_asns.any? %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% @top_asns.each do |asn| %>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium text-gray-900">
|
||||||
|
ASN <%= asn.asn %>
|
||||||
|
<% if asn.asn_org.present? %>
|
||||||
|
<span class="ml-2 text-gray-600">• <%= asn.asn_org.truncate(30) %></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-600">
|
||||||
|
<span><%= number_with_delimiter(asn.event_count) %> events</span>
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<span><%= number_with_delimiter(asn.unique_ips) %> unique IPs</span>
|
||||||
|
<span class="mx-2">•</span>
|
||||||
|
<span><%= number_with_delimiter(asn.network_count) %> networks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<%= link_to "Filter Events", events_path(asn: asn.asn),
|
||||||
|
class: "text-blue-600 hover:text-blue-800 text-sm font-medium" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500">No ASN data available for this time period.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<%= link_to "← Back to Dashboard", analytics_path,
|
||||||
|
class: "text-blue-600 hover:text-blue-800 font-medium" %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Showing network analytics for the <%= @time_period.to_s.humanize.downcase %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<%= form_with url: events_path, method: :get, local: true, class: "space-y-4" do |form| %>
|
<%= form_with url: events_path, method: :get, local: true, class: "space-y-4" do |form| %>
|
||||||
|
<!-- Basic Filters Row -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :ip, "IP Address", class: "block text-sm font-medium text-gray-700" %>
|
<%= form.label :ip, "IP Address", class: "block text-sm font-medium text-gray-700" %>
|
||||||
@@ -40,6 +41,43 @@
|
|||||||
class: "inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
class: "inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Intelligence Filters Row -->
|
||||||
|
<div class="border-t border-gray-200 pt-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-3">Network Intelligence Filters</h4>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<%= form.label :company, "Company", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :company, value: params[:company],
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
placeholder: "e.g., Amazon, Google" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :network_type, "Network Type", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :network_type,
|
||||||
|
options_for_select([
|
||||||
|
['All', ''],
|
||||||
|
['Standard ( Residential/Business )', 'standard'],
|
||||||
|
['Datacenter', 'datacenter'],
|
||||||
|
['VPN', 'vpn'],
|
||||||
|
['Proxy', 'proxy']
|
||||||
|
], params[:network_type]),
|
||||||
|
{ }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :asn, "ASN", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :asn, value: params[:asn],
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
placeholder: "Autonomous System Number" %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= form.label :network_cidr, "Network CIDR", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :network_cidr, value: params[:network_cidr],
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
placeholder: "e.g., 192.168.1.0/24" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +90,8 @@
|
|||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<%= link_to "📊 Analytics Dashboard", analytics_path,
|
<%= link_to "📊 Analytics Dashboard", analytics_path,
|
||||||
class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
|
class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
|
||||||
|
<%= link_to "🌐 Network Analytics", analytics_networks_path,
|
||||||
|
class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
|
||||||
<% if @pagy.pages > 1 %>
|
<% if @pagy.pages > 1 %>
|
||||||
<span class="text-sm text-gray-500">
|
<span class="text-sm text-gray-500">
|
||||||
Page <%= @pagy.page %> of <%= @pagy.pages %>
|
Page <%= @pagy.page %> of <%= @pagy.pages %>
|
||||||
@@ -92,8 +132,45 @@
|
|||||||
<%= event.timestamp.strftime("%Y-%m-%d") %>
|
<%= event.timestamp.strftime("%Y-%m-%d") %>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
<td class="px-6 py-4 text-sm text-gray-900">
|
||||||
<%= event.ip_address %>
|
<% network_range = @network_ranges_by_ip[event.ip_address.to_s] %>
|
||||||
|
<% if network_range %>
|
||||||
|
<%= link_to event.ip_address, network_range_path(network_range),
|
||||||
|
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono" %>
|
||||||
|
|
||||||
|
<!-- Network Intelligence Summary -->
|
||||||
|
<div class="mt-1 space-y-1">
|
||||||
|
<% if network_range.company.present? %>
|
||||||
|
<div class="text-xs text-gray-600 font-medium">
|
||||||
|
<%= network_range.company %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if network_range.is_datacenter? || network_range.is_vpn? || network_range.is_proxy? %>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<% if network_range.is_datacenter? %>
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-800" title="Datacenter">DC</span>
|
||||||
|
<% end %>
|
||||||
|
<% if network_range.is_vpn? %>
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800" title="VPN">VPN</span>
|
||||||
|
<% end %>
|
||||||
|
<% if network_range.is_proxy? %>
|
||||||
|
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800" title="Proxy">PROXY</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<%= network_range.cidr %>
|
||||||
|
<% if network_range.asn.present? %>
|
||||||
|
• ASN <%= network_range.asn %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<span class="font-mono"><%= event.ip_address %></span>
|
||||||
|
<div class="mt-1 text-xs text-gray-400">Unknown network</div>
|
||||||
|
<% end %>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
@@ -117,9 +194,9 @@
|
|||||||
<%= event.response_status || '-' %>
|
<%= event.response_status || '-' %>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
<% if event.country_code.present? %>
|
<% if event.lookup_country.present? %>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
<%= event.country_code %>
|
<%= event.lookup_country %>
|
||||||
</span>
|
</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400">-</span>
|
||||||
|
|||||||
@@ -68,6 +68,8 @@
|
|||||||
class: nav_link_class(events_path) %>
|
class: nav_link_class(events_path) %>
|
||||||
<%= link_to "⚙️ Rules", rules_path,
|
<%= link_to "⚙️ Rules", rules_path,
|
||||||
class: nav_link_class(rules_path) %>
|
class: nav_link_class(rules_path) %>
|
||||||
|
<%= link_to "🛡️ WAF Policies", waf_policies_path,
|
||||||
|
class: nav_link_class(waf_policies_path) %>
|
||||||
<%= link_to "🌐 Network Ranges", network_ranges_path,
|
<%= link_to "🌐 Network Ranges", network_ranges_path,
|
||||||
class: nav_link_class(network_ranges_path) %>
|
class: nav_link_class(network_ranges_path) %>
|
||||||
|
|
||||||
@@ -157,6 +159,8 @@
|
|||||||
class: mobile_nav_link_class(events_path) %>
|
class: mobile_nav_link_class(events_path) %>
|
||||||
<%= link_to "⚙️ Rules", rules_path,
|
<%= link_to "⚙️ Rules", rules_path,
|
||||||
class: mobile_nav_link_class(rules_path) %>
|
class: mobile_nav_link_class(rules_path) %>
|
||||||
|
<%= link_to "🛡️ WAF Policies", waf_policies_path,
|
||||||
|
class: mobile_nav_link_class(waf_policies_path) %>
|
||||||
<%= link_to "🌐 Network Ranges", network_ranges_path,
|
<%= link_to "🌐 Network Ranges", network_ranges_path,
|
||||||
class: mobile_nav_link_class(network_ranges_path) %>
|
class: mobile_nav_link_class(network_ranges_path) %>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
</nav>
|
</nav>
|
||||||
<div class="mt-2 flex items-center space-x-3">
|
<div class="mt-2 flex items-center space-x-3">
|
||||||
<h1 class="text-3xl font-bold text-gray-900"><%= @network_range.cidr %></h1>
|
<h1 class="text-3xl font-bold text-gray-900"><%= @network_range.cidr %></h1>
|
||||||
|
<% if @network_range.virtual? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
Virtual
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
<% if @network_range.ipv4? %>
|
<% if @network_range.ipv4? %>
|
||||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">IPv4</span>
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">IPv4</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -30,8 +38,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<%= link_to "Edit", edit_network_range_path(@network_range), class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
<% if @network_range.virtual? %>
|
||||||
<%= link_to "Create Rule", new_rule_path(network_range_id: @network_range.id), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
|
<%= link_to "Create Network", new_network_range_path(network: @network_range.cidr), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700" %>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to "Edit", edit_network_range_path(@network_range), class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
|
||||||
|
<%= link_to "Create Rule", new_rule_path(network_range_id: @network_range.id), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,20 +98,32 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div>
|
<% if @network_range.persisted? %>
|
||||||
<dt class="text-sm font-medium text-gray-500">Source</dt>
|
<div>
|
||||||
<dd class="mt-1 text-sm text-gray-900"><%= @network_range.source %></dd>
|
<dt class="text-sm font-medium text-gray-500">Source</dt>
|
||||||
</div>
|
<dd class="mt-1 text-sm text-gray-900"><%= @network_range.source %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900"><%= time_ago_in_words(@network_range.created_at) %> ago</dd>
|
<dd class="mt-1 text-sm text-gray-900"><%= time_ago_in_words(@network_range.created_at) %> ago</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Updated</dt>
|
<dt class="text-sm font-medium text-gray-500">Updated</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900"><%= time_ago_in_words(@network_range.updated_at) %> ago</dd>
|
<dd class="mt-1 text-sm text-gray-900"><%= time_ago_in_words(@network_range.updated_at) %> ago</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">Virtual Network</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Events Found</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @traffic_stats[:total_requests] %> requests</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<!-- Classification Flags -->
|
<!-- Classification Flags -->
|
||||||
<div class="md:col-span-2 lg:col-span-3">
|
<div class="md:col-span-2 lg:col-span-3">
|
||||||
@@ -200,17 +224,22 @@
|
|||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-lg font-medium text-gray-900">Associated Rules (<%= @associated_rules.count %>)</h3>
|
<h3 class="text-lg font-medium text-gray-900">Associated Rules (<%= @associated_rules.count %>)</h3>
|
||||||
<button type="button" onclick="toggleQuickCreateRule()" class="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
<% if @network_range.persisted? %>
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<button type="button" onclick="toggleQuickCreateRule()" class="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
Quick Create Rule
|
</svg>
|
||||||
</button>
|
Quick Create Rule
|
||||||
|
</button>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-sm text-gray-500">Create this network to add rules</span>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick Create Rule Form -->
|
<!-- Quick Create Rule Form -->
|
||||||
<div id="quick_create_rule" class="hidden border-b border-gray-200">
|
<% if @network_range.persisted? %>
|
||||||
|
<div id="quick_create_rule" class="hidden border-b border-gray-200">
|
||||||
<div class="px-6 py-4 bg-blue-50">
|
<div class="px-6 py-4 bg-blue-50">
|
||||||
<%= form_with(model: Rule.new, url: rules_path, local: true,
|
<%= form_with(model: Rule.new, url: rules_path, local: true,
|
||||||
class: "space-y-4",
|
class: "space-y-4",
|
||||||
@@ -384,6 +413,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<!-- Rules List -->
|
<!-- Rules List -->
|
||||||
<% if @associated_rules.any? %>
|
<% if @associated_rules.any? %>
|
||||||
@@ -436,8 +466,13 @@
|
|||||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No rules yet</h3>
|
<% if @network_range.virtual? %>
|
||||||
<p class="mt-1 text-sm text-gray-500">Get started by creating a rule for this network range.</p>
|
<h3 class="mt-2 text-sm font-medium text-gray-900">Virtual Network</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Create this network range to add rules and manage it permanently.</p>
|
||||||
|
<% else %>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No rules yet</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by creating a rule for this network range.</p>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
231
app/views/waf_policies/index.html.erb
Normal file
231
app/views/waf_policies/index.html.erb
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<% content_for :title, "WAF Policies" %>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">WAF Policies</h1>
|
||||||
|
<p class="mt-2 text-gray-600">High-level firewall policies that automatically generate rules</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "🌍 Block Countries", new_country_waf_policies_path,
|
||||||
|
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700" %>
|
||||||
|
<%= link_to "Create Policy", new_waf_policy_path,
|
||||||
|
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Total Policies</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@waf_policies.count) %></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Active Policies</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@waf_policies.active.count) %></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Generated Rules</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
|
<%= number_with_delimiter(Rule.policy_generated.count) %>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Deny Policies</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
|
<%= number_with_delimiter(@waf_policies.where(action: 'deny').count) %>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Policies Table -->
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
|
<div class="px-4 py-5 sm:px-6">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">Firewall Policies</h3>
|
||||||
|
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||||
|
High-level policies that automatically generate specific WAF rules when matching network ranges are discovered.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200">
|
||||||
|
<% if @waf_policies.any? %>
|
||||||
|
<ul class="divide-y divide-gray-200">
|
||||||
|
<% @waf_policies.each do |policy| %>
|
||||||
|
<li class="hover:bg-gray-50">
|
||||||
|
<div class="px-4 py-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<% if policy.country_policy? %>
|
||||||
|
<span class="text-2xl">🌍</span>
|
||||||
|
<% elsif policy.asn_policy? %>
|
||||||
|
<span class="text-2xl">🏢</span>
|
||||||
|
<% elsif policy.company_policy? %>
|
||||||
|
<span class="text-2xl">🏭</span>
|
||||||
|
<% elsif policy.network_type_policy? %>
|
||||||
|
<span class="text-2xl">🌐</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<%= link_to policy.name, waf_policy_path(policy),
|
||||||
|
class: "text-sm font-medium text-gray-900 hover:text-blue-600" %>
|
||||||
|
|
||||||
|
<!-- Status Badge -->
|
||||||
|
<% if policy.active? %>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Action Badge -->
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
|
<%= case policy.action
|
||||||
|
when 'deny' then 'bg-red-100 text-red-800'
|
||||||
|
when 'allow' then 'bg-green-100 text-green-800'
|
||||||
|
when 'redirect' then 'bg-yellow-100 text-yellow-800'
|
||||||
|
when 'challenge' then 'bg-purple-100 text-purple-800'
|
||||||
|
end %>">
|
||||||
|
<%= policy.action.upcase %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-500">
|
||||||
|
<%= policy.policy_type.humanize %> policy targeting
|
||||||
|
<% if policy.targets.length > 3 %>
|
||||||
|
<%= policy.targets.length %> items
|
||||||
|
<% else %>
|
||||||
|
<%= policy.targets.join(', ') %>
|
||||||
|
<% end %>
|
||||||
|
• <%= policy.generated_rules_count %> rules generated
|
||||||
|
</div>
|
||||||
|
<% if policy.description.present? %>
|
||||||
|
<div class="mt-1 text-sm text-gray-600">
|
||||||
|
<%= policy.description %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<%= link_to "View", waf_policy_path(policy),
|
||||||
|
class: "inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50" %>
|
||||||
|
|
||||||
|
<% if policy.active? %>
|
||||||
|
<%= link_to "Deactivate", deactivate_waf_policy_path(policy),
|
||||||
|
method: :post,
|
||||||
|
data: { confirm: "Are you sure you want to deactivate this policy?" },
|
||||||
|
class: "inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50" %>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to "Activate", activate_waf_policy_path(policy),
|
||||||
|
method: :post,
|
||||||
|
class: "inline-flex items-center px-3 py-1 border border-transparent shadow-sm text-xs font-medium rounded text-white bg-green-600 hover:bg-green-700" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to "Edit", edit_waf_policy_path(policy),
|
||||||
|
class: "inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No policies</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by creating your first WAF policy.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<%= link_to "Create Policy", new_waf_policy_path,
|
||||||
|
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<% if @waf_policies.respond_to?(:total_pages) && @waf_policies.total_pages > 1 %>
|
||||||
|
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||||
|
<div class="flex-1 flex justify-between sm:hidden">
|
||||||
|
<%= link_to_previous_page @waf_policies, "Previous", class: "relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" %>
|
||||||
|
<%= link_to_next_page @waf_policies, "Next", class: "ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" %>
|
||||||
|
</div>
|
||||||
|
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-700">
|
||||||
|
Showing
|
||||||
|
<span class="font-medium"><%= (@waf_policies.current_page - 1) * @waf_policies.limit_value + 1 %></span>
|
||||||
|
to
|
||||||
|
<span class="font-medium"><%= [@waf_policies.current_page * @waf_policies.limit_value, @waf_policies.total_count].min %></span>
|
||||||
|
of
|
||||||
|
<span class="font-medium"><%= number_with_delimiter(@waf_policies.total_count) %></span>
|
||||||
|
results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%== pagy_nav(@pagy) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -38,6 +38,7 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
# Analytics dashboard
|
# Analytics dashboard
|
||||||
get "analytics", to: "analytics#index"
|
get "analytics", to: "analytics#index"
|
||||||
|
get "analytics/networks", to: "analytics#networks"
|
||||||
|
|
||||||
# Root path - analytics dashboard
|
# Root path - analytics dashboard
|
||||||
root "analytics#index"
|
root "analytics#index"
|
||||||
@@ -66,4 +67,16 @@ Rails.application.routes.draw do
|
|||||||
post :enable
|
post :enable
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# WAF Policy management
|
||||||
|
resources :waf_policies, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
|
||||||
|
member do
|
||||||
|
post :activate
|
||||||
|
post :deactivate
|
||||||
|
end
|
||||||
|
collection do
|
||||||
|
get :new_country
|
||||||
|
post :create_country
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class RemoveGeoFieldsFromEvents < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
remove_column :events, :country_code, :string
|
||||||
|
remove_column :events, :city, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
23
db/migrate/20251110023053_create_waf_policies.rb
Normal file
23
db/migrate/20251110023053_create_waf_policies.rb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
class CreateWafPolicies < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :waf_policies do |t|
|
||||||
|
t.string :name, null: false
|
||||||
|
t.text :description
|
||||||
|
t.string :policy_type, null: false, default: 'country'
|
||||||
|
t.string :action, null: false, default: 'deny'
|
||||||
|
t.json :targets, default: []
|
||||||
|
t.boolean :enabled, default: true, null: false
|
||||||
|
t.datetime :expires_at
|
||||||
|
t.references :user, null: false, foreign_key: true
|
||||||
|
t.json :additional_data, default: {}
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add indexes for efficient policy matching
|
||||||
|
add_index :waf_policies, [:policy_type, :enabled], name: "idx_waf_policies_type_enabled"
|
||||||
|
add_index :waf_policies, :enabled
|
||||||
|
add_index :waf_policies, :expires_at
|
||||||
|
add_index :waf_policies, :name, unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
6
db/migrate/20251110023232_add_waf_policy_to_rules.rb
Normal file
6
db/migrate/20251110023232_add_waf_policy_to_rules.rb
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class AddWafPolicyToRules < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
add_reference :rules, :waf_policy, null: true, foreign_key: true
|
||||||
|
add_index :rules, :waf_policy_id, name: "idx_rules_waf_policy"
|
||||||
|
end
|
||||||
|
end
|
||||||
28
db/schema.rb
28
db/schema.rb
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
|
ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
|
|
||||||
@@ -27,8 +27,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
|
|||||||
t.string "agent_name"
|
t.string "agent_name"
|
||||||
t.string "agent_version"
|
t.string "agent_version"
|
||||||
t.text "blocked_reason"
|
t.text "blocked_reason"
|
||||||
t.string "city"
|
|
||||||
t.string "country_code"
|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "environment"
|
t.string "environment"
|
||||||
t.string "event_id", null: false
|
t.string "event_id", null: false
|
||||||
@@ -140,6 +138,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
|
|||||||
t.string "source", limit: 100, default: "manual"
|
t.string "source", limit: 100, default: "manual"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.bigint "user_id"
|
t.bigint "user_id"
|
||||||
|
t.bigint "waf_policy_id"
|
||||||
t.index ["action"], name: "index_rules_on_action"
|
t.index ["action"], name: "index_rules_on_action"
|
||||||
t.index ["enabled", "expires_at"], name: "idx_rules_active"
|
t.index ["enabled", "expires_at"], name: "idx_rules_active"
|
||||||
t.index ["enabled"], name: "index_rules_on_enabled"
|
t.index ["enabled"], name: "index_rules_on_enabled"
|
||||||
@@ -151,6 +150,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
|
|||||||
t.index ["source"], name: "index_rules_on_source"
|
t.index ["source"], name: "index_rules_on_source"
|
||||||
t.index ["updated_at", "id"], name: "idx_rules_sync"
|
t.index ["updated_at", "id"], name: "idx_rules_sync"
|
||||||
t.index ["user_id"], name: "index_rules_on_user_id"
|
t.index ["user_id"], name: "index_rules_on_user_id"
|
||||||
|
t.index ["waf_policy_id"], name: "idx_rules_waf_policy"
|
||||||
|
t.index ["waf_policy_id"], name: "index_rules_on_waf_policy_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "sessions", force: :cascade do |t|
|
create_table "sessions", force: :cascade do |t|
|
||||||
@@ -171,9 +172,30 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
|
|||||||
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
t.index ["email_address"], name: "index_users_on_email_address", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "waf_policies", force: :cascade do |t|
|
||||||
|
t.string "action", default: "deny", null: false
|
||||||
|
t.json "additional_data", default: {}
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.text "description"
|
||||||
|
t.boolean "enabled", default: true, null: false
|
||||||
|
t.datetime "expires_at"
|
||||||
|
t.string "name", null: false
|
||||||
|
t.string "policy_type", default: "country", null: false
|
||||||
|
t.json "targets", default: []
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.bigint "user_id", null: false
|
||||||
|
t.index ["enabled"], name: "index_waf_policies_on_enabled"
|
||||||
|
t.index ["expires_at"], name: "index_waf_policies_on_expires_at"
|
||||||
|
t.index ["name"], name: "index_waf_policies_on_name", unique: true
|
||||||
|
t.index ["policy_type", "enabled"], name: "idx_waf_policies_type_enabled"
|
||||||
|
t.index ["user_id"], name: "index_waf_policies_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
add_foreign_key "events", "request_hosts"
|
add_foreign_key "events", "request_hosts"
|
||||||
add_foreign_key "network_ranges", "users"
|
add_foreign_key "network_ranges", "users"
|
||||||
add_foreign_key "rules", "network_ranges"
|
add_foreign_key "rules", "network_ranges"
|
||||||
add_foreign_key "rules", "users"
|
add_foreign_key "rules", "users"
|
||||||
|
add_foreign_key "rules", "waf_policies"
|
||||||
add_foreign_key "sessions", "users"
|
add_foreign_key "sessions", "users"
|
||||||
|
add_foreign_key "waf_policies", "users"
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user