522 lines
15 KiB
Ruby
522 lines
15 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
|
|
enum :waf_action, { allow: 0, deny: 1, rate_limit: 2, redirect: 3, log: 4, challenge: 5 }, scopes: false, prefix: true
|
|
enum :waf_rule_type, { network: 0, rate_limit: 1, path_pattern: 2 }, scopes: false, prefix: true
|
|
|
|
# Legacy string constants for backward compatibility
|
|
RULE_TYPES = %w[network rate_limit path_pattern].freeze
|
|
ACTIONS = %w[allow deny rate_limit redirect log challenge].freeze
|
|
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
|
|
|
|
# Backward compatibility accessors for transition period
|
|
def action
|
|
waf_action
|
|
end
|
|
|
|
def action=(value)
|
|
self.waf_action = value
|
|
self[:action] = value # Also set the legacy column
|
|
end
|
|
|
|
def rule_type
|
|
waf_rule_type
|
|
end
|
|
|
|
def rule_type=(value)
|
|
self.waf_rule_type = value
|
|
self[:rule_type] = value # Also set the legacy column
|
|
end
|
|
|
|
# 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 }
|
|
|
|
# Legacy enum definitions (disabled to prevent conflicts)
|
|
# enum :action, { allow: "allow", deny: "deny", rate_limit: "rate_limit", redirect: "redirect", log: "log", challenge: "challenge" }, scopes: false
|
|
# enum :rule_type, { network: "network", rate_limit: "rate_limit", path_pattern: "path_pattern" }, scopes: false
|
|
|
|
# Legacy validations for backward compatibility during transition
|
|
# validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }, allow_nil: true
|
|
# validates :action, presence: true, inclusion: { in: ACTIONS }, allow_nil: true
|
|
|
|
# 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(waf_rule_type: type) }
|
|
scope :network_rules, -> { network }
|
|
scope :rate_limit_rules, -> { rate_limit }
|
|
scope :path_pattern_rules, -> { 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) }
|
|
|
|
# Legacy scopes for backward compatibility
|
|
scope :by_type_legacy, ->(type) { where(rule_type: type) }
|
|
scope :network_rules_legacy, -> { where(rule_type: "network") }
|
|
scope :rate_limit_rules_legacy, -> { where(rule_type: "rate_limit") }
|
|
scope :path_pattern_rules_legacy, -> { where(rule_type: "path_pattern") }
|
|
|
|
# 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
|
|
before_save :sync_legacy_columns
|
|
|
|
# Rule type checks
|
|
def network_rule?
|
|
waf_rule_type_network?
|
|
end
|
|
|
|
def rate_limit_rule?
|
|
waf_rule_type_rate_limit?
|
|
end
|
|
|
|
def path_pattern_rule?
|
|
waf_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?
|
|
waf_action_redirect?
|
|
end
|
|
|
|
def challenge_action?
|
|
waf_action_challenge?
|
|
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 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
|
|
|
|
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!(
|
|
waf_rule_type: 'network',
|
|
waf_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!(
|
|
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, **options)
|
|
network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created')
|
|
|
|
create!(
|
|
waf_rule_type: 'rate_limit',
|
|
waf_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.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
|
|
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
|
|
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 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 "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
|
|
|
|
def sync_legacy_columns
|
|
# Sync enum values to legacy string columns for backward compatibility
|
|
if waf_action.present?
|
|
self[:action] = waf_action
|
|
end
|
|
if waf_rule_type.present?
|
|
self[:rule_type] = waf_rule_type
|
|
end
|
|
end
|
|
|
|
end |