Migrate to Postgresql for better network handling. Add more user functionality.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :session
|
||||
attribute :baffle_host
|
||||
attribute :baffle_internal_host
|
||||
delegate :user, to: :session, allow_nil: true
|
||||
end
|
||||
|
||||
@@ -268,11 +268,114 @@ class Event < ApplicationRecord
|
||||
headers.transform_keys(&:downcase)
|
||||
end
|
||||
|
||||
# GeoIP enrichment methods
|
||||
# Network range resolution methods
|
||||
def matching_network_ranges
|
||||
return [] unless ip_address.present?
|
||||
|
||||
NetworkRange.contains_ip(ip_address).map do |range|
|
||||
{
|
||||
range: range,
|
||||
cidr: range.cidr,
|
||||
prefix_length: range.prefix_length,
|
||||
specificity: range.prefix_length,
|
||||
intelligence: range.inherited_intelligence
|
||||
}
|
||||
end.sort_by { |r| -r[:specificity] } # Most specific first
|
||||
end
|
||||
|
||||
def most_specific_range
|
||||
matching_network_ranges.first
|
||||
end
|
||||
|
||||
def broadest_range
|
||||
matching_network_ranges.last
|
||||
end
|
||||
|
||||
def network_intelligence
|
||||
most_specific_range&.dig(:intelligence) || {}
|
||||
end
|
||||
|
||||
def company
|
||||
network_intelligence[:company]
|
||||
end
|
||||
|
||||
def asn
|
||||
network_intelligence[:asn]
|
||||
end
|
||||
|
||||
def asn_org
|
||||
network_intelligence[:asn_org]
|
||||
end
|
||||
|
||||
def is_datacenter?
|
||||
network_intelligence[:is_datacenter] || false
|
||||
end
|
||||
|
||||
def is_proxy?
|
||||
network_intelligence[:is_proxy] || false
|
||||
end
|
||||
|
||||
def is_vpn?
|
||||
network_intelligence[:is_vpn] || false
|
||||
end
|
||||
|
||||
# IP validation
|
||||
def valid_ipv4?
|
||||
return false unless ip_address.present?
|
||||
|
||||
IPAddr.new(ip_address).ipv4?
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
|
||||
def valid_ipv6?
|
||||
return false unless ip_address.present?
|
||||
|
||||
IPAddr.new(ip_address).ipv6?
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
|
||||
def valid_ip?
|
||||
valid_ipv4? || valid_ipv6?
|
||||
end
|
||||
|
||||
# Rules affecting this IP
|
||||
def matching_rules
|
||||
return Rule.none unless ip_address.present?
|
||||
|
||||
# Get all network ranges that contain this IP
|
||||
range_ids = matching_network_ranges.map { |r| r[:range].id }
|
||||
|
||||
# Find rules for those ranges, ordered by priority (most specific first)
|
||||
Rule.network_rules
|
||||
.where(network_range_id: range_ids)
|
||||
.enabled
|
||||
.includes(:network_range)
|
||||
.order('masklen(network_ranges.network) DESC')
|
||||
end
|
||||
|
||||
def active_blocking_rules
|
||||
matching_rules.where(action: 'deny')
|
||||
end
|
||||
|
||||
def has_blocking_rules?
|
||||
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
|
||||
@@ -282,13 +385,21 @@ class Event < ApplicationRecord
|
||||
# Class method to enrich multiple events
|
||||
def self.enrich_geo_location_batch(events = nil)
|
||||
events ||= where(country_code: [nil, '']).where.not(ip_address: [nil, ''])
|
||||
geo_service = GeoIpService.new
|
||||
updated_count = 0
|
||||
|
||||
events.find_each do |event|
|
||||
next if event.country_code.present?
|
||||
|
||||
country = geo_service.lookup_country(event.ip_address)
|
||||
# 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
|
||||
@@ -303,6 +414,11 @@ class Event < ApplicationRecord
|
||||
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}"
|
||||
@@ -311,16 +427,19 @@ class Event < ApplicationRecord
|
||||
|
||||
# Check if event has valid geo location data
|
||||
def has_geo_data?
|
||||
country_code.present? || city.present?
|
||||
country_code.present? || city.present? || network_intelligence[:country].present?
|
||||
end
|
||||
|
||||
# Get full geo location details
|
||||
def geo_location
|
||||
network_info = network_intelligence
|
||||
|
||||
{
|
||||
country_code: country_code,
|
||||
country_code: country_code || network_info[:country],
|
||||
city: city,
|
||||
ip_address: ip_address,
|
||||
has_data: has_geo_data?
|
||||
has_data: has_geo_data?,
|
||||
network_intelligence: network_info
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
290
app/models/network_range.rb
Normal file
290
app/models/network_range.rb
Normal file
@@ -0,0 +1,290 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# NetworkRange - Unified IPv4/IPv6 network range management
|
||||
#
|
||||
# Uses PostgreSQL's inet type to handle both IPv4 and IPv4 networks seamlessly.
|
||||
# Provides network intelligence data including ASN, company, geographic info,
|
||||
# and classification flags (datacenter, proxy, VPN).
|
||||
class NetworkRange < ApplicationRecord
|
||||
# Sources for network range creation
|
||||
SOURCES = %w[api_imported user_created manual auto_generated inherited].freeze
|
||||
|
||||
# Associations
|
||||
has_many :rules, dependent: :destroy
|
||||
belongs_to :user, optional: true
|
||||
|
||||
# Validations
|
||||
validates :network, presence: true, uniqueness: true
|
||||
validates :source, inclusion: { in: SOURCES }
|
||||
validates :asn, numericality: { greater_than: 0 }, allow_blank: true
|
||||
|
||||
# Scopes
|
||||
scope :ipv4, -> { where("family(network) = 4") }
|
||||
scope :ipv6, -> { where("family(network) = 6") }
|
||||
scope :by_country, ->(country) { where(country: country) }
|
||||
scope :by_company, ->(company) { where(company: company) }
|
||||
scope :by_asn, ->(asn) { where(asn: asn) }
|
||||
scope :datacenter, -> { where(is_datacenter: true) }
|
||||
scope :proxy, -> { where(is_proxy: true) }
|
||||
scope :vpn, -> { where(is_vpn: true) }
|
||||
scope :user_created, -> { where(source: 'user_created') }
|
||||
scope :api_imported, -> { where(source: 'api_imported') }
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_default_source
|
||||
# after_save :update_children_inheritance!, if: :should_update_children_inheritance? # Disabled for now
|
||||
|
||||
# Virtual attribute for CIDR notation
|
||||
def cidr
|
||||
network.to_s
|
||||
end
|
||||
|
||||
def cidr=(new_cidr)
|
||||
self.network = new_cidr
|
||||
end
|
||||
|
||||
# Network properties
|
||||
def prefix_length
|
||||
# Get prefix length from IPAddr object
|
||||
network.prefix
|
||||
end
|
||||
|
||||
def network_address
|
||||
# Use PostgreSQL's host function or get from IPAddr object
|
||||
network.to_s
|
||||
end
|
||||
|
||||
def cidr
|
||||
# Return full CIDR notation
|
||||
"#{network_address}/#{prefix_length}"
|
||||
end
|
||||
|
||||
def broadcast_address
|
||||
# Use PostgreSQL's broadcast function
|
||||
result = self.class.connection.execute("SELECT broadcast('#{network.to_s}')").first
|
||||
result&.values&.first
|
||||
end
|
||||
|
||||
def family
|
||||
# Check if it's IPv4 or IPv6 by looking at the address
|
||||
addr = network.to_s.split('/').first
|
||||
addr.include?(':') ? 6 : 4
|
||||
end
|
||||
|
||||
def ipv4?
|
||||
family == 4
|
||||
end
|
||||
|
||||
def ipv6?
|
||||
family == 6
|
||||
end
|
||||
|
||||
# Network containment and overlap operations
|
||||
def contains_ip?(ip_string)
|
||||
# Use Postgres >>= operator for containment
|
||||
self.class.where("network >>= ?::inet", ip_string).exists?
|
||||
rescue => e
|
||||
Rails.logger.error "Error checking IP containment: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def contains_network?(other_cidr)
|
||||
other_network = IPAddr.new(other_cidr)
|
||||
network_range = IPAddr.new(network)
|
||||
network_range.include?(other_network)
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
|
||||
def overlaps?(other_cidr)
|
||||
network_range = IPAddr.new(network)
|
||||
other_network = IPAddr.new(other_cidr)
|
||||
network_range.include?(other_network) || other_network.include?(network_range)
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
|
||||
# Parent/child relationships
|
||||
def parent_ranges
|
||||
NetworkRange.where("network << ?::inet AND masklen(network) < ?", network.to_s, prefix_length)
|
||||
.order("masklen(network) DESC")
|
||||
end
|
||||
|
||||
def child_ranges
|
||||
NetworkRange.where("network >> ?::inet AND masklen(network) > ?", network.to_s, prefix_length)
|
||||
.order("masklen(network) ASC")
|
||||
end
|
||||
|
||||
def sibling_ranges
|
||||
NetworkRange.where("masklen(network) = ?", prefix_length)
|
||||
.where("network && ?::inet", network.to_s)
|
||||
.where.not(id: id)
|
||||
end
|
||||
|
||||
# Find nearest parent with intelligence data
|
||||
def parent_with_intelligence
|
||||
# Use Postgres network operators to find parent ranges directly
|
||||
cidr_str = network.to_s
|
||||
if cidr_str.include?('/')
|
||||
addr_parts = network_address.split('.')
|
||||
case addr_parts.length
|
||||
when 4 # IPv4
|
||||
new_prefix = [prefix_length - 8, 16].max
|
||||
parent_cidr = "#{addr_parts[0]}.#{addr_parts[1]}.#{addr_parts[2]}.0/#{new_prefix}"
|
||||
else # IPv6 - skip for now
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
return nil unless parent_cidr
|
||||
|
||||
NetworkRange.where("network <<= ?::inet AND masklen(network) < ?", parent_cidr, prefix_length)
|
||||
.where.not(asn: nil)
|
||||
.order("masklen(network) DESC")
|
||||
.first
|
||||
end
|
||||
|
||||
def inherited_intelligence
|
||||
return own_intelligence if has_intelligence?
|
||||
|
||||
parent = parent_with_intelligence
|
||||
parent ? parent.own_intelligence.merge(inherited: true, parent_cidr: parent.cidr) : {}
|
||||
end
|
||||
|
||||
def has_intelligence?
|
||||
asn.present? || company.present? || country.present? ||
|
||||
is_datacenter? || is_proxy? || is_vpn?
|
||||
end
|
||||
|
||||
def own_intelligence
|
||||
{
|
||||
asn: asn,
|
||||
asn_org: asn_org,
|
||||
company: company,
|
||||
country: country,
|
||||
is_datacenter: is_datacenter,
|
||||
is_proxy: is_proxy,
|
||||
is_vpn: is_vpn,
|
||||
inherited: false,
|
||||
source: source
|
||||
}
|
||||
end
|
||||
|
||||
# Geographic lookup
|
||||
def geo_lookup_country!
|
||||
return if country.present?
|
||||
|
||||
sample_ip = network_address
|
||||
geo_country = GeoIpService.lookup_country(sample_ip)
|
||||
update!(country: geo_country) if geo_country.present?
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to lookup geo location for network range #{cidr}: #{e.message}"
|
||||
end
|
||||
|
||||
# Class methods for network operations
|
||||
def self.contains_ip(ip_string)
|
||||
where("network >>= ?", ip_string)
|
||||
.order("masklen(network) DESC") # Most specific first
|
||||
end
|
||||
|
||||
def self.overlapping(range_cidr)
|
||||
where("network && ?", range_cidr)
|
||||
end
|
||||
|
||||
def self.find_or_create_by_cidr(cidr, user: nil, source: nil, reason: nil)
|
||||
find_or_create_by(network: cidr) do |range|
|
||||
range.user = user
|
||||
range.source = source || 'user_created'
|
||||
range.creation_reason = reason
|
||||
end
|
||||
end
|
||||
|
||||
def self.import_from_cidr(cidr, **attributes)
|
||||
find_or_create_by(network: cidr) do |range|
|
||||
range.assign_attributes(attributes)
|
||||
end
|
||||
end
|
||||
|
||||
# Convenience methods for JSON fields
|
||||
def abuser_scores_hash
|
||||
abuser_scores ? JSON.parse(abuser_scores) : {}
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def abuser_scores_hash=(hash)
|
||||
self.abuser_scores = hash.to_json
|
||||
end
|
||||
|
||||
def additional_data_hash
|
||||
additional_data ? JSON.parse(additional_data) : {}
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def additional_data_hash=(hash)
|
||||
self.additional_data = hash.to_json
|
||||
end
|
||||
|
||||
# String representations
|
||||
def to_s
|
||||
cidr
|
||||
end
|
||||
|
||||
def to_param
|
||||
cidr.to_s.gsub('/', '_')
|
||||
end
|
||||
|
||||
# Analytics methods
|
||||
def events_count
|
||||
Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address]).count
|
||||
end
|
||||
|
||||
def recent_events(limit: 100)
|
||||
Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address])
|
||||
.recent
|
||||
.limit(limit)
|
||||
end
|
||||
|
||||
def blocking_rules
|
||||
rules.where(action: 'deny', enabled: true)
|
||||
end
|
||||
|
||||
def active_rules
|
||||
rules.enabled.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_default_source
|
||||
self.source ||= 'api_imported'
|
||||
end
|
||||
|
||||
def should_update_children_inheritance?
|
||||
saved_change_to_attribute?(:asn) ||
|
||||
saved_change_to_attribute?(:company) ||
|
||||
saved_change_to_attribute?(:country) ||
|
||||
saved_change_to_attribute?(:is_datacenter) ||
|
||||
saved_change_to_attribute?(:is_proxy) ||
|
||||
saved_change_to_attribute?(:is_vpn)
|
||||
end
|
||||
|
||||
def update_children_inheritance!
|
||||
# Find child ranges that don't have their own intelligence
|
||||
child_without_intelligence = child_ranges.where(
|
||||
asn: nil,
|
||||
company: nil,
|
||||
country: nil,
|
||||
is_datacenter: false,
|
||||
is_proxy: false,
|
||||
is_vpn: false
|
||||
)
|
||||
|
||||
child_without_intelligence.find_each do |child|
|
||||
Rails.logger.info "Child range #{child.cidr} can now inherit from parent #{cidr}"
|
||||
# The inherited_intelligence method will pick up the new parent data
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,20 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Rule - WAF rule management with NetworkRange integration
|
||||
#
|
||||
# Rules define actions to take for matching traffic conditions.
|
||||
# Network rules are associated with NetworkRange objects for rich context.
|
||||
class Rule < ApplicationRecord
|
||||
# Rule types for the new architecture
|
||||
RULE_TYPES = %w[network_v4 network_v6 rate_limit path_pattern].freeze
|
||||
# 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].freeze
|
||||
SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default manual:surgical_block manual:surgical_exception].freeze
|
||||
|
||||
# Associations
|
||||
belongs_to :user
|
||||
belongs_to :network_range, optional: true
|
||||
|
||||
# Validations
|
||||
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
|
||||
validates :action, presence: true, inclusion: { in: ACTIONS }
|
||||
validates :conditions, presence: true
|
||||
validates :conditions, presence: true, unless: :network_rule?
|
||||
validates :enabled, inclusion: { in: [true, false] }
|
||||
validates :source, inclusion: { in: SOURCES }
|
||||
|
||||
# Custom validations based on rule type
|
||||
# Custom validations
|
||||
validate :validate_conditions_by_type
|
||||
validate :validate_metadata_by_action
|
||||
validate :network_range_required_for_network_rules
|
||||
validate :validate_network_consistency, if: :network_rule?
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
@@ -22,20 +33,80 @@ 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(rule_type: type) }
|
||||
scope :network_rules, -> { where(rule_type: ["network_v4", "network_v6"]) }
|
||||
scope :network_rules, -> { where(rule_type: "network") }
|
||||
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) }
|
||||
scope :surgical_blocks, -> { where(source: "manual:surgical_block") }
|
||||
scope :surgical_exceptions, -> { where(source: "manual:surgical_exception") }
|
||||
|
||||
# Sync queries (ordered by updated_at for incremental sync)
|
||||
scope :since, ->(timestamp) { where("updated_at >= ?", timestamp - 0.5.seconds).order(:updated_at, :id) }
|
||||
# Sync queries
|
||||
scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) }
|
||||
scope :sync_order, -> { order(:updated_at, :id) }
|
||||
|
||||
# Callbacks
|
||||
before_validation :set_defaults
|
||||
before_save :calculate_priority_from_cidr
|
||||
before_validation :parse_json_fields
|
||||
before_save :calculate_priority_for_network_rules
|
||||
|
||||
# Check if rule is currently active
|
||||
# Rule type checks
|
||||
def network_rule?
|
||||
rule_type == "network"
|
||||
end
|
||||
|
||||
def rate_limit_rule?
|
||||
rule_type == "rate_limit"
|
||||
end
|
||||
|
||||
def path_pattern_rule?
|
||||
rule_type == "path_pattern"
|
||||
end
|
||||
|
||||
# Network-specific methods
|
||||
def cidr
|
||||
network_rule? ? network_range&.cidr : conditions&.dig("cidr")
|
||||
end
|
||||
|
||||
def prefix_length
|
||||
network_rule? ? network_range&.prefix_length : cidr&.split("/")&.last&.to_i
|
||||
end
|
||||
|
||||
def network_intelligence
|
||||
return {} unless network_rule? && network_range
|
||||
|
||||
network_range.inherited_intelligence
|
||||
end
|
||||
|
||||
def network_address
|
||||
network_rule? ? network_range&.network_address : nil
|
||||
end
|
||||
|
||||
# Surgical block methods
|
||||
def surgical_block?
|
||||
source == "manual:surgical_block"
|
||||
end
|
||||
|
||||
def surgical_exception?
|
||||
source == "manual:surgical_exception"
|
||||
end
|
||||
|
||||
def related_surgical_rules
|
||||
if surgical_block?
|
||||
# Find the corresponding exception rule
|
||||
surgical_exceptions.where(
|
||||
conditions: { cidr: network_address ? "#{network_address}/32" : nil }
|
||||
)
|
||||
elsif surgical_exception?
|
||||
# Find the parent block rule
|
||||
surgical_blocks.joins(:network_range).where(
|
||||
network_ranges: { network: parent_cidr }
|
||||
)
|
||||
else
|
||||
Rule.none
|
||||
end
|
||||
end
|
||||
|
||||
# Rule lifecycle
|
||||
def active?
|
||||
enabled? && !expired?
|
||||
end
|
||||
@@ -44,14 +115,37 @@ class Rule < ApplicationRecord
|
||||
expires_at.present? && expires_at <= Time.current
|
||||
end
|
||||
|
||||
# Convert to format for agent consumption
|
||||
def activate!
|
||||
update!(enabled: true)
|
||||
end
|
||||
|
||||
def deactivate!
|
||||
update!(enabled: false)
|
||||
end
|
||||
|
||||
def disable!(reason: nil)
|
||||
update!(
|
||||
enabled: false,
|
||||
metadata: metadata.merge(
|
||||
disabled_at: Time.current.iso8601,
|
||||
disabled_reason: reason
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def extend_expiry!(duration)
|
||||
new_expiry = Time.current + duration
|
||||
update!(expires_at: new_expiry)
|
||||
end
|
||||
|
||||
# Agent serialization
|
||||
def to_agent_format
|
||||
{
|
||||
format = {
|
||||
id: id,
|
||||
rule_type: rule_type,
|
||||
action: action,
|
||||
conditions: conditions || {},
|
||||
priority: priority,
|
||||
conditions: agent_conditions,
|
||||
priority: agent_priority,
|
||||
expires_at: expires_at&.iso8601,
|
||||
enabled: enabled,
|
||||
source: source,
|
||||
@@ -59,50 +153,118 @@ class Rule < ApplicationRecord
|
||||
created_at: created_at.iso8601,
|
||||
updated_at: updated_at.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
# Class method to get latest version (for sync cursor)
|
||||
# Returns microsecond Unix timestamp for efficient machine comparison
|
||||
def self.latest_version
|
||||
max_time = maximum(:updated_at)
|
||||
if max_time
|
||||
# Convert to microseconds since epoch
|
||||
(max_time.to_f * 1_000_000).to_i
|
||||
else
|
||||
(Time.current.to_f * 1_000_000).to_i
|
||||
# Add network intelligence for debugging (optional)
|
||||
if network_rule? && network_range
|
||||
format[:network_intelligence] = network_intelligence
|
||||
end
|
||||
|
||||
format
|
||||
end
|
||||
|
||||
# Disable rule (soft delete)
|
||||
def disable!(reason: nil)
|
||||
update!(
|
||||
enabled: false,
|
||||
metadata: (metadata || {}).merge(
|
||||
disabled_at: Time.current.iso8601,
|
||||
disabled_reason: reason
|
||||
)
|
||||
# Class methods for rule creation
|
||||
def self.create_network_rule(cidr, action: 'deny', user: nil, **options)
|
||||
network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created')
|
||||
|
||||
create!(
|
||||
rule_type: 'network',
|
||||
action: action,
|
||||
network_range: network_range,
|
||||
user: user,
|
||||
**options
|
||||
)
|
||||
end
|
||||
|
||||
# Enable rule
|
||||
def enable!
|
||||
update!(enabled: true)
|
||||
def self.create_surgical_block(ip_address, parent_cidr, user: nil, reason: nil, **options)
|
||||
# Create block rule for parent range
|
||||
network_range = NetworkRange.find_or_create_by_cidr(parent_cidr, user: user, source: 'user_created')
|
||||
|
||||
block_rule = create!(
|
||||
rule_type: 'network',
|
||||
action: 'deny',
|
||||
network_range: network_range,
|
||||
source: 'manual:surgical_block',
|
||||
user: user,
|
||||
metadata: {
|
||||
reason: reason,
|
||||
surgical_block: true,
|
||||
original_ip: ip_address,
|
||||
**options[:metadata]
|
||||
},
|
||||
**options.except(:metadata)
|
||||
)
|
||||
|
||||
# Create exception rule for specific IP
|
||||
ip_network_range = NetworkRange.find_or_create_by_cidr("#{ip_address}/#{ip_address.include?(':') ? '128' : '32'}", user: user, source: 'user_created')
|
||||
|
||||
exception_rule = create!(
|
||||
rule_type: 'network',
|
||||
action: 'allow',
|
||||
network_range: ip_network_range,
|
||||
source: 'manual:surgical_exception',
|
||||
user: user,
|
||||
priority: ip_network_range.prefix_length, # Higher priority = more specific
|
||||
metadata: {
|
||||
reason: "Exception for #{ip_address} in surgical block of #{parent_cidr}",
|
||||
surgical_exception: true,
|
||||
parent_rule_id: block_rule.id,
|
||||
**options[:metadata]
|
||||
},
|
||||
**options.except(:metadata)
|
||||
)
|
||||
|
||||
[block_rule, exception_rule]
|
||||
end
|
||||
|
||||
# Check if this is a network rule
|
||||
def network_rule?
|
||||
rule_type.in?(%w[network_v4 network_v6])
|
||||
def self.create_rate_limit_rule(cidr, limit:, window:, user: nil, **options)
|
||||
network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created')
|
||||
|
||||
create!(
|
||||
rule_type: 'rate_limit',
|
||||
action: 'rate_limit',
|
||||
network_range: network_range,
|
||||
conditions: { cidr: cidr, scope: 'ip' },
|
||||
metadata: {
|
||||
limit: limit,
|
||||
window: window,
|
||||
**options[:metadata]
|
||||
},
|
||||
user: user,
|
||||
**options.except(:metadata)
|
||||
)
|
||||
end
|
||||
|
||||
# Get CIDR from conditions (for network rules)
|
||||
def cidr
|
||||
conditions&.dig("cidr") if network_rule?
|
||||
# Sync and versioning
|
||||
def self.latest_version
|
||||
max_time = maximum(:updated_at)
|
||||
max_time ? max_time.to_i : Time.current.to_i
|
||||
end
|
||||
|
||||
# Get prefix length from CIDR
|
||||
def prefix_length
|
||||
return nil unless cidr
|
||||
cidr.split("/").last.to_i
|
||||
def self.active_for_agent
|
||||
active.sync_order.map(&:to_agent_format)
|
||||
end
|
||||
|
||||
# Analytics methods
|
||||
def matching_events(limit: 100)
|
||||
return Event.none unless network_rule? && network_range
|
||||
|
||||
# This would need efficient IP range queries
|
||||
# For now, simple IP match
|
||||
Event.where(ip_address: network_range.network_address)
|
||||
.recent
|
||||
.limit(limit)
|
||||
end
|
||||
|
||||
def effectiveness_stats
|
||||
return {} unless network_rule?
|
||||
|
||||
events = matching_events
|
||||
{
|
||||
total_events: events.count,
|
||||
blocked_events: events.blocked.count,
|
||||
allowed_events: events.allowed.count,
|
||||
block_rate: events.count > 0 ? (events.blocked.count.to_f / events.count * 100).round(2) : 0
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
@@ -112,19 +274,40 @@ class Rule < ApplicationRecord
|
||||
self.conditions ||= {}
|
||||
self.metadata ||= {}
|
||||
self.source ||= "manual"
|
||||
|
||||
# Set system user for auto-generated rules if no user is set
|
||||
if source&.start_with?('auto:') || source == 'default'
|
||||
self.user ||= User.find_by(role: 1) # admin role
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
def calculate_priority_for_network_rules
|
||||
if network_rule? && network_range
|
||||
self.priority = network_range.prefix_length
|
||||
end
|
||||
end
|
||||
|
||||
def agent_conditions
|
||||
if network_rule?
|
||||
{ cidr: cidr }
|
||||
else
|
||||
conditions || {}
|
||||
end
|
||||
end
|
||||
|
||||
def agent_priority
|
||||
if network_rule?
|
||||
prefix_length || 0
|
||||
else
|
||||
priority || 0
|
||||
end
|
||||
end
|
||||
|
||||
def validate_conditions_by_type
|
||||
case rule_type
|
||||
when "network_v4", "network_v6"
|
||||
validate_network_conditions
|
||||
when "network"
|
||||
# Network rules don't need conditions in DB - stored in network_range
|
||||
true
|
||||
when "rate_limit"
|
||||
validate_rate_limit_conditions
|
||||
when "path_pattern"
|
||||
@@ -132,29 +315,6 @@ class Rule < ApplicationRecord
|
||||
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
|
||||
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 validate_rate_limit_conditions
|
||||
scope = conditions&.dig("scope")
|
||||
cidr_value = conditions&.dig("cidr")
|
||||
@@ -163,11 +323,6 @@ class Rule < ApplicationRecord
|
||||
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
|
||||
@@ -193,4 +348,50 @@ class Rule < ApplicationRecord
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def network_range_required_for_network_rules
|
||||
if network_rule? && network_range.nil?
|
||||
errors.add(:network_range, "is required for network rules")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_network_consistency
|
||||
return unless network_rule? && network_range
|
||||
|
||||
# For network rules, we don't use conditions - the network_range handles everything
|
||||
# So we can skip this validation for now
|
||||
true
|
||||
end
|
||||
|
||||
def parent_cidr
|
||||
return nil unless network_range
|
||||
|
||||
# Find a broader network range that contains this one
|
||||
network_range.parent_ranges.first&.cidr
|
||||
end
|
||||
|
||||
def parse_json_fields
|
||||
# Parse conditions if it's a string
|
||||
if conditions.is_a?(String) && conditions.present?
|
||||
begin
|
||||
self.conditions = JSON.parse(conditions) if conditions != "{}"
|
||||
rescue JSON::ParserError
|
||||
self.conditions = {}
|
||||
end
|
||||
end
|
||||
|
||||
# Parse metadata if it's a string
|
||||
if metadata.is_a?(String) && metadata.present?
|
||||
begin
|
||||
self.metadata = JSON.parse(metadata) if metadata != "{}"
|
||||
rescue JSON::ParserError
|
||||
self.metadata = {}
|
||||
end
|
||||
end
|
||||
|
||||
# Ensure they are hashes
|
||||
self.conditions ||= {}
|
||||
self.metadata ||= {}
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,108 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class RuleSet < ApplicationRecord
|
||||
has_many :rules, dependent: :destroy
|
||||
|
||||
validates :name, presence: true, uniqueness: true
|
||||
validates :slug, presence: true, uniqueness: true
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
scope :by_priority, -> { order(priority: :desc, created_at: :desc) }
|
||||
|
||||
before_validation :generate_slug, if: :name?
|
||||
before_validation :set_default_values
|
||||
|
||||
# Rule Types
|
||||
RULE_TYPES = %w[ip cidr path user_agent parameter method rate_limit country].freeze
|
||||
ACTIONS = %w[allow deny challenge rate_limit].freeze
|
||||
|
||||
def to_waf_rules
|
||||
return [] unless enabled?
|
||||
|
||||
rules.enabled.by_priority.map do |rule|
|
||||
{
|
||||
id: rule.id,
|
||||
type: rule.rule_type,
|
||||
target: rule.target,
|
||||
action: rule.action,
|
||||
conditions: rule.conditions,
|
||||
priority: rule.priority,
|
||||
expires_at: rule.expires_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def add_rule(rule_type, target, action, conditions: {}, expires_at: nil, priority: 100)
|
||||
rules.create!(
|
||||
rule_type: rule_type,
|
||||
target: target,
|
||||
action: action,
|
||||
conditions: conditions,
|
||||
expires_at: expires_at,
|
||||
priority: priority
|
||||
)
|
||||
end
|
||||
|
||||
def remove_rule(rule_id)
|
||||
rules.find(rule_id).destroy
|
||||
end
|
||||
|
||||
def block_ip(ip_address, expires_at: nil, reason: nil)
|
||||
add_rule('ip', ip_address, 'deny', expires_at: expires_at, priority: 1000)
|
||||
end
|
||||
|
||||
def allow_ip(ip_address, expires_at: nil)
|
||||
add_rule('ip', ip_address, 'allow', expires_at: expires_at, priority: 1000)
|
||||
end
|
||||
|
||||
def block_cidr(cidr, expires_at: nil, reason: nil)
|
||||
add_rule('cidr', cidr, 'deny', expires_at: expires_at, priority: 900)
|
||||
end
|
||||
|
||||
def block_path(path, conditions: {}, expires_at: nil)
|
||||
add_rule('path', path, 'deny', conditions: conditions, expires_at: expires_at, priority: 500)
|
||||
end
|
||||
|
||||
def block_user_agent(user_agent_pattern, expires_at: nil)
|
||||
add_rule('user_agent', user_agent_pattern, 'deny', expires_at: expires_at, priority: 600)
|
||||
end
|
||||
|
||||
def push_to_agents!
|
||||
# This would integrate with the agent distribution system
|
||||
Rails.logger.info "Pushing rule set '#{name}' with #{rules.count} rules to agents"
|
||||
|
||||
# Broadcast update to connected projects
|
||||
projects = Project.where(id: projects_subscription || [])
|
||||
projects.each(&:broadcast_rules_refresh)
|
||||
end
|
||||
|
||||
def active_projects
|
||||
return Project.none unless projects_subscription.present?
|
||||
|
||||
Project.where(id: projects_subscription).enabled
|
||||
end
|
||||
|
||||
def subscribe_project(project)
|
||||
subscriptions = projects_subscription || []
|
||||
subscriptions << project.id unless subscriptions.include?(project.id)
|
||||
update(projects_subscription: subscriptions.uniq)
|
||||
end
|
||||
|
||||
def unsubscribe_project(project)
|
||||
subscriptions = projects_subscription || []
|
||||
subscriptions.delete(project.id)
|
||||
update(projects_subscription: subscriptions)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_slug
|
||||
self.slug = name&.parameterize&.downcase
|
||||
end
|
||||
|
||||
def set_default_values
|
||||
self.enabled = true if enabled.nil?
|
||||
self.priority = 100 if priority.nil?
|
||||
self.projects_subscription = [] if projects_subscription.nil?
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,10 @@ class User < ApplicationRecord
|
||||
|
||||
enum :role, { admin: 0, user: 1, viewer: 2 }, default: :user
|
||||
|
||||
generates_token_for :password_reset, expires_in: 1.hour do
|
||||
updated_at
|
||||
end
|
||||
|
||||
validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||
validates :role, presence: true
|
||||
|
||||
@@ -18,13 +22,18 @@ class User < ApplicationRecord
|
||||
|
||||
user = find_or_initialize_by(email_address: email)
|
||||
|
||||
# Map OIDC groups to role
|
||||
# Map OIDC groups to role for new users or update existing user's role
|
||||
if auth_hash.dig('extra', 'raw_info', 'groups')
|
||||
user.role = map_oidc_groups_to_role(auth_hash.dig('extra', 'raw_info', 'groups'))
|
||||
end
|
||||
|
||||
# Don't override password for OIDC users
|
||||
user.save!(validate: false) if user.new_record?
|
||||
# For OIDC users, set a random password if they don't have one
|
||||
if user.new_record? && !user.password_digest?
|
||||
user.password = SecureRandom.hex(32) # OIDC users won't use this
|
||||
end
|
||||
|
||||
# Save the user (skip password validation for OIDC users)
|
||||
user.save!(validate: false) if user.changed?
|
||||
user
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user