This commit is contained in:
Dan Milne
2025-11-14 16:35:49 +11:00
parent df94ac9720
commit 6433f6c5bb
30 changed files with 833 additions and 245 deletions

View File

@@ -126,6 +126,37 @@ class Event < ApplicationRecord
where("json_array_length(request_segment_ids) > ?", depth)
}
# Analytics: Get response time percentiles over different time windows
def self.response_time_percentiles(windows: { hour: 1.hour, day: 1.day, week: 1.week })
results = {}
windows.each do |label, duration|
scope = where('timestamp >= ?', duration.ago)
stats = scope.pick(
Arel.sql(<<~SQL.squish)
percentile_cont(0.5) WITHIN GROUP (ORDER BY response_time_ms) as p50,
percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms) as p95,
percentile_cont(0.99) WITHIN GROUP (ORDER BY response_time_ms) as p99,
COUNT(*) as count
SQL
)
results[label] = if stats
{
p50: stats[0]&.round(2),
p95: stats[1]&.round(2),
p99: stats[2]&.round(2),
count: stats[3]
}
else
{ p50: nil, p95: nil, p99: nil, count: 0 }
end
end
results
end
# Helper methods
def path_depth
request_segment_ids&.length || 0
@@ -455,7 +486,7 @@ class Event < ApplicationRecord
end
def active_blocking_rules
matching_rules.where(action: 'deny')
matching_rules.where(waf_action: :deny)
end
def has_blocking_rules?
@@ -502,6 +533,121 @@ class Event < ApplicationRecord
Rails.logger.error "Failed to normalize event #{id}: #{e.message}"
end
def should_populate_network_intelligence?
# Only populate if IP is present and country is not yet set
# Also repopulate if IP address changed (rare case)
ip_address.present? && (country.blank? || ip_address_changed?)
end
def populate_network_intelligence
return unless ip_address.present?
# Convert IPAddr to string for PostgreSQL query
ip_string = ip_address.to_s
# CRITICAL: Always find_or_create /24 tracking network for public IPs
# This /24 serves as:
# 1. The tracking unit for IPAPI deduplication (stores ipapi_queried_at)
# 2. The reference point for preventing duplicate API calls
# 3. The fallback network if no more specific GeoIP data exists
tracking_network = find_or_create_tracking_network(ip_string)
# Find most specific network range with actual GeoIP data
# This might be more specific (e.g., /25) or broader (e.g., /22) than the /24
data_range = NetworkRange.where("network >>= ?", ip_string)
.where.not(country: nil) # Must have actual data
.order(Arel.sql("masklen(network) DESC"))
.first
# Use the most specific range with data, or fall back to tracking network
range = data_range || tracking_network
if range
# Populate all network intelligence fields from the range
self.country = range.country
self.company = range.company
self.asn = range.asn
self.asn_org = range.asn_org
self.is_datacenter = range.is_datacenter || false
self.is_vpn = range.is_vpn || false
self.is_proxy = range.is_proxy || false
else
# No range at all (shouldn't happen, but defensive)
self.is_datacenter = false
self.is_vpn = false
self.is_proxy = false
end
# ALWAYS set network_range_id to the tracking /24
# This is what FetchIpapiDataJob uses to check ipapi_queried_at
# and prevent duplicate API calls
self.network_range_id = tracking_network&.id
rescue => e
Rails.logger.error "Failed to populate network intelligence for event #{id}: #{e.message}"
# Set defaults on error to prevent null values
self.is_datacenter = false
self.is_vpn = false
self.is_proxy = false
end
# Find or create the /24 tracking network for this IP
# This is the fundamental unit for IPAPI deduplication
def find_or_create_tracking_network(ip_string)
return nil if private_or_reserved_ip?(ip_string)
ip_addr = IPAddr.new(ip_string)
# Calculate /24 for IPv4, /64 for IPv6
if ip_addr.ipv4?
prefix_length = 24
mask = (2**32 - 1) ^ ((2**(32 - prefix_length)) - 1)
network_int = ip_addr.to_i & mask
network_base = IPAddr.new(network_int, Socket::AF_INET)
network_cidr = "#{network_base}/#{prefix_length}" # e.g., "1.2.3.0/24"
else
prefix_length = 64
mask = (2**128 - 1) ^ ((2**(128 - prefix_length)) - 1)
network_int = ip_addr.to_i & mask
network_base = IPAddr.new(network_int, Socket::AF_INET6)
network_cidr = "#{network_base}/#{prefix_length}" # e.g., "2001:db8::/64"
end
# Find or create the tracking network
NetworkRange.find_or_create_by!(network: network_cidr) do |nr|
nr.source = 'auto_generated'
nr.creation_reason = 'tracking unit for IPAPI deduplication'
nr.is_datacenter = NetworkRangeGenerator.datacenter_ip?(ip_addr) rescue false
nr.is_vpn = false
nr.is_proxy = false
end
rescue => e
Rails.logger.error "Failed to create tracking network for IP #{ip_string}: #{e.message}"
nil
end
# Check if IP is private or reserved (should not create network ranges)
def private_or_reserved_ip?(ip_string = nil)
ip_str = ip_string || ip_address.to_s
ip = IPAddr.new(ip_str)
# Private and reserved ranges
[
IPAddr.new('10.0.0.0/8'),
IPAddr.new('172.16.0.0/12'),
IPAddr.new('192.168.0.0/16'),
IPAddr.new('127.0.0.0/8'),
IPAddr.new('169.254.0.0/16'),
IPAddr.new('224.0.0.0/4'),
IPAddr.new('240.0.0.0/4'),
IPAddr.new('::1/128'),
IPAddr.new('fc00::/7'),
IPAddr.new('fe80::/10'),
IPAddr.new('ff00::/8')
].any? { |range| range.include?(ip) }
rescue IPAddr::InvalidAddressError
true # Treat invalid IPs as "reserved"
end
def extract_fields_from_payload
return unless payload.present?

