Add WafPolicies

This commit is contained in:
Dan Milne
2025-11-10 14:10:37 +11:00
parent af7413c899
commit 772fae7e8b
22 changed files with 1784 additions and 147 deletions

View File

@@ -32,10 +32,36 @@ class Event < ApplicationRecord
scope :by_ip, ->(ip) { where(ip_address: ip) }
scope :by_user_agent, ->(user_agent) { where(user_agent: user_agent) }
scope :by_waf_action, ->(waf_action) { where(waf_action: waf_action) }
scope :blocked, -> { where(waf_action: ['block', 'deny']) }
scope :allowed, -> { where(waf_action: ['allow', 'pass']) }
scope :blocked, -> { where(waf_action: :deny) }
scope :allowed, -> { where(waf_action: :allow) }
scope :rate_limited, -> { where(waf_action: 'rate_limit') }
# Network-based filtering scopes
scope :by_company, ->(company) {
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.where("network_ranges.company ILIKE ?", "%#{company}%")
}
scope :by_network_type, ->(type) {
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.case(type)
.when("datacenter") { where("network_ranges.is_datacenter = ?", true) }
.when("vpn") { where("network_ranges.is_vpn = ?", true) }
.when("proxy") { where("network_ranges.is_proxy = ?", true) }
.when("standard") { where("network_ranges.is_datacenter = ? AND network_ranges.is_vpn = ? AND network_ranges.is_proxy = ?", false, false, false) }
.else { none }
}
scope :by_asn, ->(asn) {
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.where("network_ranges.asn = ?", asn.to_i)
}
scope :by_network_cidr, ->(cidr) {
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.where("network_ranges.network = ?", cidr)
}
# Path prefix matching using range queries (uses B-tree index efficiently)
scope :with_path_prefix, ->(prefix_segment_ids) {
return none if prefix_segment_ids.blank?
@@ -112,10 +138,7 @@ class Event < ApplicationRecord
server_name: normalized_payload["server_name"],
environment: normalized_payload["environment"],
# Geographic data
country_code: normalized_payload.dig("geo", "country_code"),
city: normalized_payload.dig("geo", "city"),
# WAF agent info
agent_version: normalized_payload.dig("agent", "version"),
agent_name: normalized_payload.dig("agent", "name")
@@ -269,7 +292,7 @@ class Event < ApplicationRecord
def matching_network_ranges
return [] unless ip_address.present?
NetworkRange.contains_ip(ip_address).map do |range|
NetworkRange.contains_ip(ip_address.to_s).map do |range|
{
range: range,
cidr: range.cidr,
@@ -360,86 +383,34 @@ class Event < ApplicationRecord
active_blocking_rules.exists?
end
# GeoIP enrichment methods (now uses network range data when available)
def enrich_geo_location!
return if ip_address.blank?
return if country_code.present? # Already has geo data
# First try to get from network range
network_info = network_intelligence
if network_info[:country].present?
update!(country_code: network_info[:country])
return
end
# Fallback to direct lookup
country = GeoIpService.lookup_country(ip_address)
update!(country_code: country) if country.present?
rescue => e
Rails.logger.error "Failed to enrich geo location for event #{id}: #{e.message}"
end
# Class method to enrich multiple events
def self.enrich_geo_location_batch(events = nil)
events ||= where(country_code: [nil, '']).where.not(ip_address: [nil, ''])
updated_count = 0
events.find_each do |event|
next if event.country_code.present?
# Try network range first
network_info = event.network_intelligence
if network_info[:country].present?
event.update!(country_code: network_info[:country])
updated_count += 1
next
end
# Fallback to direct lookup
country = GeoIpService.lookup_country(event.ip_address)
if country.present?
event.update!(country_code: country)
updated_count += 1
end
end
updated_count
end
# Lookup country code for this event's IP
def lookup_country
return country_code if country_code.present?
return nil if ip_address.blank?
# First try network range
network_info = network_intelligence
return network_info[:country] if network_info[:country].present?
# Fallback to direct lookup
GeoIpService.lookup_country(ip_address)
rescue => e
Rails.logger.error "GeoIP lookup failed for #{ip_address}: #{e.message}"
nil
end
# Check if event has valid geo location data
def has_geo_data?
country_code.present? || city.present? || network_intelligence[:country].present?
end
# Get full geo location details
# Get full geo location details from network range
def geo_location
network_info = network_intelligence
{
country_code: country_code || network_info[:country],
city: city,
country_code: network_info[:country],
ip_address: ip_address,
has_data: has_geo_data?,
has_data: network_info[:country].present?,
network_intelligence: network_info
}
end
# Check if event has valid geo location data via network range
def has_geo_data?
network_intelligence[:country].present?
end
# Lookup country code for this event's IP via network range
def lookup_country
return nil if ip_address.blank?
network_info = network_intelligence
network_info[:country]
rescue => e
Rails.logger.error "Network lookup failed for #{ip_address}: #{e.message}"
nil
end
private
def should_normalize?
@@ -483,11 +454,7 @@ class Event < ApplicationRecord
self.server_name = payload["server_name"]
self.environment = payload["environment"]
# Extract geographic data
geo_data = payload.dig("geo") || {}
self.country_code = geo_data["country_code"]
self.city = geo_data["city"]
# Extract agent info
agent_data = payload.dig("agent") || {}
self.agent_version = agent_data["version"]

View File

@@ -73,6 +73,11 @@ class NetworkRange < ApplicationRecord
addr.include?(':') ? 6 : 4
end
def virtual?
# Virtual networks are unsaved instances (not persisted to database)
!persisted?
end
def ipv4?
family == 4
end

View File

@@ -7,12 +7,13 @@
class Rule < ApplicationRecord
# Rule types and actions
RULE_TYPES = %w[network 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 manual:surgical_block manual:surgical_exception].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
# Validations
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
@@ -39,6 +40,8 @@ class Rule < ApplicationRecord
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) }
# Sync queries
scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) }
@@ -94,6 +97,37 @@ class Rule < ApplicationRecord
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
# Redirect/challenge convenience methods
def redirect_url
metadata&.dig('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
@@ -365,6 +399,12 @@ class Rule < ApplicationRecord
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")

399
app/models/waf_policy.rb Normal file
View File

@@ -0,0 +1,399 @@
# frozen_string_literal: true
# WafPolicy - High-level firewall policies that generate specific Rules
#
# WafPolicies contain strategic decisions like "Block Brazil" that automatically
# generate specific Rules when matching network ranges are discovered.
class WafPolicy < ApplicationRecord
# Policy types - different categories of blocking rules
POLICY_TYPES = %w[country asn company network_type].freeze
# Actions - what to do when traffic matches this policy
ACTIONS = %w[allow deny redirect challenge].freeze
# Associations
belongs_to :user
has_many :generated_rules, class_name: 'Rule', dependent: :destroy
# Validations
validates :name, presence: true, uniqueness: true
validates :policy_type, presence: true, inclusion: { in: POLICY_TYPES }
validates :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?
# 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(policy_type: type) }
scope :country, -> { by_type('country') }
scope :asn, -> { by_type('asn') }
scope :company, -> { by_type('company') }
scope :network_type, -> { by_type('network_type') }
# Callbacks
before_validation :set_defaults
# Policy type methods
def country_policy?
policy_type == 'country'
end
def asn_policy?
policy_type == 'asn'
end
def company_policy?
policy_type == 'company'
end
def network_type_policy?
policy_type == 'network_type'
end
# Action methods
def allow_action?
action == 'allow'
end
def deny_action?
action == 'deny'
end
def redirect_action?
action == 'redirect'
end
def challenge_action?
action == 'challenge'
end
# Lifecycle methods
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 expire!
update!(expires_at: Time.current)
end
# Network range matching methods
def matches_network_range?(network_range)
return false unless active?
case policy_type
when 'country'
matches_country?(network_range)
when 'asn'
matches_asn?(network_range)
when 'company'
matches_company?(network_range)
when 'network_type'
matches_network_type?(network_range)
else
false
end
end
def create_rule_for_network_range(network_range)
return nil unless matches_network_range?(network_range)
rule = Rule.create!(
rule_type: 'network',
action: action,
network_range: network_range,
waf_policy: self,
user: user,
source: "policy:#{name}",
metadata: build_rule_metadata(network_range),
priority: network_range.prefix_length
)
# Handle redirect/challenge specific data
if redirect_action? && additional_data['redirect_url']
rule.update!(
metadata: rule.metadata.merge(
redirect_url: additional_data['redirect_url'],
redirect_status: additional_data['redirect_status'] || 302
)
)
elsif challenge_action?
rule.update!(
metadata: rule.metadata.merge(
challenge_type: additional_data['challenge_type'] || 'captcha',
challenge_message: additional_data['challenge_message']
)
)
end
rule
end
# Class methods for creating common policies
def self.create_country_policy(countries, action: 'deny', user:, **options)
create!(
name: "#{action.capitalize} #{countries.join(', ')}",
policy_type: 'country',
targets: Array(countries),
action: action,
user: user,
**options
)
end
def self.create_asn_policy(asns, action: 'deny', user:, **options)
create!(
name: "#{action.capitalize} ASNs #{asns.join(', ')}",
policy_type: 'asn',
targets: Array(asns).map(&:to_i),
action: action,
user: user,
**options
)
end
def self.create_company_policy(companies, action: 'deny', user:, **options)
create!(
name: "#{action.capitalize} #{companies.join(', ')}",
policy_type: 'company',
targets: Array(companies),
action: action,
user: user,
**options
)
end
def self.create_network_type_policy(types, action: 'deny', user:, **options)
create!(
name: "#{action.capitalize} #{types.join(', ')}",
policy_type: 'network_type',
targets: Array(types),
action: action,
user: user,
**options
)
end
# Redirect/challenge specific methods
def redirect_url
additional_data&.dig('redirect_url')
end
def redirect_status
additional_data&.dig('redirect_status') || 302
end
def challenge_type
additional_data&.dig('challenge_type') || 'captcha'
end
def challenge_message
additional_data&.dig('challenge_message')
end
# Statistics and analytics
def generated_rules_count
generated_rules.count
end
def active_rules_count
generated_rules.active.count
end
def effectiveness_stats
recent_rules = generated_rules.where('created_at > ?', 7.days.ago)
{
total_rules_generated: generated_rules_count,
active_rules: active_rules_count,
rules_last_7_days: recent_rules.count,
policy_type: policy_type,
action: action,
targets_count: targets&.length || 0
}
end
# String representations
def to_s
name
end
def to_param
name.parameterize
end
private
def set_defaults
self.targets ||= []
self.additional_data ||= {}
self.enabled = true if enabled.nil?
end
def targets_must_be_array
unless targets.is_a?(Array)
errors.add(:targets, "must be an array")
end
end
def validate_targets_by_type
return if targets.blank?
case policy_type
when 'country'
validate_country_targets
when 'asn'
validate_asn_targets
when 'company'
validate_company_targets
when 'network_type'
validate_network_type_targets
end
end
def validate_country_targets
unless targets.all? { |target| target.is_a?(String) && target.match?(/\A[A-Z]{2}\z/) }
errors.add(:targets, "must be valid ISO country codes (e.g., 'BR', 'US')")
end
end
def validate_asn_targets
unless targets.all? { |target| target.is_a?(Integer) && target > 0 }
errors.add(:targets, "must be valid ASNs (positive integers)")
end
end
def validate_company_targets
unless targets.all? { |target| target.is_a?(String) && target.present? }
errors.add(:targets, "must be valid company names")
end
end
def validate_network_type_targets
valid_types = %w[datacenter proxy vpn standard]
unless targets.all? { |target| valid_types.include?(target) }
errors.add(:targets, "must be one of: #{valid_types.join(', ')}")
end
end
def validate_redirect_configuration
if additional_data['redirect_url'].blank?
errors.add(:additional_data, "must include 'redirect_url' for redirect action")
end
end
def validate_challenge_configuration
# Challenge is flexible - can use defaults if not specified
valid_challenge_types = %w[captcha javascript proof_of_work]
challenge_type_value = additional_data&.dig('challenge_type')
if challenge_type_value && !valid_challenge_types.include?(challenge_type_value)
errors.add(:additional_data, "challenge_type must be one of: #{valid_challenge_types.join(', ')}")
end
end
# Matching logic for different policy types
def matches_country?(network_range)
country = network_range.country || network_range.inherited_intelligence[:country]
targets.include?(country)
end
def matches_asn?(network_range)
asn = network_range.asn || network_range.inherited_intelligence[:asn]
targets.include?(asn)
end
def matches_company?(network_range)
company = network_range.company || network_range.inherited_intelligence[:company]
return false if company.blank?
targets.any? do |target_company|
company.downcase.include?(target_company.downcase) ||
target_company.downcase.include?(company.downcase)
end
end
def matches_network_type?(network_range)
intelligence = network_range.inherited_intelligence
targets.any? do |target_type|
case target_type
when 'datacenter'
intelligence[:is_datacenter] == true
when 'proxy'
intelligence[:is_proxy] == true
when 'vpn'
intelligence[:is_vpn] == true
when 'standard'
intelligence[:is_datacenter] == false &&
intelligence[:is_proxy] == false &&
intelligence[:is_vpn] == false
else
false
end
end
end
def build_rule_metadata(network_range)
base_metadata = {
generated_by_policy: id,
policy_name: name,
policy_type: policy_type,
matched_field: matched_field(network_range),
matched_value: matched_value(network_range)
}
base_metadata.merge!(additional_data || {})
end
def matched_field(network_range)
case policy_type
when 'country'
'country'
when 'asn'
'asn'
when 'company'
'company'
when 'network_type'
'network_type'
else
'unknown'
end
end
def matched_value(network_range)
case policy_type
when 'country'
network_range.country || network_range.inherited_intelligence[:country]
when 'asn'
network_range.asn || network_range.inherited_intelligence[:asn]
when 'company'
network_range.company || network_range.inherited_intelligence[:company]
when 'network_type'
intelligence = network_range.inherited_intelligence
types = []
types << 'datacenter' if intelligence[:is_datacenter]
types << 'proxy' if intelligence[:is_proxy]
types << 'vpn' if intelligence[:is_vpn]
types.join(',') || 'standard'
end
end
end