From 772fae7e8be8223d82b26271ac286ac128b1e711 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Mon, 10 Nov 2025 14:10:37 +1100 Subject: [PATCH] Add WafPolicies --- Gemfile | 3 + app/controllers/analytics_controller.rb | 179 ++++++++ app/controllers/events_controller.rb | 23 + app/controllers/network_ranges_controller.rb | 96 +++-- app/controllers/waf_policies_controller.rb | 165 ++++++++ app/jobs/process_waf_event_job.rb | 17 + app/jobs/process_waf_policies_job.rb | 101 +++++ app/models/event.rb | 133 +++--- app/models/network_range.rb | 5 + app/models/rule.rb | 44 +- app/models/waf_policy.rb | 399 ++++++++++++++++++ app/views/analytics/index.html.erb | 6 +- app/views/analytics/networks.html.erb | 283 +++++++++++++ app/views/events/index.html.erb | 85 +++- app/views/layouts/application.html.erb | 4 + app/views/network_ranges/show.html.erb | 81 +++- app/views/waf_policies/index.html.erb | 231 ++++++++++ config/routes.rb | 13 + ...110015542_remove_geo_fields_from_events.rb | 6 + .../20251110023053_create_waf_policies.rb | 23 + .../20251110023232_add_waf_policy_to_rules.rb | 6 + db/schema.rb | 28 +- 22 files changed, 1784 insertions(+), 147 deletions(-) create mode 100644 app/controllers/waf_policies_controller.rb create mode 100644 app/jobs/process_waf_policies_job.rb create mode 100644 app/models/waf_policy.rb create mode 100644 app/views/analytics/networks.html.erb create mode 100644 app/views/waf_policies/index.html.erb create mode 100644 db/migrate/20251110015542_remove_geo_fields_from_events.rb create mode 100644 db/migrate/20251110023053_create_waf_policies.rb create mode 100644 db/migrate/20251110023232_add_waf_policy_to_rules.rb diff --git a/Gemfile b/Gemfile index ce3ef74..f662b2f 100644 --- a/Gemfile +++ b/Gemfile @@ -57,6 +57,9 @@ gem "maxmind-db" # HTTP client for database downloads gem "httparty" +# Country data and ISO code utilities +gem "countries" + # Authorization library gem "pundit" diff --git a/app/controllers/analytics_controller.rb b/app/controllers/analytics_controller.rb index 4031a38..ec73907 100644 --- a/app/controllers/analytics_controller.rb +++ b/app/controllers/analytics_controller.rb @@ -76,6 +76,57 @@ class AnalyticsController < ApplicationController end end + def networks + authorize :analytics, :index? + + # Time period selector (default: last 24 hours) + @time_period = params[:period]&.to_sym || :day + @start_time = calculate_start_time(@time_period) + + # Top networks by request volume + @top_networks = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") + .where("events.timestamp >= ? OR events.timestamp IS NULL", @start_time) + .group("network_ranges.id", "network_ranges.network", "network_ranges.company", "network_ranges.asn", "network_ranges.country", "network_ranges.is_datacenter", "network_ranges.is_vpn", "network_ranges.is_proxy") + .select("network_ranges.*, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips") + .order("event_count DESC") + .limit(50) + + # Network type breakdown with traffic stats + @network_breakdown = calculate_network_type_stats(@start_time) + + # Company breakdown for top traffic sources + @top_companies = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") + .where("events.timestamp >= ? AND network_ranges.company IS NOT NULL", @start_time) + .group("network_ranges.company") + .select("network_ranges.company, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count") + .order("event_count DESC") + .limit(20) + + # ASN breakdown + @top_asns = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") + .where("events.timestamp >= ? AND network_ranges.asn IS NOT NULL", @start_time) + .group("network_ranges.asn", "network_ranges.asn_org") + .select("network_ranges.asn, network_ranges.asn_org, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count") + .order("event_count DESC") + .limit(15) + + # Geographic breakdown + @top_countries = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") + .where("events.timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time) + .group("network_ranges.country") + .select("network_ranges.country, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count") + .order("event_count DESC") + .limit(15) + + # Suspicious network activity patterns + @suspicious_patterns = calculate_suspicious_patterns(@start_time) + + respond_to do |format| + format.html + format.json { render json: network_analytics_json } + end + end + private def calculate_start_time(period) @@ -132,4 +183,132 @@ class AnalyticsController < ApplicationController ] } 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 \ No newline at end of file diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index b2602f9..7a546b2 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -11,6 +11,12 @@ class EventsController < ApplicationController @events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].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}" # Debug info @@ -19,7 +25,24 @@ class EventsController < ApplicationController # Paginate @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 "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages" + Rails.logger.debug "Preloaded network ranges for #{@network_ranges_by_ip.count} unique IPs" end end \ No newline at end of file diff --git a/app/controllers/network_ranges_controller.rb b/app/controllers/network_ranges_controller.rb index 4d91482..e056b9f 100644 --- a/app/controllers/network_ranges_controller.rb +++ b/app/controllers/network_ranges_controller.rb @@ -14,17 +14,20 @@ class NetworkRangesController < ApplicationController # GET /network_ranges def index - @pagy, @network_ranges = pagy(policy_scope(NetworkRange.includes(:rules)) - .order(updated_at: :desc)) + # Start with base scope + base_scope = policy_scope(NetworkRange.includes(:rules)).order(updated_at: :desc) - # Apply filters - @network_ranges = apply_filters(@network_ranges) + # Apply filters BEFORE pagination + base_scope = apply_filters(base_scope) - # Apply search + # Apply search BEFORE pagination if params[:search].present? - @network_ranges = search_network_ranges(@network_ranges, params[:search]) + base_scope = search_network_ranges(base_scope, params[:search]) end + # Apply pagination to the filtered scope + @pagy, @network_ranges = pagy(base_scope) + # Statistics @total_ranges = NetworkRange.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 def show authorize @network_range - @related_events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network") - .where("network_ranges.id = ?", @network_range.id) - .recent - .limit(100) + + if @network_range.persisted? + # Real network - use existing logic + @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) @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_stats = calculate_traffic_stats(@network_range) @@ -57,7 +69,7 @@ class NetworkRangesController < ApplicationController # GET /network_ranges/new def new authorize NetworkRange - @network_range = NetworkRange.new + @network_range = NetworkRange.new(network: params[:network]) end # POST /network_ranges @@ -154,7 +166,12 @@ class NetworkRangesController < ApplicationController def set_network_range # Handle CIDR slugs (e.g., "40.77.167.100_32" -> "40.77.167.100/32") 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 def network_range_params @@ -194,15 +211,43 @@ class NetworkRangesController < ApplicationController end def calculate_traffic_stats(network_range) - # Use the cached events_count for total requests (much more performant) - # For detailed breakdown, we still need to query but we can optimize with a limit - if network_range.events_count > 0 - events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network") - .where("network_ranges.id = ?", network_range.id) - .limit(1000) # Limit the sample for performance + if network_range.persisted? + # Real network - use cached events_count for total requests (much more performant) + if network_range.events_count > 0 + events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network") + .where("network_ranges.id = ?", network_range.id) + .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), blocked_requests: events.blocked.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), 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 \ No newline at end of file diff --git a/app/controllers/waf_policies_controller.rb b/app/controllers/waf_policies_controller.rb new file mode 100644 index 0000000..6a5bf75 --- /dev/null +++ b/app/controllers/waf_policies_controller.rb @@ -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 \ No newline at end of file diff --git a/app/jobs/process_waf_event_job.rb b/app/jobs/process_waf_event_job.rb index 8a10b6a..d62ac86 100644 --- a/app/jobs/process_waf_event_job.rb +++ b/app/jobs/process_waf_event_job.rb @@ -35,6 +35,23 @@ class ProcessWafEventJob < ApplicationJob 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 ProcessWafAnalyticsJob.perform_later(event_id: event.id) diff --git a/app/jobs/process_waf_policies_job.rb b/app/jobs/process_waf_policies_job.rb new file mode 100644 index 0000000..74a0263 --- /dev/null +++ b/app/jobs/process_waf_policies_job.rb @@ -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 \ No newline at end of file diff --git a/app/models/event.rb b/app/models/event.rb index 7561577..05b098d 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -32,10 +32,36 @@ class Event < ApplicationRecord scope :by_ip, ->(ip) { where(ip_address: ip) } scope :by_user_agent, ->(user_agent) { where(user_agent: user_agent) } scope :by_waf_action, ->(waf_action) { where(waf_action: waf_action) } - scope :blocked, -> { where(waf_action: ['block', 'deny']) } - scope :allowed, -> { where(waf_action: ['allow', 'pass']) } + scope :blocked, -> { where(waf_action: :deny) } + scope :allowed, -> { where(waf_action: :allow) } 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) scope :with_path_prefix, ->(prefix_segment_ids) { return none if prefix_segment_ids.blank? @@ -112,10 +138,7 @@ class Event < ApplicationRecord server_name: normalized_payload["server_name"], environment: normalized_payload["environment"], - # Geographic data - country_code: normalized_payload.dig("geo", "country_code"), - city: normalized_payload.dig("geo", "city"), - + # WAF agent info agent_version: normalized_payload.dig("agent", "version"), agent_name: normalized_payload.dig("agent", "name") @@ -269,7 +292,7 @@ class Event < ApplicationRecord def matching_network_ranges 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, cidr: range.cidr, @@ -360,86 +383,34 @@ class Event < ApplicationRecord active_blocking_rules.exists? end - # GeoIP enrichment methods (now uses network range data when available) - 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 + # Get full geo location details from network range def geo_location network_info = network_intelligence { - country_code: country_code || network_info[:country], - city: city, + country_code: network_info[:country], ip_address: ip_address, - has_data: has_geo_data?, + has_data: network_info[:country].present?, network_intelligence: network_info } 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 def should_normalize? @@ -483,11 +454,7 @@ class Event < ApplicationRecord self.server_name = payload["server_name"] 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 agent_data = payload.dig("agent") || {} self.agent_version = agent_data["version"] diff --git a/app/models/network_range.rb b/app/models/network_range.rb index 7aa0d8d..066b37f 100644 --- a/app/models/network_range.rb +++ b/app/models/network_range.rb @@ -73,6 +73,11 @@ class NetworkRange < ApplicationRecord addr.include?(':') ? 6 : 4 end + def virtual? + # Virtual networks are unsaved instances (not persisted to database) + !persisted? + end + def ipv4? family == 4 end diff --git a/app/models/rule.rb b/app/models/rule.rb index 231665c..03b757e 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -7,12 +7,13 @@ class Rule < ApplicationRecord # Rule types and actions RULE_TYPES = %w[network rate_limit path_pattern].freeze - ACTIONS = %w[allow deny rate_limit redirect log].freeze - SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default manual:surgical_block manual:surgical_exception].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 policy].freeze # Associations belongs_to :user belongs_to :network_range, optional: true + belongs_to :waf_policy, optional: true # Validations validates :rule_type, presence: true, inclusion: { in: RULE_TYPES } @@ -39,6 +40,8 @@ class Rule < ApplicationRecord scope :by_source, ->(source) { where(source: source) } scope :surgical_blocks, -> { where(source: "manual:surgical_block") } 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 scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) } @@ -94,6 +97,37 @@ class Rule < ApplicationRecord source == "manual:surgical_exception" 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 if surgical_block? # Find the corresponding exception rule @@ -365,6 +399,12 @@ class Rule < ApplicationRecord unless metadata&.dig("redirect_url").present? errors.add(:metadata, "must include 'redirect_url' for redirect action") 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" unless metadata&.dig("limit").present? && metadata&.dig("window").present? errors.add(:metadata, "must include 'limit' and 'window' for rate_limit action") diff --git a/app/models/waf_policy.rb b/app/models/waf_policy.rb new file mode 100644 index 0000000..20ab7a9 --- /dev/null +++ b/app/models/waf_policy.rb @@ -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 diff --git a/app/views/analytics/index.html.erb b/app/views/analytics/index.html.erb index 20e1283..cf98e7f 100644 --- a/app/views/analytics/index.html.erb +++ b/app/views/analytics/index.html.erb @@ -239,7 +239,11 @@
-

