Files
baffle-hub/app/models/waf_policy.rb

612 lines
16 KiB
Ruby

# 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 path_pattern].freeze
# Actions - what to do when traffic matches this policy
ACTIONS = %w[allow deny redirect challenge add_header].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 :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_policy_action?
validate :validate_challenge_configuration, if: :challenge_policy_action?
validate :validate_add_header_configuration, if: :add_header_policy_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
def path_pattern_policy?
policy_type == 'path_pattern'
end
# Action methods
def allow_action?
policy_action == 'allow'
end
def deny_action?
policy_action == 'deny'
end
def redirect_action?
policy_action == 'redirect'
end
def challenge_action?
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
def add_header_policy_action?
policy_action == 'add_header'
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
# Event matching methods (for path patterns)
def matches_event?(event)
return false unless active?
case policy_type
when 'country', 'asn', 'company', 'network_type'
# For network-based policies, use the event's network range
event.network_range && matches_network_range?(event.network_range)
when 'path_pattern'
matches_path_patterns?(event)
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: policy_action,
network_range: network_range,
waf_policy: self,
user: user,
source: "policy",
metadata: build_rule_metadata(network_range),
priority: network_range.prefix_length
)
# Handle redirect/challenge/add_header 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']
)
)
elsif add_header_action?
rule.update!(
metadata: rule.metadata.merge(
header_name: additional_data['header_name'],
header_value: additional_data['header_value']
)
)
end
rule
end
def create_rule_for_event(event)
return nil unless matches_event?(event)
# For path pattern policies, create a path_pattern rule
if path_pattern_policy?
# Check for existing path_pattern rule with same policy and patterns
existing_rule = Rule.find_by(
waf_rule_type: 'path_pattern',
waf_action: policy_action,
waf_policy: self,
enabled: true
)
if existing_rule
Rails.logger.debug "Path pattern rule already exists for policy #{name}"
return existing_rule
end
rule = Rule.create!(
waf_rule_type: 'path_pattern',
waf_action: policy_action,
waf_policy: self,
user: user,
source: "policy",
conditions: build_path_pattern_conditions(event),
metadata: build_path_pattern_metadata(event),
priority: 50 # Default priority for path rules
)
# Handle redirect/challenge/add_header 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']
)
)
elsif add_header_action?
rule.update!(
metadata: rule.metadata.merge(
header_name: additional_data['header_name'],
header_value: additional_data['header_value']
)
)
end
rule
else
# For network-based policies, fall back to network range rule creation
create_rule_for_network_range(event.network_range)
end
end
# Class methods for creating common policies
def self.create_country_policy(countries, policy_action: 'deny', user:, **options)
create!(
name: "#{policy_action.capitalize} #{countries.join(', ')}",
policy_type: 'country',
targets: Array(countries),
policy_action: policy_action,
user: user,
**options
)
end
def self.create_asn_policy(asns, policy_action: 'deny', user:, **options)
create!(
name: "#{policy_action.capitalize} ASNs #{asns.join(', ')}",
policy_type: 'asn',
targets: Array(asns).map(&:to_i),
policy_action: policy_action,
user: user,
**options
)
end
def self.create_company_policy(companies, policy_action: 'deny', user:, **options)
create!(
name: "#{policy_action.capitalize} #{companies.join(', ')}",
policy_type: 'company',
targets: Array(companies),
policy_action: policy_action,
user: user,
**options
)
end
def self.create_network_type_policy(types, policy_action: 'deny', user:, **options)
create!(
name: "#{policy_action.capitalize} #{types.join(', ')}",
policy_type: 'network_type',
targets: Array(types),
policy_action: policy_action,
user: user,
**options
)
end
def self.create_path_pattern_policy(patterns, policy_action: 'deny', user:, **options)
create!(
name: "#{policy_action.capitalize} path patterns: #{Array(patterns).join(', ')}",
policy_type: 'path_pattern',
targets: Array(patterns),
policy_action: policy_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,
policy_action: policy_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?
# Set default header values for add_header action
if add_header_policy_action?
self.additional_data['header_name'] ||= 'X-Bot-Agent'
self.additional_data['header_value'] ||= 'Unknown'
end
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
when 'path_pattern'
validate_path_pattern_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_path_pattern_targets
unless targets.all? { |target| target.is_a?(String) && target.present? }
errors.add(:targets, "must be valid path pattern strings")
end
# Validate path patterns format (basic validation)
targets.each do |pattern|
begin
# Basic validation - ensure it's a reasonable pattern
unless pattern.match?(/\A[a-zA-Z0-9\-\._\*\/\?\[\]\{\}]+\z/)
errors.add(:targets, "contains invalid characters in pattern: #{pattern}")
end
rescue => e
errors.add(:targets, "invalid path pattern: #{pattern} - #{e.message}")
end
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
def validate_add_header_configuration
if additional_data['header_name'].blank?
errors.add(:additional_data, "must include 'header_name' for add_header action")
end
if additional_data['header_value'].blank?
errors.add(:additional_data, "must include 'header_value' for add_header action")
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
# Path pattern matching methods
def matches_path_patterns?(event)
return false if event.request_path.blank?
path = event.request_path.downcase
targets.any? { |pattern| matches_path_pattern?(pattern, path) }
end
def matches_path_pattern?(pattern, path)
pattern = pattern.downcase
# Handle different pattern types
case pattern
when /\*/, /\?/, /\[/
# Glob patterns - simple matching
match_glob_pattern(pattern, path)
when /\.php$/, /\.exe$/, /\.js$/
# File extension patterns
path.end_with?(pattern)
when /\A\//
# Exact path match
path == pattern
else
# Simple substring match
path.include?(pattern)
end
end
def match_glob_pattern(pattern, path)
# Convert simple glob patterns to regex
regex_pattern = pattern
.gsub('*', '.*')
.gsub('?', '.')
.gsub('[', '\[')
.gsub(']', '\]')
path.match?(/\A#{regex_pattern}\z/)
end
def build_path_pattern_conditions(event)
{
"patterns" => targets,
"match_type" => "path_pattern"
}
end
def build_path_pattern_metadata(event)
base_metadata = {
generated_by_policy: id,
policy_name: name,
policy_type: policy_type,
matched_path: event.request_path,
generated_from: "event"
}
base_metadata.merge!(additional_data || {})
end
end