View File

@@ -392,13 +392,52 @@ class NetworkRange < ApplicationRecord
end
def blocking_rules
rules.where(action: 'deny', enabled: true)
rules.where(waf_action: :deny, enabled: true)
end
def active_rules
rules.enabled.where("expires_at IS NULL OR expires_at > ?", Time.current)
end
# Find all network ranges that are contained by this network and have enabled rules
# Used when creating a supernet rule to identify redundant child rules
def child_network_ranges_with_rules
NetworkRange
.where("network << ?::inet", network.to_s) # network is strictly contained by this network
.joins(:rules)
.where(rules: { enabled: true })
.distinct
end
# Find all enabled rules on child network ranges (more specific networks)
# Used after creating a rule to expire redundant child rules
def child_rules
Rule
.joins(:network_range)
.where("network_ranges.network << ?::inet", cidr)
.where(enabled: true)
end
# Find all network ranges that contain this network and have enabled rules
# Used to check if creating a rule would be redundant
def parent_network_ranges_with_rules
NetworkRange
.where("?::inet << network", cidr) # this network is strictly contained by parent
.joins(:rules)
.where(rules: { enabled: true })
.distinct
end
# Find all enabled rules on parent network ranges (less specific networks)
# Used before creating a rule to check if it would be redundant
def supernet_rules
Rule
.joins(:network_range)
.where("?::inet << network_ranges.network", cidr)
.where(enabled: true)
.order("masklen(network_ranges.network) DESC") # Most specific supernet first
end
# Check if this network range needs WAF policy evaluation
# Returns true if:
# - Never been evaluated, OR

View File

@@ -5,9 +5,9 @@
# 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
# Rule enums (prefix needed to avoid rate_limit collision)
enum :waf_action, { allow: 0, deny: 1, rate_limit: 2, redirect: 3, log: 4, challenge: 5 }, prefix: :action
enum :waf_rule_type, { network: 0, rate_limit: 1, path_pattern: 2 }, prefix: :type
# Legacy string constants for backward compatibility
RULE_TYPES = %w[network rate_limit path_pattern].freeze
@@ -20,25 +20,6 @@ class Rule < ApplicationRecord
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 }
@@ -59,6 +40,7 @@ class Rule < ApplicationRecord
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) }
@@ -66,20 +48,18 @@ class Rule < ApplicationRecord
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 :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) }
# 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") }
# 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) }
@@ -89,19 +69,19 @@ class Rule < ApplicationRecord
before_validation :set_defaults
before_validation :parse_json_fields
before_save :calculate_priority_for_network_rules
before_save :sync_legacy_columns
after_create :expire_redundant_child_rules, if: :should_expire_child_rules?
# Rule type checks
def network_rule?
waf_rule_type_network?
type_network?
end
def rate_limit_rule?
waf_rule_type_rate_limit?
type_rate_limit?
end
def path_pattern_rule?
waf_rule_type_path_pattern?
type_path_pattern?
end
# Network-specific methods
@@ -143,11 +123,11 @@ class Rule < ApplicationRecord
# Action-specific methods
def redirect_action?
waf_action_redirect?
action_redirect?
end
def challenge_action?
waf_action_challenge?
action_challenge?
end
# Redirect/challenge convenience methods
@@ -509,14 +489,52 @@ class Rule < ApplicationRecord
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
# 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
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

