# 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 path_pattern].freeze # Actions - what to do when traffic matches this policy ACTIONS = %w[allow deny redirect challenge log].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 :policy_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_policy_action? validate :validate_challenge_configuration, if: :challenge_policy_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 def path_pattern_policy? policy_type == 'path_pattern' end # Action methods def allow_action? policy_action == 'allow' end def deny_action? policy_action == 'deny' end def redirect_action? policy_action == 'redirect' end def challenge_action? policy_action == 'challenge' end # Policy action methods (to avoid confusion with Rails' action methods) def allow_policy_action? policy_action == 'allow' end def deny_policy_action? policy_action == 'deny' end def redirect_policy_action? policy_action == 'redirect' end def challenge_policy_action? policy_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 # Event matching methods (for path patterns) def matches_event?(event) return false unless active? case policy_type when 'country', 'asn', 'company', 'network_type' # For network-based policies, use the event's network range event.network_range && matches_network_range?(event.network_range) when 'path_pattern' matches_path_patterns?(event) else false end end def create_rule_for_network_range(network_range) return nil unless matches_network_range?(network_range) rule = Rule.create!( waf_rule_type: 'network', waf_action: policy_action, network_range: network_range, waf_policy: self, user: user, source: "policy", 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 def create_rule_for_event(event) return nil unless matches_event?(event) # For path pattern policies, create a path_pattern rule if path_pattern_policy? # Check for existing path_pattern rule with same policy and patterns existing_rule = Rule.find_by( waf_rule_type: 'path_pattern', waf_action: policy_action, waf_policy: self, enabled: true ) if existing_rule Rails.logger.debug "Path pattern rule already exists for policy #{name}" return existing_rule end rule = Rule.create!( waf_rule_type: 'path_pattern', waf_action: policy_action, waf_policy: self, user: user, source: "policy", conditions: build_path_pattern_conditions(event), metadata: build_path_pattern_metadata(event), priority: 50 # Default priority for path rules ) # 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 else # For network-based policies, fall back to network range rule creation create_rule_for_network_range(event.network_range) end end # Class methods for creating common policies def self.create_country_policy(countries, policy_action: 'deny', user:, **options) create!( name: "#{policy_action.capitalize} #{countries.join(', ')}", policy_type: 'country', targets: Array(countries), policy_action: policy_action, user: user, **options ) end def self.create_asn_policy(asns, policy_action: 'deny', user:, **options) create!( name: "#{policy_action.capitalize} ASNs #{asns.join(', ')}", policy_type: 'asn', targets: Array(asns).map(&:to_i), policy_action: policy_action, user: user, **options ) end def self.create_company_policy(companies, policy_action: 'deny', user:, **options) create!( name: "#{policy_action.capitalize} #{companies.join(', ')}", policy_type: 'company', targets: Array(companies), policy_action: policy_action, user: user, **options ) end def self.create_network_type_policy(types, policy_action: 'deny', user:, **options) create!( name: "#{policy_action.capitalize} #{types.join(', ')}", policy_type: 'network_type', targets: Array(types), policy_action: policy_action, user: user, **options ) end def self.create_path_pattern_policy(patterns, policy_action: 'deny', user:, **options) create!( name: "#{policy_action.capitalize} path patterns: #{Array(patterns).join(', ')}", policy_type: 'path_pattern', targets: Array(patterns), policy_action: policy_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, policy_action: policy_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 when 'path_pattern' validate_path_pattern_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_path_pattern_targets unless targets.all? { |target| target.is_a?(String) && target.present? } errors.add(:targets, "must be valid path pattern strings") end # Validate path patterns format (basic validation) targets.each do |pattern| begin # Basic validation - ensure it's a reasonable pattern unless pattern.match?(/\A[a-zA-Z0-9\-\._\*\/\?\[\]\{\}]+\z/) errors.add(:targets, "contains invalid characters in pattern: #{pattern}") end rescue => e errors.add(:targets, "invalid path pattern: #{pattern} - #{e.message}") end 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 # Path pattern matching methods def matches_path_patterns?(event) return false if event.request_path.blank? path = event.request_path.downcase targets.any? { |pattern| matches_path_pattern?(pattern, path) } end def matches_path_pattern?(pattern, path) pattern = pattern.downcase # Handle different pattern types case pattern when /\*/, /\?/, /\[/ # Glob patterns - simple matching match_glob_pattern(pattern, path) when /\.php$/, /\.exe$/, /\.js$/ # File extension patterns path.end_with?(pattern) when /\A\// # Exact path match path == pattern else # Simple substring match path.include?(pattern) end end def match_glob_pattern(pattern, path) # Convert simple glob patterns to regex regex_pattern = pattern .gsub('*', '.*') .gsub('?', '.') .gsub('[', '\[') .gsub(']', '\]') path.match?(/\A#{regex_pattern}\z/) end def build_path_pattern_conditions(event) { "patterns" => targets, "match_type" => "path_pattern" } end def build_path_pattern_metadata(event) base_metadata = { generated_by_policy: id, policy_name: name, policy_type: policy_type, matched_path: event.request_path, generated_from: "event" } base_metadata.merge!(additional_data || {}) end end