Files
baffle-hub/app/models/rule.rb

628 lines
18 KiB
Ruby

# 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
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