View File

@@ -18,13 +18,13 @@ class WafPolicy < ApplicationRecord
# Validations
validates :name, presence: true, uniqueness: true
validates :policy_type, presence: true, inclusion: { in: POLICY_TYPES }
validates :action, presence: true, inclusion: { in: ACTIONS }
validates :policy_action, presence: true, inclusion: { in: ACTIONS }
validates :targets, presence: true
validate :targets_must_be_array
validates :user, presence: true
validate :validate_targets_by_type
validate :validate_redirect_configuration, if: :redirect_action?
validate :validate_challenge_configuration, if: :challenge_action?
validate :validate_redirect_configuration, if: :redirect_policy_action?
validate :validate_challenge_configuration, if: :challenge_policy_action?
# Scopes
scope :enabled, -> { where(enabled: true) }
@@ -59,19 +59,36 @@ validate :targets_must_be_array
# Action methods
def allow_action?
action == 'allow'
policy_action == 'allow'
end
def deny_action?
action == 'deny'
policy_action == 'deny'
end
def redirect_action?
action == 'redirect'
policy_action == 'redirect'
end
def challenge_action?
action == 'challenge'
policy_action == 'challenge'
end
# Policy action methods (to avoid confusion with Rails' action methods)
def allow_policy_action?
policy_action == 'allow'
end
def deny_policy_action?
policy_action == 'deny'
end
def redirect_policy_action?
policy_action == 'redirect'
end
def challenge_policy_action?
policy_action == 'challenge'
end
# Lifecycle methods
@@ -118,7 +135,7 @@ validate :targets_must_be_array
rule = Rule.create!(
rule_type: 'network',
action: action,
action: policy_action,
network_range: network_range,
waf_policy: self,
user: user,
@@ -148,45 +165,45 @@ validate :targets_must_be_array
end
# Class methods for creating common policies
def self.create_country_policy(countries, action: 'deny', user:, **options)
def self.create_country_policy(countries, policy_action: 'deny', user:, **options)
create!(
name: "#{action.capitalize} #{countries.join(', ')}",
name: "#{policy_action.capitalize} #{countries.join(', ')}",
policy_type: 'country',
targets: Array(countries),
action: action,
policy_action: policy_action,
user: user,
**options
)
end
def self.create_asn_policy(asns, action: 'deny', user:, **options)
def self.create_asn_policy(asns, policy_action: 'deny', user:, **options)
create!(
name: "#{action.capitalize} ASNs #{asns.join(', ')}",
name: "#{policy_action.capitalize} ASNs #{asns.join(', ')}",
policy_type: 'asn',
targets: Array(asns).map(&:to_i),
action: action,
policy_action: policy_action,
user: user,
**options
)
end
def self.create_company_policy(companies, action: 'deny', user:, **options)
def self.create_company_policy(companies, policy_action: 'deny', user:, **options)
create!(
name: "#{action.capitalize} #{companies.join(', ')}",
name: "#{policy_action.capitalize} #{companies.join(', ')}",
policy_type: 'company',
targets: Array(companies),
action: action,
policy_action: policy_action,
user: user,
**options
)
end
def self.create_network_type_policy(types, action: 'deny', user:, **options)
def self.create_network_type_policy(types, policy_action: 'deny', user:, **options)
create!(
name: "#{action.capitalize} #{types.join(', ')}",
name: "#{policy_action.capitalize} #{types.join(', ')}",
policy_type: 'network_type',
targets: Array(types),
action: action,
policy_action: policy_action,
user: user,
**options
)
@@ -226,7 +243,7 @@ validate :targets_must_be_array
active_rules: active_rules_count,
rules_last_7_days: recent_rules.count,
policy_type: policy_type,
action: action,
policy_action: policy_action,
targets_count: targets&.length || 0
}
end