Accepts incoming events and correctly parses them into events. GeoLite2 integration complete"
This commit is contained in:
@@ -1,126 +1,189 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Rule < ApplicationRecord
|
||||
belongs_to :rule_set
|
||||
# Rule types for the new architecture
|
||||
RULE_TYPES = %w[network_v4 network_v6 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].freeze
|
||||
|
||||
validates :rule_type, presence: true, inclusion: { in: RuleSet::RULE_TYPES }
|
||||
validates :target, presence: true
|
||||
validates :action, presence: true, inclusion: { in: RuleSet::ACTIONS }
|
||||
validates :priority, presence: true, numericality: { greater_than: 0 }
|
||||
# Validations
|
||||
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
|
||||
validates :action, presence: true, inclusion: { in: ACTIONS }
|
||||
validates :conditions, presence: true
|
||||
validates :enabled, inclusion: { in: [true, false] }
|
||||
|
||||
# Custom validations based on rule type
|
||||
validate :validate_conditions_by_type
|
||||
validate :validate_metadata_by_action
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
scope :by_priority, -> { order(priority: :desc, created_at: :desc) }
|
||||
scope :expired, -> { where("expires_at < ?", Time.current) }
|
||||
scope :not_expired, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||
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_v4", "network_v6"]) }
|
||||
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) }
|
||||
|
||||
# Sync queries (ordered by updated_at for incremental sync)
|
||||
scope :since, ->(timestamp) { where("updated_at >= ?", timestamp - 0.5.seconds).order(:updated_at, :id) }
|
||||
scope :sync_order, -> { order(:updated_at, :id) }
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_defaults
|
||||
before_save :calculate_priority_from_cidr
|
||||
|
||||
# Check if rule is currently active
|
||||
def active?
|
||||
enabled? && (expires_at.nil? || expires_at > Time.current)
|
||||
enabled? && !expired?
|
||||
end
|
||||
|
||||
# Check if rule matches given request context
|
||||
def matches?(context)
|
||||
return false unless active?
|
||||
|
||||
case rule_type
|
||||
when 'ip'
|
||||
match_ip_rule?(context)
|
||||
when 'cidr'
|
||||
match_cidr_rule?(context)
|
||||
when 'path'
|
||||
match_path_rule?(context)
|
||||
when 'user_agent'
|
||||
match_user_agent_rule?(context)
|
||||
when 'parameter'
|
||||
match_parameter_rule?(context)
|
||||
when 'method'
|
||||
match_method_rule?(context)
|
||||
when 'country'
|
||||
match_country_rule?(context)
|
||||
else
|
||||
false
|
||||
end
|
||||
def expired?
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
def to_waf_format
|
||||
# Convert to format for agent consumption
|
||||
def to_agent_format
|
||||
{
|
||||
id: id,
|
||||
type: rule_type,
|
||||
target: target,
|
||||
rule_type: rule_type,
|
||||
action: action,
|
||||
conditions: conditions || {},
|
||||
priority: priority,
|
||||
expires_at: expires_at,
|
||||
active: active?
|
||||
expires_at: expires_at&.iso8601,
|
||||
enabled: enabled,
|
||||
source: source,
|
||||
metadata: metadata || {},
|
||||
created_at: created_at.iso8601,
|
||||
updated_at: updated_at.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
# Class method to get latest version (for sync cursor)
|
||||
def self.latest_version
|
||||
maximum(:updated_at)&.iso8601(6) || Time.current.iso8601(6)
|
||||
end
|
||||
|
||||
# Disable rule (soft delete)
|
||||
def disable!(reason: nil)
|
||||
update!(
|
||||
enabled: false,
|
||||
metadata: (metadata || {}).merge(
|
||||
disabled_at: Time.current.iso8601,
|
||||
disabled_reason: reason
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
# Enable rule
|
||||
def enable!
|
||||
update!(enabled: true)
|
||||
end
|
||||
|
||||
# Check if this is a network rule
|
||||
def network_rule?
|
||||
rule_type.in?(%w[network_v4 network_v6])
|
||||
end
|
||||
|
||||
# Get CIDR from conditions (for network rules)
|
||||
def cidr
|
||||
conditions&.dig("cidr") if network_rule?
|
||||
end
|
||||
|
||||
# Get prefix length from CIDR
|
||||
def prefix_length
|
||||
return nil unless cidr
|
||||
cidr.split("/").last.to_i
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def match_ip_rule?(context)
|
||||
return false unless context[:ip_address]
|
||||
|
||||
target == context[:ip_address]
|
||||
def set_defaults
|
||||
self.enabled = true if enabled.nil?
|
||||
self.conditions ||= {}
|
||||
self.metadata ||= {}
|
||||
self.source ||= "manual"
|
||||
end
|
||||
|
||||
def match_cidr_rule?(context)
|
||||
return false unless context[:ip_address]
|
||||
def calculate_priority_from_cidr
|
||||
# For network rules, priority is the prefix length (more specific = higher priority)
|
||||
if network_rule? && cidr.present?
|
||||
self.priority = prefix_length
|
||||
end
|
||||
end
|
||||
|
||||
def validate_conditions_by_type
|
||||
case rule_type
|
||||
when "network_v4", "network_v6"
|
||||
validate_network_conditions
|
||||
when "rate_limit"
|
||||
validate_rate_limit_conditions
|
||||
when "path_pattern"
|
||||
validate_path_pattern_conditions
|
||||
end
|
||||
end
|
||||
|
||||
def validate_network_conditions
|
||||
cidr_value = conditions&.dig("cidr")
|
||||
|
||||
if cidr_value.blank?
|
||||
errors.add(:conditions, "must include 'cidr' for network rules")
|
||||
return
|
||||
end
|
||||
|
||||
# Validate CIDR format
|
||||
begin
|
||||
range = IPAddr.new(target)
|
||||
range.include?(context[:ip_address])
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
addr = IPAddr.new(cidr_value)
|
||||
|
||||
# Check IPv4 vs IPv6 matches rule_type
|
||||
if rule_type == "network_v4" && !addr.ipv4?
|
||||
errors.add(:conditions, "cidr must be IPv4 for network_v4 rules")
|
||||
elsif rule_type == "network_v6" && !addr.ipv6?
|
||||
errors.add(:conditions, "cidr must be IPv6 for network_v6 rules")
|
||||
end
|
||||
rescue IPAddr::InvalidAddressError => e
|
||||
errors.add(:conditions, "invalid CIDR format: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
def match_path_rule?(context)
|
||||
return false unless context[:request_path]
|
||||
def validate_rate_limit_conditions
|
||||
scope = conditions&.dig("scope")
|
||||
cidr_value = conditions&.dig("cidr")
|
||||
|
||||
# Support exact match and regex patterns
|
||||
if conditions&.dig('regex') == true
|
||||
Regexp.new(target).match?(context[:request_path])
|
||||
else
|
||||
context[:request_path].start_with?(target)
|
||||
if scope.blank?
|
||||
errors.add(:conditions, "must include 'scope' for rate_limit rules")
|
||||
end
|
||||
|
||||
if cidr_value.blank?
|
||||
errors.add(:conditions, "must include 'cidr' for rate_limit rules")
|
||||
end
|
||||
|
||||
# Validate metadata has rate limit config
|
||||
unless metadata&.dig("limit").present? && metadata&.dig("window").present?
|
||||
errors.add(:metadata, "must include 'limit' and 'window' for rate_limit rules")
|
||||
end
|
||||
end
|
||||
|
||||
def match_user_agent_rule?(context)
|
||||
return false unless context[:user_agent]
|
||||
def validate_path_pattern_conditions
|
||||
patterns = conditions&.dig("patterns")
|
||||
|
||||
# Support exact match and regex patterns
|
||||
if conditions&.dig('regex') == true
|
||||
Regexp.new(target, Regexp::IGNORECASE).match?(context[:user_agent])
|
||||
else
|
||||
context[:user_agent].downcase.include?(target.downcase)
|
||||
if patterns.blank? || !patterns.is_a?(Array)
|
||||
errors.add(:conditions, "must include 'patterns' array for path_pattern rules")
|
||||
end
|
||||
end
|
||||
|
||||
def match_parameter_rule?(context)
|
||||
return false unless context[:query_params]
|
||||
|
||||
param_name = conditions&.dig('parameter_name') || target
|
||||
param_value = context[:query_params][param_name]
|
||||
|
||||
return false unless param_value
|
||||
|
||||
# Check if parameter value matches pattern
|
||||
if conditions&.dig('regex') == true
|
||||
Regexp.new(target, Regexp::IGNORECASE).match?(param_value.to_s)
|
||||
else
|
||||
param_value.to_s.downcase.include?(target.downcase)
|
||||
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 match_method_rule?(context)
|
||||
return false unless context[:request_method]
|
||||
|
||||
target.upcase == context[:request_method].upcase
|
||||
end
|
||||
|
||||
def match_country_rule?(context)
|
||||
return false unless context[:country_code]
|
||||
|
||||
target.upcase == context[:country_code].upcase
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user