# frozen_string_literal: true # Rule - WAF rule management with NetworkRange integration # # Rules define actions to take for matching traffic conditions. # Network rules are associated with NetworkRange objects for rich context. class Rule < ApplicationRecord # Rule enums (prefix needed to avoid rate_limit collision) # Canonical WAF action order - aligned with Agent and Event models enum :waf_action, { deny: 0, allow: 1, redirect: 2, challenge: 3, log: 4, add_header: 5 }, prefix: :action enum :waf_rule_type, { network: 0, rate_limit: 1, path_pattern: 2 }, prefix: :type 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 has_many :events, dependent: :nullify # Validations validates :waf_rule_type, presence: true, inclusion: { in: waf_rule_types.keys } validates :waf_action, presence: true, inclusion: { in: waf_actions.keys } validates :conditions, presence: true, unless: :network_rule? validates :enabled, inclusion: { in: [true, false] } validates :source, inclusion: { in: SOURCES } # Custom validations validate :validate_conditions_by_type validate :validate_metadata_by_action validate :network_range_required_for_network_rules validate :validate_network_consistency, if: :network_rule? validate :no_supernet_rule_exists, if: :should_check_supernet? # 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(waf_rule_type: type) } scope :network_rules, -> { where(waf_rule_type: :network) } scope :rate_limit_rules, -> { where(waf_rule_type: :rate_limit) } scope :path_pattern_rules, -> { where(waf_rule_type: :path_pattern) } 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) } # Action scopes (manual to avoid enum collision with rate_limit) scope :deny, -> { where(waf_action: :deny) } scope :allow, -> { where(waf_action: :allow) } # Sync queries scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) } scope :sync_order, -> { order(:updated_at, :id) } # Callbacks before_validation :set_defaults before_validation :parse_json_fields before_save :calculate_priority_for_network_rules after_create :expire_redundant_child_rules, if: :should_expire_child_rules? # Rule type checks def network_rule? type_network? end def rate_limit_rule? type_rate_limit? end def path_pattern_rule? type_path_pattern? end # Network-specific methods def network_range? network_range.present? end def cidr network_rule? ? network_range&.cidr : conditions&.dig("cidr") end def prefix_length network_rule? ? network_range&.prefix_length : cidr&.split("/")&.last&.to_i end def network_intelligence return {} unless network_rule? && network_range network_range.inherited_intelligence end def network_address network_rule? ? network_range&.network_address : nil end # Surgical block methods def surgical_block? source == "manual:surgical_block" end def surgical_exception? 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 def add_header_action? action_add_header? end # Redirect/challenge convenience methods def redirect_url metadata_hash['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 header_name metadata&.dig('header_name') end def header_value metadata&.dig('header_value') end # Tag-related methods def tags metadata_hash['tags'] || [] end def tags=(new_tags) self.metadata = metadata_hash.merge('tags' => Array(new_tags)) end def add_tag(tag) current_tags = tags return if current_tags.include?(tag.to_s) self.metadata = metadata_hash.merge('tags' => (current_tags + [tag.to_s])) end def remove_tag(tag) current_tags = tags return unless current_tags.include?(tag.to_s) self.metadata = metadata_hash.merge('tags' => (current_tags - [tag.to_s])) end def has_tag?(tag) tags.include?(tag.to_s) end # Headers for add_header action or metadata-based header injection def headers metadata_hash['headers'] || {} end def headers=(new_headers) self.metadata = metadata_hash.merge('headers' => new_headers.to_h) end def related_surgical_rules if surgical_block? # Find the corresponding exception rule surgical_exceptions.where( conditions: { cidr: network_address ? "#{network_address}/32" : nil } ) elsif surgical_exception? # Find the parent block rule surgical_blocks.joins(:network_range).where( network_ranges: { network: parent_cidr } ) else Rule.none end end # Rule lifecycle 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 disable!(reason: nil) new_metadata = metadata_hash.merge( disabled_at: Time.current.iso8601, disabled_reason: reason ) update!( enabled: false, metadata: new_metadata ) end def extend_expiry!(duration) new_expiry = Time.current + duration update!(expires_at: new_expiry) end # Agent serialization def to_agent_format format = { id: id, waf_rule_type: waf_rule_type, waf_action: waf_action, # Use the enum field directly conditions: agent_conditions, priority: agent_priority, expires_at: expires_at&.to_i, # Agents expect Unix timestamps enabled: enabled, source: source, metadata: metadata || {}, created_at: created_at.to_i, # Agents expect Unix timestamps updated_at: updated_at.to_i # Agents expect Unix timestamps } # For network rules, resolve the network range to actual IP data if network_rule? && network_range begin ip_range = IPAddr.new(network_range.cidr) range = ip_range.to_range if ip_range.ipv4? format[:network_start] = range.first.to_i format[:network_end] = range.last.to_i else # IPv6 - use binary representation format[:network_start] = range.first.hton format[:network_end] = range.last.hton end format[:network_prefix] = network_range.prefix_length format[:network_intelligence] = network_intelligence rescue => e Rails.logger.error "Failed to resolve network range #{network_range.cidr}: #{e.message}" # Fallback to CIDR format format[:conditions] = { cidr: network_range.cidr } end end # For path_pattern rules, include segment IDs and match type if path_pattern_rule? format[:conditions] = { segment_ids: path_segment_ids, match_type: path_match_type } end format end # Path pattern rule helper methods def path_segment_ids conditions&.dig("segment_ids") || [] end def path_match_type conditions&.dig("match_type") end def path_segments_text return [] if path_segment_ids.empty? PathSegment.where(id: path_segment_ids).order(:id).pluck(:segment) end def path_pattern_display return nil unless path_pattern_rule? "/" + path_segments_text.join("/") end # Class methods for rule creation def self.create_network_rule(cidr, action: 'deny', user: nil, **options) network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created') create!( waf_rule_type: 'network', waf_action: action, network_range: network_range, user: user, **options ) end def self.create_path_pattern_rule(pattern:, match_type:, action: 'deny', user: nil, **options) # Parse pattern string to segments (case-insensitive) segments = pattern.split('/').reject(&:blank?).map(&:downcase) if segments.empty? raise ArgumentError, "Pattern must contain at least one path segment" end unless %w[exact prefix suffix contains].include?(match_type) raise ArgumentError, "Match type must be one of: exact, prefix, suffix, contains" end # Find or create PathSegment entries segment_ids = segments.map do |seg| PathSegment.find_or_create_segment(seg).id end create!( waf_rule_type: 'path_pattern', waf_action: action, conditions: { segment_ids: segment_ids, match_type: match_type, original_pattern: pattern }, metadata: { segments: segments, pattern_display: "/" + segments.join("/") }, user: user, source: options[:source] || 'manual', priority: options[:priority] || 50, **options.except(:source, :priority) ) end def self.create_surgical_block(ip_address, parent_cidr, user: nil, reason: nil, **options) # Create block rule for parent range network_range = NetworkRange.find_or_create_by_cidr(parent_cidr, user: user, source: 'user_created') block_rule = create!( waf_rule_type: 'network', waf_action: 'deny', network_range: network_range, source: 'manual:surgical_block', user: user, metadata: { reason: reason, surgical_block: true, original_ip: ip_address, **options[:metadata] }, **options.except(:metadata) ) # Create exception rule for specific IP ip_network_range = NetworkRange.find_or_create_by_cidr("#{ip_address}/#{ip_address.include?(':') ? '128' : '32'}", user: user, source: 'user_created') exception_rule = create!( waf_rule_type: 'network', waf_action: 'allow', network_range: ip_network_range, source: 'manual:surgical_exception', user: user, priority: ip_network_range.prefix_length, # Higher priority = more specific metadata: { reason: "Exception for #{ip_address} in surgical block of #{parent_cidr}", surgical_exception: true, parent_rule_id: block_rule.id, **options[:metadata] }, **options.except(:metadata) ) [block_rule, exception_rule] end def self.create_rate_limit_rule(cidr, limit:, window:, user: nil, action: 'deny', **options) network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created') create!( waf_rule_type: 'rate_limit', waf_action: action, # Action to take when rate limit exceeded (deny, redirect, challenge, log) network_range: network_range, conditions: { cidr: cidr, scope: 'ip' }, metadata: { limit: limit, window: window, **options[:metadata] }, user: user, **options.except(:metadata) ) end # Sync and versioning def self.latest_version max_time = maximum(:updated_at) max_time ? max_time.to_i : Time.current.to_i end def self.active_for_agent active.sync_order.map(&:to_agent_format) end # Analytics methods def matching_events(limit: 100) return Event.none unless network_rule? && network_range # This would need efficient IP range queries # For now, simple IP match Event.where("ip_address <<= ?", network_range.cidr) .recent .limit(limit) end def effectiveness_stats return {} unless network_rule? events = matching_events { total_events: events.count, blocked_events: events.blocked.count, allowed_events: events.allowed.count, block_rate: events.count > 0 ? (events.blocked.count.to_f / events.count * 100).round(2) : 0 } end # Helper method to safely access metadata as hash def metadata_hash case metadata when Hash metadata when String metadata.present? ? (JSON.parse(metadata) rescue {}) : {} else {} end end private def set_defaults self.enabled = true if enabled.nil? self.conditions ||= {} self.metadata ||= {} self.source ||= "manual" # Set system user for auto-generated rules if no user is set if source&.start_with?('auto:') || source == 'default' self.user ||= User.find_by(role: 1) # admin role end # Set default header values for add_header action if add_header_action? self.metadata['header_name'] ||= 'X-Bot-Agent' self.metadata['header_value'] ||= 'Unknown' end end def calculate_priority_for_network_rules if network_rule? && network_range self.priority = network_range.prefix_length end end def agent_conditions if network_rule? { cidr: cidr } else conditions || {} end end def agent_priority if network_rule? prefix_length || 0 else priority || 0 end end def validate_conditions_by_type case waf_rule_type when "network" # Network rules don't need conditions in DB - stored in network_range true when "rate_limit" validate_rate_limit_conditions when "path_pattern" validate_path_pattern_conditions end end def validate_rate_limit_conditions scope = conditions&.dig("scope") cidr_value = conditions&.dig("cidr") if scope.blank? errors.add(:conditions, "must include 'scope' for rate_limit rules") end unless metadata&.dig("limit").present? && metadata&.dig("window").present? errors.add(:metadata, "must include 'limit' and 'window' for rate_limit rules") end end def validate_path_pattern_conditions segment_ids = conditions&.dig("segment_ids") match_type = conditions&.dig("match_type") if segment_ids.blank? || !segment_ids.is_a?(Array) errors.add(:conditions, "must include 'segment_ids' array for path_pattern rules") end unless %w[exact prefix suffix contains].include?(match_type) errors.add(:conditions, "match_type must be one of: exact, prefix, suffix, contains") end # Validate that all segment IDs exist if segment_ids.is_a?(Array) && segment_ids.any? existing_ids = PathSegment.where(id: segment_ids).pluck(:id) missing_ids = segment_ids - existing_ids if missing_ids.any? errors.add(:conditions, "references non-existent path segment IDs: #{missing_ids.join(', ')}") end end end def validate_metadata_by_action case waf_action when "redirect" 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 "add_header" unless metadata&.dig("header_name").present? errors.add(:metadata, "must include 'header_name' for add_header action") end unless metadata&.dig("header_value").present? errors.add(:metadata, "must include 'header_value' for add_header action") end end end def network_range_required_for_network_rules if network_rule? && network_range.nil? errors.add(:network_range, "is required for network rules") end end def validate_network_consistency return unless network_rule? && network_range # For network rules, we don't use conditions - the network_range handles everything # So we can skip this validation for now true end def parent_cidr return nil unless network_range # Find a broader network range that contains this one network_range.parent_ranges.first&.cidr end def parse_json_fields # Parse conditions if it's a string if conditions.is_a?(String) && conditions.present? begin self.conditions = JSON.parse(conditions) if conditions != "{}" rescue JSON::ParserError self.conditions = {} end end # Parse metadata if it's a string if metadata.is_a?(String) && metadata.present? begin self.metadata = JSON.parse(metadata) if metadata != "{}" rescue JSON::ParserError self.metadata = {} end end # Ensure they are hashes self.conditions ||= {} self.metadata ||= {} end # Supernet/subnet redundancy checking def should_check_supernet? network_rule? && network_range.present? && new_record? end def no_supernet_rule_exists return unless network_range supernet_rule = network_range.supernet_rules.first if supernet_rule errors.add( :base, "A supernet rule already covers this network. " \ "Rule ##{supernet_rule.id} for #{supernet_rule.network_range.cidr} " \ "(action: #{supernet_rule.waf_action}) makes this rule redundant." ) end end def should_expire_child_rules? network_rule? && network_range.present? && enabled? end def expire_redundant_child_rules return unless network_range child_rules = network_range.child_rules return if child_rules.empty? expired_count = 0 child_rules.find_each do |child_rule| # Disable the child rule and mark it as redundant child_rule.update!( enabled: false, metadata: child_rule.metadata_hash.merge( disabled_at: Time.current.iso8601, disabled_reason: "Redundant - covered by supernet rule ##{id} (#{network_range.cidr})", superseded_by_rule_id: id ) ) expired_count += 1 end if expired_count > 0 Rails.logger.info "Rule ##{id}: Expired #{expired_count} redundant child rule(s) for #{network_range.cidr}" end end end