Accepts incoming events and correctly parses them into events. GeoLite2 integration complete"

This commit is contained in:
Dan Milne
2025-11-04 00:11:10 +11:00
parent 0cbd462e7c
commit 5ff166613e
49 changed files with 4489 additions and 322 deletions

View File

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