From 108caf2fe63d7263de7869e680ae2980c1fa6070 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Thu, 25 Dec 2025 12:06:11 +1100 Subject: [PATCH 1/2] Expand geo ranges when possible --- app/models/network_range.rb | 27 +++++++++++++++ app/models/waf_policy.rb | 66 ++++++++++++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 8 deletions(-) diff --git a/app/models/network_range.rb b/app/models/network_range.rb index 0c98316..a147a06 100644 --- a/app/models/network_range.rb +++ b/app/models/network_range.rb @@ -136,6 +136,33 @@ class NetworkRange < ApplicationRecord .order("masklen(network) ASC") # Least specific child first end + # Find or create an ancestor network at a specific prefix length + # For example, given 192.168.1.0/24 and prefix 16, returns 192.168.0.0/16 + def find_or_create_ancestor_at_prefix(target_prefix) + return self if prefix_length <= target_prefix + + # Use PostgreSQL's set_masklen to create the ancestor CIDR + result = self.class.connection.execute( + "SELECT set_masklen('#{network}'::inet, #{target_prefix})::text as ancestor_cidr" + ).first + + return self unless result + + ancestor_cidr = result["ancestor_cidr"] + return self if ancestor_cidr == cidr + + # Find or create the ancestor network range + ancestor = NetworkRange.find_by(network: ancestor_cidr) + + if ancestor.nil? + # Create a virtual ancestor (not persisted, just for reference) + # The caller can decide whether to persist it + ancestor = NetworkRange.new(network: ancestor_cidr, source: 'inherited') + end + + ancestor + end + # Find nearest parent with intelligence data def parent_with_intelligence # Find all parent ranges (networks that contain this network) diff --git a/app/models/waf_policy.rb b/app/models/waf_policy.rb index 0f958e6..1c7d0bf 100644 --- a/app/models/waf_policy.rb +++ b/app/models/waf_policy.rb @@ -152,10 +152,14 @@ validate :targets_must_be_array def create_rule_for_network_range(network_range) return nil unless matches_network_range?(network_range) + # For country policies, expand to largest matching ancestor + # This consolidates /24 rules into /16, /8, etc. when possible + expanded_range = find_largest_matching_ancestor(network_range) + # Check for existing supernet rules before attempting to create - if network_range.supernet_rules.any? - supernet = network_range.supernet_rules.first - Rails.logger.debug "Skipping rule creation for #{network_range.cidr} - covered by supernet rule ##{supernet.id} (#{supernet.network_range.cidr})" + if expanded_range.supernet_rules.any? + supernet = expanded_range.supernet_rules.first + Rails.logger.debug "Skipping rule creation for #{expanded_range.cidr} - covered by supernet rule ##{supernet.id} (#{supernet.network_range.cidr})" return nil end @@ -164,21 +168,21 @@ validate :targets_must_be_array rule = Rule.create!( waf_rule_type: 'network', waf_action: policy_action.to_sym, - network_range: network_range, + network_range: expanded_range, waf_policy: self, user: user, source: "policy", - metadata: build_rule_metadata(network_range), - priority: network_range.prefix_length + metadata: build_rule_metadata(expanded_range), + priority: expanded_range.prefix_length ) rescue ActiveRecord::RecordNotUnique # Rule already exists (created by another job or earlier in this job) # Find and return the existing rule - Rails.logger.debug "Rule already exists for #{network_range.cidr} with policy #{name}" + Rails.logger.debug "Rule already exists for #{expanded_range.cidr} with policy #{name}" return Rule.find_by( waf_rule_type: 'network', waf_action: policy_action, - network_range: network_range, + network_range: expanded_range, waf_policy: self, source: "policy" ) @@ -505,6 +509,52 @@ validate :targets_must_be_array base_metadata.merge!(additional_data || {}) end + # For country policies, find the largest ancestor network that matches the same country + # This allows consolidating /24 rules into /16, /8, etc. when the entire block is in the same country + def find_largest_matching_ancestor(network_range) + return network_range unless country_policy? + + country = network_range.country || network_range.inherited_intelligence[:country] + return network_range unless country + + # Walk up from current prefix to /8 (IPv4) or /32 to /1 (IPv6) + current_prefix = network_range.prefix_length + max_prefix = network_range.ipv4? ? 8 : 1 + + current_prefix.step(-1, -1).each do |prefix| + break if prefix < max_prefix + + candidate = network_range.find_or_create_ancestor_at_prefix(prefix) + # Check if candidate has geo data (either direct or inherited) + candidate_country = candidate.country || (candidate.persisted? ? candidate.inherited_intelligence[:country] : nil) + + # For virtual (unpersisted) ancestors, we need to check if any existing records in that range have the country + if candidate_country.nil? && !candidate.persisted? + # Query database to see if networks in this range have the same country + country_check = NetworkRange.where("network <<= ?", candidate.cidr) + .where.not(country: nil) + .select(:country) + .distinct + # If all networks in this range have the same country, we can use it + if country_check.count == 1 && country_check.first.country == country + candidate_country = country + end + end + + # If ancestor has same country, use it (persist if virtual) + if candidate_country == country + if !candidate.persisted? + candidate.save! + Rails.logger.info "Created ancestor network range #{candidate.cidr} for country #{country}" + end + Rails.logger.debug "Expanded #{network_range.cidr} to #{candidate.cidr} (both #{country})" + return candidate + end + end + + network_range + end + def matched_field(network_range) case policy_type when 'country' -- 2.49.1 From e53e782223b15cf7846d543197ac7da03d44fab7 Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Sat, 27 Dec 2025 11:56:19 +1100 Subject: [PATCH 2/2] Find supernets, don't create them --- app/models/network_range.rb | 27 -------------- app/models/waf_policy.rb | 70 ++++++++++++++++++++++--------------- 2 files changed, 41 insertions(+), 56 deletions(-) diff --git a/app/models/network_range.rb b/app/models/network_range.rb index a147a06..0c98316 100644 --- a/app/models/network_range.rb +++ b/app/models/network_range.rb @@ -136,33 +136,6 @@ class NetworkRange < ApplicationRecord .order("masklen(network) ASC") # Least specific child first end - # Find or create an ancestor network at a specific prefix length - # For example, given 192.168.1.0/24 and prefix 16, returns 192.168.0.0/16 - def find_or_create_ancestor_at_prefix(target_prefix) - return self if prefix_length <= target_prefix - - # Use PostgreSQL's set_masklen to create the ancestor CIDR - result = self.class.connection.execute( - "SELECT set_masklen('#{network}'::inet, #{target_prefix})::text as ancestor_cidr" - ).first - - return self unless result - - ancestor_cidr = result["ancestor_cidr"] - return self if ancestor_cidr == cidr - - # Find or create the ancestor network range - ancestor = NetworkRange.find_by(network: ancestor_cidr) - - if ancestor.nil? - # Create a virtual ancestor (not persisted, just for reference) - # The caller can decide whether to persist it - ancestor = NetworkRange.new(network: ancestor_cidr, source: 'inherited') - end - - ancestor - end - # Find nearest parent with intelligence data def parent_with_intelligence # Find all parent ranges (networks that contain this network) diff --git a/app/models/waf_policy.rb b/app/models/waf_policy.rb index 1c7d0bf..932e689 100644 --- a/app/models/waf_policy.rb +++ b/app/models/waf_policy.rb @@ -517,41 +517,53 @@ validate :targets_must_be_array country = network_range.country || network_range.inherited_intelligence[:country] return network_range unless country - # Walk up from current prefix to /8 (IPv4) or /32 to /1 (IPv6) - current_prefix = network_range.prefix_length - max_prefix = network_range.ipv4? ? 8 : 1 + # Check if this network has IPAPI data with a larger CIDR (asn.route or ipapi_returned_cidr) + ipapi_cidr = network_range.network_data&.dig('ipapi', 'asn', 'route') || + network_range.network_data&.dig('ipapi_returned_cidr') - current_prefix.step(-1, -1).each do |prefix| - break if prefix < max_prefix - - candidate = network_range.find_or_create_ancestor_at_prefix(prefix) - # Check if candidate has geo data (either direct or inherited) - candidate_country = candidate.country || (candidate.persisted? ? candidate.inherited_intelligence[:country] : nil) - - # For virtual (unpersisted) ancestors, we need to check if any existing records in that range have the country - if candidate_country.nil? && !candidate.persisted? - # Query database to see if networks in this range have the same country - country_check = NetworkRange.where("network <<= ?", candidate.cidr) - .where.not(country: nil) - .select(:country) - .distinct - # If all networks in this range have the same country, we can use it - if country_check.count == 1 && country_check.first.country == country - candidate_country = country + if ipapi_cidr && ipapi_cidr != network_range.cidr + # IPAPI returned a larger network - use it if it exists + existing = NetworkRange.find_by(network: ipapi_cidr) + if existing + existing_country = existing.country || existing.inherited_intelligence[:country] + if existing_country == country + Rails.logger.debug "Using IPAPI CIDR #{existing.cidr} instead of #{network_range.cidr} (both #{country})" + return existing end - end - - # If ancestor has same country, use it (persist if virtual) - if candidate_country == country - if !candidate.persisted? - candidate.save! - Rails.logger.info "Created ancestor network range #{candidate.cidr} for country #{country}" + else + # Create the IPAPI network range if it doesn't exist + begin + ipapi_network = NetworkRange.create!( + network: ipapi_cidr, + source: 'inherited', + country: country + ) + Rails.logger.info "Created IPAPI network range #{ipapi_cidr} for country #{country}" + return ipapi_network + rescue ActiveRecord::RecordNotUnique + # Race condition - another process created it + existing = NetworkRange.find_by(network: ipapi_cidr) + return existing || network_range end - Rails.logger.debug "Expanded #{network_range.cidr} to #{candidate.cidr} (both #{country})" - return candidate end end + # Fallback: Look for existing parent networks with IPAPI data and same country + # Query for all networks that contain this network and have IPAPI data + parent_with_ipapi = NetworkRange.where( + "?::inet << network", network_range.cidr + ).where( + "network_data ? 'ipapi' AND " \ + "network_data -> 'ipapi' ->> 'location' ->> 'country_code' = ?", + country + ).order("masklen(network) DESC").first + + if parent_with_ipapi + Rails.logger.debug "Found existing IPAPI parent #{parent_with_ipapi.cidr} for #{network_range.cidr} (both #{country})" + return parent_with_ipapi + end + + # No expansion possible - use original network network_range end -- 2.49.1