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'