Network Intelligence

+
+

Network Intelligence

+ <%= link_to "Detailed Network Analytics →", analytics_networks_path, + class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> +
diff --git a/app/views/analytics/networks.html.erb b/app/views/analytics/networks.html.erb new file mode 100644 index 0000000..8dd7f37 --- /dev/null +++ b/app/views/analytics/networks.html.erb @@ -0,0 +1,283 @@ +<% content_for :title, "Network Analytics - Baffle Hub" %> + +
+ +
+

Network Analytics

+

Detailed traffic analysis and network intelligence insights

+
+ + +
+
+
+

Time Period

+
+ <% [: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 %> +
+
+
+
+ + +
+
+
+
+
+ + + +
+
+

Standard Networks

+

+ <%= number_with_delimiter(@network_breakdown.dig('standard', :networks) || 0) %> +

+

+ <%= number_with_delimiter(@network_breakdown.dig('standard', :events) || 0) %> events + (<%= @network_breakdown.dig('standard', :percentage) || 0 %>%) +

+
+
+
+
+ +
+
+
+
+ + + +
+
+

Datacenter Networks

+

+ <%= number_with_delimiter(@network_breakdown.dig('datacenter', :networks) || 0) %> +

+

+ <%= number_with_delimiter(@network_breakdown.dig('datacenter', :events) || 0) %> events + (<%= @network_breakdown.dig('datacenter', :percentage) || 0 %>%) +

+
+
+
+
+ +
+
+
+
+ + + +
+
+

VPN Networks

+

+ <%= number_with_delimiter(@network_breakdown.dig('vpn', :networks) || 0) %> +

+

+ <%= number_with_delimiter(@network_breakdown.dig('vpn', :events) || 0) %> events + (<%= @network_breakdown.dig('vpn', :percentage) || 0 %>%) +

+
+
+
+
+ +
+
+
+
+ + + +
+
+

Proxy Networks

+

+ <%= number_with_delimiter(@network_breakdown.dig('proxy', :networks) || 0) %> +

+

+ <%= number_with_delimiter(@network_breakdown.dig('proxy', :events) || 0) %> events + (<%= @network_breakdown.dig('proxy', :percentage) || 0 %>%) +

+
+
+
+
+
+ + +
+
+

Top Networks by Traffic Volume

+

Networks with the most requests in the selected time period

+
+
+ <% if @top_networks.any? %> + + + + + + + + + + + + + <% @top_networks.each do |network| %> + + + + + + + + + <% end %> + +
NetworkCompanyTypeEventsUnique IPsActions
+
+ <%= link_to network.cidr, network_range_path(network), + class: "text-blue-600 hover:text-blue-800 hover:underline font-mono font-medium" %> +
+
+ <% if network.country.present? %> + 🏳️ <%= network.country %> + <% end %> + <% if network.asn.present? %> + • ASN <%= network.asn %> + <% end %> +
+
+ <%= network.company || 'Unknown' %> + + <% if network.is_datacenter? %> + Datacenter + <% elsif network.is_vpn? %> + VPN + <% elsif network.is_proxy? %> + Proxy + <% else %> + Standard + <% end %> + + <%= number_with_delimiter(network.event_count) %> + + <%= number_with_delimiter(network.unique_ips) %> + + <%= link_to "View Events", events_path(network_cidr: network.cidr), + class: "text-blue-600 hover:text-blue-800 text-sm" %> +
+ <% else %> +
+ + + +

No network traffic

+

No network activity found in the selected time period.

+
+ <% end %> +
+
+ + +
+ +
+
+

Top Companies by Traffic

+

Companies generating the most traffic

+
+
+ <% if @top_companies.any? %> +
+ <% @top_companies.each do |company| %> +
+
+
+
<%= company.company %>
+
+ <%= number_with_delimiter(company.network_count) %> networks +
+
+
+
+ <%= number_with_delimiter(company.event_count) %> events + + <%= number_with_delimiter(company.unique_ips) %> unique IPs +
+
+
+
+ <%= link_to "Filter Events", events_path(company: company.company), + class: "text-blue-600 hover:text-blue-800 text-sm font-medium" %> +
+
+ <% end %> +
+ <% else %> +

No company data available for this time period.

+ <% end %> +
+
+ + +
+
+

Top Autonomous Systems

+

ASNs with the most traffic

+
+
+ <% if @top_asns.any? %> +
+ <% @top_asns.each do |asn| %> +
+
+
+ ASN <%= asn.asn %> + <% if asn.asn_org.present? %> + • <%= asn.asn_org.truncate(30) %> + <% end %> +
+
+ <%= number_with_delimiter(asn.event_count) %> events + + <%= number_with_delimiter(asn.unique_ips) %> unique IPs + + <%= number_with_delimiter(asn.network_count) %> networks +
+
+
+ <%= link_to "Filter Events", events_path(asn: asn.asn), + class: "text-blue-600 hover:text-blue-800 text-sm font-medium" %> +
+
+ <% end %> +
+ <% else %> +

No ASN data available for this time period.

+ <% end %> +
+
+
+ + +
+
+ <%= link_to "← Back to Dashboard", analytics_path, + class: "text-blue-600 hover:text-blue-800 font-medium" %> +
+
+ Showing network analytics for the <%= @time_period.to_s.humanize.downcase %> +
+
+
\ No newline at end of file diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index c855788..bb215c6 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -14,6 +14,7 @@
<%= form_with url: events_path, method: :get, local: true, class: "space-y-4" do |form| %> +
<%= 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" %>
+ + +
+

Network Intelligence Filters

+
+
+ <%= 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" %> +
+
+ <%= 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" } %> +
+
+ <%= 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" %> +
+
+ <%= 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" %> +
+
+
<% end %>
@@ -52,6 +90,8 @@
<%= link_to "📊 Analytics Dashboard", analytics_path, 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 %> Page <%= @pagy.page %> of <%= @pagy.pages %> @@ -92,8 +132,45 @@ <%= event.timestamp.strftime("%Y-%m-%d") %>
- - <%= 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" %> + + +
+ <% if network_range.company.present? %> +
+ <%= network_range.company %> +
+ <% end %> + + <% if network_range.is_datacenter? || network_range.is_vpn? || network_range.is_proxy? %> +
+ <% if network_range.is_datacenter? %> + DC + <% end %> + <% if network_range.is_vpn? %> + VPN + <% end %> + <% if network_range.is_proxy? %> + PROXY + <% end %> +
+ <% end %> + +
+ <%= network_range.cidr %> + <% if network_range.asn.present? %> + • ASN <%= network_range.asn %> + <% end %> +
+
+ <% else %> + <%= event.ip_address %> +
Unknown network
+ <% end %> - <% if event.country_code.present? %> + <% if event.lookup_country.present? %> - <%= event.country_code %> + <%= event.lookup_country %> <% else %> - diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index fd332db..cb1dd9b 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -68,6 +68,8 @@ class: nav_link_class(events_path) %> <%= link_to "⚙️ Rules", 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, class: nav_link_class(network_ranges_path) %> @@ -157,6 +159,8 @@ class: mobile_nav_link_class(events_path) %> <%= link_to "⚙️ Rules", 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, class: mobile_nav_link_class(network_ranges_path) %> diff --git a/app/views/network_ranges/show.html.erb b/app/views/network_ranges/show.html.erb index 3889d35..0390515 100644 --- a/app/views/network_ranges/show.html.erb +++ b/app/views/network_ranges/show.html.erb @@ -22,6 +22,14 @@

<%= @network_range.cidr %>

+ <% if @network_range.virtual? %> + + + + + Virtual + + <% end %> <% if @network_range.ipv4? %> IPv4 <% else %> @@ -30,8 +38,12 @@
- <%= 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" %> + <% if @network_range.virtual? %> + <%= 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 %>
@@ -86,20 +98,32 @@ <% end %> -
-
Source
-
<%= @network_range.source %>
-
+ <% if @network_range.persisted? %> +
+
Source
+
<%= @network_range.source %>
+
-
-
Created
-
<%= time_ago_in_words(@network_range.created_at) %> ago
-
+
+
Created
+
<%= time_ago_in_words(@network_range.created_at) %> ago
+
-
-
Updated
-
<%= time_ago_in_words(@network_range.updated_at) %> ago
-
+
+
Updated
+
<%= time_ago_in_words(@network_range.updated_at) %> ago
+
+ <% else %> +
+
Status
+
Virtual Network
+
+ +
+
Events Found
+
<%= @traffic_stats[:total_requests] %> requests
+
+ <% end %>
@@ -200,17 +224,22 @@

Associated Rules (<%= @associated_rules.count %>)

- + <% if @network_range.persisted? %> + + <% else %> + Create this network to add rules + <% end %>
- <% end %>
diff --git a/app/views/waf_policies/index.html.erb b/app/views/waf_policies/index.html.erb new file mode 100644 index 0000000..33331bf --- /dev/null +++ b/app/views/waf_policies/index.html.erb @@ -0,0 +1,231 @@ +<% content_for :title, "WAF Policies" %> + +
+ +
+
+

WAF Policies

+

High-level firewall policies that automatically generate rules

+
+
+ <%= 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" %> +
+
+ + +
+
+
+
+
+ + + +
+
+
+
Total Policies
+
<%= number_with_delimiter(@waf_policies.count) %>
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Active Policies
+
<%= number_with_delimiter(@waf_policies.active.count) %>
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Generated Rules
+
+ <%= number_with_delimiter(Rule.policy_generated.count) %> +
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Deny Policies
+
+ <%= number_with_delimiter(@waf_policies.where(action: 'deny').count) %> +
+
+
+
+
+
+
+ + +
+
+

Firewall Policies

+

+ High-level policies that automatically generate specific WAF rules when matching network ranges are discovered. +

+
+
+ <% if @waf_policies.any? %> +
    + <% @waf_policies.each do |policy| %> +
  • +
    +
    +
    +
    + <% if policy.country_policy? %> + 🌍 + <% elsif policy.asn_policy? %> + 🏢 + <% elsif policy.company_policy? %> + 🏭 + <% elsif policy.network_type_policy? %> + 🌐 + <% end %> +
    +
    +
    + <%= link_to policy.name, waf_policy_path(policy), + class: "text-sm font-medium text-gray-900 hover:text-blue-600" %> + + + <% if policy.active? %> + + Active + + <% else %> + + Inactive + + <% end %> + + + + <%= policy.action.upcase %> + +
    +
    + <%= 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 +
    + <% if policy.description.present? %> +
    + <%= policy.description %> +
    + <% end %> +
    +
    +
    + <%= 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" %> +
    +
    +
    +
  • + <% end %> +
+ <% else %> +
+ + + +

No policies

+

Get started by creating your first WAF policy.

+
+ <%= 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" %> +
+
+ <% end %> +
+
+ + + <% if @waf_policies.respond_to?(:total_pages) && @waf_policies.total_pages > 1 %> +
+
+ <%= 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" %> +
+ +
+ <% end %> +
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 892d6bc..9a1673b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,7 @@ Rails.application.routes.draw do # Analytics dashboard get "analytics", to: "analytics#index" + get "analytics/networks", to: "analytics#networks" # Root path - analytics dashboard root "analytics#index" @@ -66,4 +67,16 @@ Rails.application.routes.draw do post :enable 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 diff --git a/db/migrate/20251110015542_remove_geo_fields_from_events.rb b/db/migrate/20251110015542_remove_geo_fields_from_events.rb new file mode 100644 index 0000000..6b0fd4e --- /dev/null +++ b/db/migrate/20251110015542_remove_geo_fields_from_events.rb @@ -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 diff --git a/db/migrate/20251110023053_create_waf_policies.rb b/db/migrate/20251110023053_create_waf_policies.rb new file mode 100644 index 0000000..908d4a2 --- /dev/null +++ b/db/migrate/20251110023053_create_waf_policies.rb @@ -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 diff --git a/db/migrate/20251110023232_add_waf_policy_to_rules.rb b/db/migrate/20251110023232_add_waf_policy_to_rules.rb new file mode 100644 index 0000000..6ec8546 --- /dev/null +++ b/db/migrate/20251110023232_add_waf_policy_to_rules.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index fd471d1..30f11ff 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 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_version" t.text "blocked_reason" - t.string "city" - t.string "country_code" t.datetime "created_at", null: false t.string "environment" 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.datetime "updated_at", null: false t.bigint "user_id" + t.bigint "waf_policy_id" t.index ["action"], name: "index_rules_on_action" t.index ["enabled", "expires_at"], name: "idx_rules_active" 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 ["updated_at", "id"], name: "idx_rules_sync" 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 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 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 "network_ranges", "users" add_foreign_key "rules", "network_ranges" add_foreign_key "rules", "users" + add_foreign_key "rules", "waf_policies" add_foreign_key "sessions", "users" + add_foreign_key "waf_policies", "users" end