# 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 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 # Associations belongs_to :user belongs_to :network_range, optional: true # Validations validates :rule_type, presence: true, inclusion: { in: RULE_TYPES } validates :action, presence: true, inclusion: { in: ACTIONS } 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? # 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(rule_type: type) } scope :network_rules, -> { where(rule_type: "network") } scope :rate_limit_rules, -> { where(rule_type: "rate_limit") } scope :path_pattern_rules, -> { where(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") } # 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 # Rule type checks def network_rule? rule_type == "network" end def rate_limit_rule? rule_type == "rate_limit" end def path_pattern_rule? rule_type == "path_pattern" end # Network-specific methods 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 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) update!( enabled: false, metadata: metadata.merge( disabled_at: Time.current.iso8601, disabled_reason: reason ) ) 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, rule_type: rule_type, waf_action: action, # Agents expect 'waf_action' field 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 format 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!( rule_type: 'network', action: action, network_range: network_range, user: user, **options ) 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!( rule_type: 'network', 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!( rule_type: 'network', 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, **options) network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created') create!( rule_type: 'rate_limit', action: 'rate_limit', 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.network_address) .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 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 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 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 patterns = conditions&.dig("patterns") if patterns.blank? || !patterns.is_a?(Array) errors.add(:conditions, "must include 'patterns' array for path_pattern rules") end end def validate_metadata_by_action case action when "redirect" unless metadata&.dig("redirect_url").present? errors.add(:metadata, "must include 'redirect_url' for redirect action") 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") 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 end