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

@@ -57,6 +57,9 @@ gem "maxmind-db"
# HTTP client for database downloads
gem "httparty"
# Country data and ISO code utilities
gem "countries"
# Authorization library
gem "pundit"

View File

@@ -76,6 +76,57 @@ class AnalyticsController < ApplicationController
end
end
def networks
authorize :analytics, :index?
# Time period selector (default: last 24 hours)
@time_period = params[:period]&.to_sym || :day
@start_time = calculate_start_time(@time_period)
# Top networks by request volume
@top_networks = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? OR events.timestamp IS NULL", @start_time)
.group("network_ranges.id", "network_ranges.network", "network_ranges.company", "network_ranges.asn", "network_ranges.country", "network_ranges.is_datacenter", "network_ranges.is_vpn", "network_ranges.is_proxy")
.select("network_ranges.*, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips")
.order("event_count DESC")
.limit(50)
# Network type breakdown with traffic stats
@network_breakdown = calculate_network_type_stats(@start_time)
# Company breakdown for top traffic sources
@top_companies = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? AND network_ranges.company IS NOT NULL", @start_time)
.group("network_ranges.company")
.select("network_ranges.company, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count")
.order("event_count DESC")
.limit(20)
# ASN breakdown
@top_asns = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? AND network_ranges.asn IS NOT NULL", @start_time)
.group("network_ranges.asn", "network_ranges.asn_org")
.select("network_ranges.asn, network_ranges.asn_org, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count")
.order("event_count DESC")
.limit(15)
# Geographic breakdown
@top_countries = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time)
.group("network_ranges.country")
.select("network_ranges.country, COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count")
.order("event_count DESC")
.limit(15)
# Suspicious network activity patterns
@suspicious_patterns = calculate_suspicious_patterns(@start_time)
respond_to do |format|
format.html
format.json { render json: network_analytics_json }
end
end
private
def calculate_start_time(period)
@@ -132,4 +183,132 @@ class AnalyticsController < ApplicationController
]
}
end
def calculate_network_type_stats(start_time)
# Get all network types with their traffic statistics
network_types = [
{ type: 'datacenter', label: 'Datacenter' },
{ type: 'vpn', label: 'VPN' },
{ type: 'proxy', label: 'Proxy' }
]
results = {}
total_events = Event.where("timestamp >= ?", start_time).count
network_types.each do |network_type|
scope = case network_type[:type]
when 'datacenter' then NetworkRange.datacenter
when 'vpn' then NetworkRange.vpn
when 'proxy' then NetworkRange.proxy
end
if scope
network_stats = scope.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? OR events.timestamp IS NULL", start_time)
.select("COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count")
.first
results[network_type[:type]] = {
label: network_type[:label],
networks: network_stats.network_count,
events: network_stats.event_count,
unique_ips: network_stats.unique_ips,
percentage: total_events > 0 ? ((network_stats.event_count.to_f / total_events) * 100).round(1) : 0
}
end
end
# Calculate standard networks (everything else)
standard_stats = NetworkRange.where(is_datacenter: false, is_vpn: false, is_proxy: false)
.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ? OR events.timestamp IS NULL", start_time)
.select("COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count")
.first
results['standard'] = {
label: 'Standard',
networks: standard_stats.network_count,
events: standard_stats.event_count,
unique_ips: standard_stats.unique_ips,
percentage: total_events > 0 ? ((standard_stats.event_count.to_f / total_events) * 100).round(1) : 0
}
results
end
def calculate_suspicious_patterns(start_time)
patterns = {}
# High volume networks (top 1% by request count)
total_networks = NetworkRange.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ?", start_time)
.distinct.count
high_volume_threshold = [total_networks * 0.01, 1].max
high_volume_networks = NetworkRange.joins("INNER JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ?", start_time)
.group("network_ranges.id")
.having("COUNT(events.id) > ?", Event.where("timestamp >= ?", start_time).count / total_networks)
.count
patterns[:high_volume] = {
count: high_volume_networks.count,
networks: high_volume_networks.keys
}
# Networks with high deny rates (> 50% blocked requests)
high_deny_networks = NetworkRange.joins("INNER JOIN events ON events.ip_address <<= network_ranges.network")
.where("events.timestamp >= ?", start_time)
.group("network_ranges.id")
.select("network_ranges.id,
COUNT(CASE WHEN events.waf_action = 1 THEN 1 END) as denied_count,
COUNT(events.id) as total_count")
.having("COUNT(CASE WHEN events.waf_action = 1 THEN 1 END)::float / COUNT(events.id) > 0.5")
.having("COUNT(events.id) >= 10") # minimum threshold
patterns[:high_deny_rate] = {
count: high_deny_networks.count,
network_ids: high_deny_networks.map(&:id)
}
# Networks appearing as multiple subnets (potential botnets)
company_subnets = NetworkRange.where("company IS NOT NULL")
.where("timestamp >= ? OR timestamp IS NULL", start_time)
.group(:company)
.select(:company, "COUNT(DISTINCT network) as subnet_count")
.having("COUNT(DISTINCT network) > 5")
.order("subnet_count DESC")
.limit(10)
patterns[:distributed_companies] = company_subnets.map do |company|
{
company: company.company,
subnets: company.subnet_count
}
end
patterns
end
def network_analytics_json
{
top_networks: @top_networks.map { |network|
{
id: network.id,
cidr: network.cidr,
company: network.company,
asn: network.asn,
country: network.country,
network_type: network.network_type,
event_count: network.event_count,
unique_ips: network.unique_ips
}
},
network_breakdown: @network_breakdown,
top_companies: @top_companies,
top_asns: @top_asns,
top_countries: @top_countries,
suspicious_patterns: @suspicious_patterns
}
end
end

View File

@@ -11,6 +11,12 @@ class EventsController < ApplicationController
@events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].present?
@events = @events.where(country_code: params[:country]) if params[:country].present?
# Network-based filters
@events = @events.by_company(params[:company]) if params[:company].present?
@events = @events.by_network_type(params[:network_type]) if params[:network_type].present?
@events = @events.by_asn(params[:asn]) if params[:asn].present?
@events = @events.by_network_cidr(params[:network_cidr]) if params[:network_cidr].present?
Rails.logger.debug "Events count after filtering: #{@events.count}"
# Debug info
@@ -19,7 +25,24 @@ class EventsController < ApplicationController
# Paginate
@pagy, @events = pagy(@events, items: 50)
# Preload network ranges for all unique IPs to avoid N+1 queries
unique_ips = @events.pluck(:ip_address).uniq.compact
@network_ranges_by_ip = {}
unique_ips.each do |ip|
ip_string = ip.to_s # IPAddr objects can be converted to string
range = NetworkRange.contains_ip(ip_string).first
# Auto-generate network range if no match found
unless range
range = NetworkRangeGenerator.find_or_create_for_ip(ip)
Rails.logger.debug "Auto-generated network range #{range&.cidr} for IP #{ip_string}" if range
end
@network_ranges_by_ip[ip_string] = range if range
end
Rails.logger.debug "Events count after pagination: #{@events.count}"
Rails.logger.debug "Pagy info: #{@pagy.count} total, #{@pagy.pages} pages"
Rails.logger.debug "Preloaded network ranges for #{@network_ranges_by_ip.count} unique IPs"
end
end

View File

@@ -14,17 +14,20 @@ class NetworkRangesController < ApplicationController
# GET /network_ranges
def index
@pagy, @network_ranges = pagy(policy_scope(NetworkRange.includes(:rules))
.order(updated_at: :desc))
# Start with base scope
base_scope = policy_scope(NetworkRange.includes(:rules)).order(updated_at: :desc)
# Apply filters
@network_ranges = apply_filters(@network_ranges)
# Apply filters BEFORE pagination
base_scope = apply_filters(base_scope)
# Apply search
# Apply search BEFORE pagination
if params[:search].present?
@network_ranges = search_network_ranges(@network_ranges, params[:search])
base_scope = search_network_ranges(base_scope, params[:search])
end
# Apply pagination to the filtered scope
@pagy, @network_ranges = pagy(base_scope)
# Statistics
@total_ranges = NetworkRange.count
@ranges_with_intelligence = NetworkRange.where.not(asn: nil).or(NetworkRange.where.not(company: nil)).count
@@ -41,14 +44,23 @@ class NetworkRangesController < ApplicationController
# GET /network_ranges/:id
def show
authorize @network_range
if @network_range.persisted?
# Real network - use existing logic
@related_events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.where("network_ranges.id = ?", @network_range.id)
.recent
.limit(100)
else
# Virtual network - find events by IP range containment
@related_events = Event.where("ip_address <<= ?::inet", @network_range.to_s)
.recent
.limit(100)
end
@child_ranges = @network_range.child_ranges.limit(20)
@parent_ranges = @network_range.parent_ranges.limit(10)
@associated_rules = @network_range.rules.includes(:user).order(created_at: :desc)
@associated_rules = @network_range.persisted? ? @network_range.rules.includes(:user).order(created_at: :desc) : []
# Traffic analytics (if we have events)
@traffic_stats = calculate_traffic_stats(@network_range)
@@ -57,7 +69,7 @@ class NetworkRangesController < ApplicationController
# GET /network_ranges/new
def new
authorize NetworkRange
@network_range = NetworkRange.new
@network_range = NetworkRange.new(network: params[:network])
end
# POST /network_ranges
@@ -154,7 +166,12 @@ class NetworkRangesController < ApplicationController
def set_network_range
# Handle CIDR slugs (e.g., "40.77.167.100_32" -> "40.77.167.100/32")
cidr = params[:id].gsub('_', '/')
@network_range = NetworkRange.find_by!(network: cidr)
@network_range = NetworkRange.find_by(network: cidr)
# If network doesn't exist, create a virtual (unsaved) instance
if @network_range.nil?
@network_range = NetworkRange.new(network: cidr)
end
end
def network_range_params
@@ -194,8 +211,8 @@ class NetworkRangesController < ApplicationController
end
def calculate_traffic_stats(network_range)
# Use the cached events_count for total requests (much more performant)
# For detailed breakdown, we still need to query but we can optimize with a limit
if network_range.persisted?
# Real network - use cached events_count for total requests (much more performant)
if network_range.events_count > 0
events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.where("network_ranges.id = ?", network_range.id)
@@ -222,5 +239,22 @@ class NetworkRangesController < ApplicationController
recent_activity: []
}
end
else
# Virtual network - calculate stats from events within range
events = Event.where("ip_address <<= ?::inet", network_range.to_s)
.limit(1000) # Limit the sample for performance
total_events = Event.where("ip_address <<= ?::inet", network_range.to_s).count
{
total_requests: total_events,
unique_ips: events.distinct.count(:ip_address),
blocked_requests: events.blocked.count,
allowed_requests: events.allowed.count,
top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10),
top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
recent_activity: events.recent.limit(20)
}
end
end
end

View File

@@ -0,0 +1,165 @@
# frozen_string_literal: true
class WafPoliciesController < ApplicationController
# Follow proper before_action order:
# 1. Authentication/Authorization
# All actions require authentication
# 2. Resource loading
before_action :set_waf_policy, only: [:show, :edit, :update, :destroy, :activate, :deactivate]
# GET /waf_policies
def index
@pagy, @waf_policies = pagy(policy_scope(WafPolicy).includes(:user, :generated_rules).order(created_at: :desc))
@policy_types = WafPolicy::POLICY_TYPES
@actions = WafPolicy::ACTIONS
end
# GET /waf_policies/new
def new
authorize WafPolicy
@waf_policy = WafPolicy.new
@policy_types = WafPolicy::POLICY_TYPES
@actions = WafPolicy::ACTIONS
# Set default values from URL parameters
@waf_policy.policy_type = params[:policy_type] if params[:policy_type].present?
@waf_policy.action = params[:action] if params[:action].present?
@waf_policy.targets = params[:targets] if params[:targets].present?
end
# POST /waf_policies
def create
authorize WafPolicy
@waf_policy = WafPolicy.new(waf_policy_params)
@waf_policy.user = Current.user
@policy_types = WafPolicy::POLICY_TYPES
@actions = WafPolicy::ACTIONS
if @waf_policy.save
# Trigger policy processing for existing network ranges
ProcessWafPoliciesJob.perform_later(waf_policy_id: @waf_policy.id)
redirect_to @waf_policy, notice: 'WAF policy was successfully created.'
else
render :new, status: :unprocessable_entity
end
end
# GET /waf_policies/:id
def show
@generated_rules = @waf_policy.generated_rules.includes(:network_range).order(created_at: :desc).limit(20)
@effectiveness_stats = @waf_policy.effectiveness_stats
end
# GET /waf_policies/:id/edit
def edit
@policy_types = WafPolicy::POLICY_TYPES
@actions = WafPolicy::ACTIONS
end
# PATCH/PUT /waf_policies/:id
def update
@policy_types = WafPolicy::POLICY_TYPES
@actions = WafPolicy::ACTIONS
if @waf_policy.update(waf_policy_params)
# Re-process policies for existing network ranges if policy was changed
if @waf_policy.saved_change_to_targets? || @waf_policy.saved_change_to_action?
ProcessWafPoliciesJob.reprocess_for_policy(@waf_policy)
end
redirect_to @waf_policy, notice: 'WAF policy was successfully updated.'
else
render :edit, status: :unprocessable_entity
end
end
# DELETE /waf_policies/:id
def destroy
policy_name = @waf_policy.name
# Soft delete by disabling and expiring the policy
@waf_policy.update!(enabled: false, expires_at: Time.current)
redirect_to waf_policies_url, notice: "WAF policy '#{policy_name}' was disabled."
end
# POST /waf_policies/:id/activate
def activate
@waf_policy.activate!
# Re-process policies for existing network ranges
ProcessWafPoliciesJob.reprocess_for_policy(@waf_policy)
redirect_to @waf_policy, notice: 'WAF policy was activated.'
end
# POST /waf_policies/:id/deactivate
def deactivate
@waf_policy.deactivate!
redirect_to @waf_policy, notice: 'WAF policy was deactivated.'
end
# GET /waf_policies/new_country
def new_country
authorize WafPolicy
@waf_policy = WafPolicy.new(policy_type: 'country', action: 'deny')
@policy_types = WafPolicy::POLICY_TYPES
@actions = WafPolicy::ACTIONS
end
# POST /waf_policies/create_country
def create_country
authorize WafPolicy
countries = params[:countries]&.reject(&:blank?) || []
action = params[:action] || 'deny'
if countries.empty?
redirect_to new_country_waf_policies_path, alert: 'Please select at least one country.'
return
end
@waf_policy = WafPolicy.create_country_policy(
countries,
action: action,
user: Current.user,
description: params[:description]
)
if @waf_policy.persisted?
# Trigger policy processing for existing network ranges
ProcessWafPoliciesJob.reprocess_for_policy(@waf_policy)
redirect_to @waf_policy, notice: "Country blocking policy was successfully created for #{countries.join(', ')}."
else
@policy_types = WafPolicy::POLICY_TYPES
@actions = WafPolicy::ACTIONS
render :new_country, status: :unprocessable_entity
end
end
private
def set_waf_policy
@waf_policy = WafPolicy.find(params[:id])
authorize @waf_policy
rescue ActiveRecord::RecordNotFound
redirect_to waf_policies_path, alert: 'WAF policy not found.'
end
def waf_policy_params
params.require(:waf_policy).permit(
:name,
:description,
:policy_type,
:action,
:enabled,
:expires_at,
targets: [],
additional_data: {}
)
end
end

View File

@@ -35,6 +35,23 @@ class ProcessWafEventJob < ApplicationJob
end
end
# Ensure network range exists for this IP and process policies
if event.ip_address.present?
begin
existing_range = NetworkRange.contains_ip(event.ip_address.to_s).first
network_range = existing_range || NetworkRangeGenerator.find_or_create_for_ip(event.ip_address)
if network_range
Rails.logger.debug "Network range #{network_range.cidr} for event IP #{event.ip_address}"
# Process WAF policies for this network range
ProcessWafPoliciesJob.perform_later(network_range_id: network_range.id, event_id: event.id)
end
rescue => e
Rails.logger.warn "Failed to create network range for event #{event.id}: #{e.message}"
end
end
# Trigger analytics processing
ProcessWafAnalyticsJob.perform_later(event_id: event.id)

View File

@@ -0,0 +1,101 @@
# frozen_string_literal: true
# ProcessWafPoliciesJob - Process firewall policies for a network range
#
# This job checks network ranges against active WAF policies and generates
# specific rules when matches are found.
class ProcessWafPoliciesJob < ApplicationJob
queue_as :waf_policies
retry_on StandardError, wait: :exponentially_longer, attempts: 3
def perform(network_range_id:, event_id: nil)
# Find the network range
network_range = NetworkRange.find_by(id: network_range_id)
return if network_range.nil?
Rails.logger.debug "Processing WAF policies for network range #{network_range.cidr}"
# Use WafPolicyMatcher to find and generate rules
matcher = WafPolicyMatcher.new(network_range: network_range)
result = matcher.match_and_generate_rules
# Log results
if result[:matching_policies].any?
Rails.logger.info "Network range #{network_range.cidr} matched #{result[:matching_policies].length} policies"
result[:matching_policies].each do |policy|
Rails.logger.info " - Matched policy: #{policy.name} (#{policy.policy_type}: #{policy.action})"
end
end
if result[:generated_rules].any?
Rails.logger.info "Generated #{result[:generated_rules].length} rules for network range #{network_range.cidr}"
result[:generated_rules].each do |rule|
Rails.logger.info " - Rule: #{rule.rule_type} #{rule.action} for #{rule.network_range&.cidr} (ID: #{rule.id})"
# Log if this is a redirect or challenge rule
if rule.redirect_action?
Rails.logger.info " Redirect to: #{rule.redirect_url} (#{rule.redirect_status})"
elsif rule.challenge_action?
Rails.logger.info " Challenge type: #{rule.challenge_type}"
end
end
# Trigger agent sync for new rules if there are any
if result[:generated_rules].any?
RulesSyncJob.perform_later
end
else
Rails.logger.debug "No matching policies found for network range #{network_range.cidr}"
end
# Update event record if provided
if event_id.present?
event = Event.find_by(id: event_id)
if event.present?
# Add policy match information to event metadata
event.update!(payload: event.payload.merge({
policy_matches: {
matching_policies_count: result[:matching_policies].length,
generated_rules_count: result[:generated_rules].length,
processed_at: Time.current.iso8601
}
}))
end
end
end
# Class method for batch processing multiple network ranges
def self.process_network_ranges(network_range_ids)
network_range_ids.each do |network_range_id|
perform_later(network_range_id: network_range_id)
end
end
# Class method to reprocess all network ranges for a specific policy
def self.reprocess_for_policy(waf_policy)
waf_policy_id = waf_policy.is_a?(WafPolicy) ? waf_policy.id : waf_policy
# Find all network ranges that could match this policy type
network_ranges = case waf_policy.policy_type
when 'country'
NetworkRange.where.not(country: nil)
when 'asn'
NetworkRange.where.not(asn: nil)
when 'company'
NetworkRange.where.not(company: nil)
when 'network_type'
NetworkRange.where("is_datacenter = ? OR is_proxy = ? OR is_vpn = ?", true, true, true)
else
NetworkRange.none
end
Rails.logger.info "Reprocessing #{network_ranges.count} network ranges for policy #{waf_policy_id}"
network_ranges.find_each do |network_range|
perform_later(network_range_id: network_range.id)
end
end
end

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,9 +138,6 @@ 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"),
@@ -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,10 +454,6 @@ 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") || {}

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

View File

@@ -239,7 +239,11 @@
<!-- Network Intelligence -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Network Intelligence</h3>
<%= link_to "Detailed Network Analytics →", analytics_networks_path,
class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
</div>
</div>
<div class="p-6">
<div class="space-y-3">

View File

@@ -0,0 +1,283 @@
<% content_for :title, "Network Analytics - Baffle Hub" %>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-3xl font-bold text-gray-900">Network Analytics</h1>
<p class="mt-2 text-gray-600">Detailed traffic analysis and network intelligence insights</p>
</div>
<!-- Time Period Selector -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Time Period</h3>
<div class="flex space-x-2">
<% [:hour, :day, :week, :month].each do |period| %>
<%= link_to period.to_s.humanize, analytics_networks_path(period: period),
class: "px-3 py-1 rounded-md text-sm font-medium #{ @time_period == period ? 'bg-blue-100 text-blue-800' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100' }" %>
<% end %>
</div>
</div>
</div>
</div>
<!-- Network Type Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="bg-white shadow rounded-lg">
<div class="p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Standard Networks</p>
<p class="text-2xl font-bold text-gray-900">
<%= number_with_delimiter(@network_breakdown.dig('standard', :networks) || 0) %>
</p>
<p class="text-xs text-gray-500">
<%= number_with_delimiter(@network_breakdown.dig('standard', :events) || 0) %> events
(<%= @network_breakdown.dig('standard', :percentage) || 0 %>%)
</p>
</div>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg">
<div class="p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Datacenter Networks</p>
<p class="text-2xl font-bold text-gray-900">
<%= number_with_delimiter(@network_breakdown.dig('datacenter', :networks) || 0) %>
</p>
<p class="text-xs text-gray-500">
<%= number_with_delimiter(@network_breakdown.dig('datacenter', :events) || 0) %> events
(<%= @network_breakdown.dig('datacenter', :percentage) || 0 %>%)
</p>
</div>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg">
<div class="p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">VPN Networks</p>
<p class="text-2xl font-bold text-gray-900">
<%= number_with_delimiter(@network_breakdown.dig('vpn', :networks) || 0) %>
</p>
<p class="text-xs text-gray-500">
<%= number_with_delimiter(@network_breakdown.dig('vpn', :events) || 0) %> events
(<%= @network_breakdown.dig('vpn', :percentage) || 0 %>%)
</p>
</div>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg">
<div class="p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-500">Proxy Networks</p>
<p class="text-2xl font-bold text-gray-900">
<%= number_with_delimiter(@network_breakdown.dig('proxy', :networks) || 0) %>
</p>
<p class="text-xs text-gray-500">
<%= number_with_delimiter(@network_breakdown.dig('proxy', :events) || 0) %> events
(<%= @network_breakdown.dig('proxy', :percentage) || 0 %>%)
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Top Networks by Traffic -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Top Networks by Traffic Volume</h3>
<p class="text-sm text-gray-500">Networks with the most requests in the selected time period</p>
</div>
<div class="overflow-x-auto">
<% if @top_networks.any? %>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Network</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Company</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Events</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Unique IPs</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @top_networks.each do |network| %>
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm">
<div>
<%= link_to network.cidr, network_range_path(network),
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono font-medium" %>
</div>
<div class="text-xs text-gray-500">
<% if network.country.present? %>
🏳️ <%= network.country %>
<% end %>
<% if network.asn.present? %>
• ASN <%= network.asn %>
<% end %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<%= network.company || 'Unknown' %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<% if network.is_datacenter? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">Datacenter</span>
<% elsif network.is_vpn? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">VPN</span>
<% elsif network.is_proxy? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Proxy</span>
<% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Standard</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<%= number_with_delimiter(network.event_count) %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<%= number_with_delimiter(network.unique_ips) %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
<%= link_to "View Events", events_path(network_cidr: network.cidr),
class: "text-blue-600 hover:text-blue-800 text-sm" %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<div class="px-6 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No network traffic</h3>
<p class="mt-1 text-sm text-gray-500">No network activity found in the selected time period.</p>
</div>
<% end %>
</div>
</div>
<!-- Additional Analytics Sections -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Top Companies -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Top Companies by Traffic</h3>
<p class="text-sm text-gray-500">Companies generating the most traffic</p>
</div>
<div class="p-6">
<% if @top_companies.any? %>
<div class="space-y-4">
<% @top_companies.each do |company| %>
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center">
<div class="font-medium text-gray-900"><%= company.company %></div>
<div class="ml-2 text-sm text-gray-500">
<%= number_with_delimiter(company.network_count) %> networks
</div>
</div>
<div class="mt-1">
<div class="flex items-center text-sm text-gray-600">
<span><%= number_with_delimiter(company.event_count) %> events</span>
<span class="mx-2">•</span>
<span><%= number_with_delimiter(company.unique_ips) %> unique IPs</span>
</div>
</div>
</div>
<div class="ml-4">
<%= link_to "Filter Events", events_path(company: company.company),
class: "text-blue-600 hover:text-blue-800 text-sm font-medium" %>
</div>
</div>
<% end %>
</div>
<% else %>
<p class="text-sm text-gray-500">No company data available for this time period.</p>
<% end %>
</div>
</div>
<!-- Top ASNs -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Top Autonomous Systems</h3>
<p class="text-sm text-gray-500">ASNs with the most traffic</p>
</div>
<div class="p-6">
<% if @top_asns.any? %>
<div class="space-y-4">
<% @top_asns.each do |asn| %>
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-gray-900">
ASN <%= asn.asn %>
<% if asn.asn_org.present? %>
<span class="ml-2 text-gray-600">• <%= asn.asn_org.truncate(30) %></span>
<% end %>
</div>
<div class="mt-1 text-sm text-gray-600">
<span><%= number_with_delimiter(asn.event_count) %> events</span>
<span class="mx-2">•</span>
<span><%= number_with_delimiter(asn.unique_ips) %> unique IPs</span>
<span class="mx-2">•</span>
<span><%= number_with_delimiter(asn.network_count) %> networks</span>
</div>
</div>
<div class="ml-4">
<%= link_to "Filter Events", events_path(asn: asn.asn),
class: "text-blue-600 hover:text-blue-800 text-sm font-medium" %>
</div>
</div>
<% end %>
</div>
<% else %>
<p class="text-sm text-gray-500">No ASN data available for this time period.</p>
<% end %>
</div>
</div>
</div>
<!-- Navigation -->
<div class="flex items-center justify-between">
<div>
<%= link_to "← Back to Dashboard", analytics_path,
class: "text-blue-600 hover:text-blue-800 font-medium" %>
</div>
<div class="text-sm text-gray-500">
Showing network analytics for the <%= @time_period.to_s.humanize.downcase %>
</div>
</div>
</div>

View File

@@ -14,6 +14,7 @@
</div>
<div class="p-6">
<%= form_with url: events_path, method: :get, local: true, class: "space-y-4" do |form| %>
<!-- Basic Filters Row -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<%= form.label :ip, "IP Address", class: "block text-sm font-medium text-gray-700" %>
@@ -40,6 +41,43 @@
class: "inline-flex justify-center py-2 px-4 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
</div>
</div>
<!-- Network Intelligence Filters Row -->
<div class="border-t border-gray-200 pt-4">
<h4 class="text-sm font-medium text-gray-900 mb-3">Network Intelligence Filters</h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<%= form.label :company, "Company", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :company, value: params[:company],
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: "e.g., Amazon, Google" %>
</div>
<div>
<%= form.label :network_type, "Network Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :network_type,
options_for_select([
['All', ''],
['Standard ( Residential/Business )', 'standard'],
['Datacenter', 'datacenter'],
['VPN', 'vpn'],
['Proxy', 'proxy']
], params[:network_type]),
{ }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
</div>
<div>
<%= form.label :asn, "ASN", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :asn, value: params[:asn],
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: "Autonomous System Number" %>
</div>
<div>
<%= form.label :network_cidr, "Network CIDR", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :network_cidr, value: params[:network_cidr],
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: "e.g., 192.168.1.0/24" %>
</div>
</div>
</div>
<% end %>
</div>
</div>
@@ -52,6 +90,8 @@
<div class="flex items-center space-x-4">
<%= link_to "📊 Analytics Dashboard", analytics_path,
class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
<%= link_to "🌐 Network Analytics", analytics_networks_path,
class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
<% if @pagy.pages > 1 %>
<span class="text-sm text-gray-500">
Page <%= @pagy.page %> of <%= @pagy.pages %>
@@ -92,8 +132,45 @@
<%= event.timestamp.strftime("%Y-%m-%d") %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
<%= event.ip_address %>
<td class="px-6 py-4 text-sm text-gray-900">
<% network_range = @network_ranges_by_ip[event.ip_address.to_s] %>
<% if network_range %>
<%= link_to event.ip_address, network_range_path(network_range),
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono" %>
<!-- Network Intelligence Summary -->
<div class="mt-1 space-y-1">
<% if network_range.company.present? %>
<div class="text-xs text-gray-600 font-medium">
<%= network_range.company %>
</div>
<% end %>
<% if network_range.is_datacenter? || network_range.is_vpn? || network_range.is_proxy? %>
<div class="flex flex-wrap gap-1">
<% if network_range.is_datacenter? %>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-800" title="Datacenter">DC</span>
<% end %>
<% if network_range.is_vpn? %>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800" title="VPN">VPN</span>
<% end %>
<% if network_range.is_proxy? %>
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800" title="Proxy">PROXY</span>
<% end %>
</div>
<% end %>
<div class="text-xs text-gray-500">
<%= network_range.cidr %>
<% if network_range.asn.present? %>
• ASN <%= network_range.asn %>
<% end %>
</div>
</div>
<% else %>
<span class="font-mono"><%= event.ip_address %></span>
<div class="mt-1 text-xs text-gray-400">Unknown network</div>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
@@ -117,9 +194,9 @@
<%= event.response_status || '-' %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<% if event.country_code.present? %>
<% if event.lookup_country.present? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<%= event.country_code %>
<%= event.lookup_country %>
</span>
<% else %>
<span class="text-gray-400">-</span>

View File

@@ -68,6 +68,8 @@
class: nav_link_class(events_path) %>
<%= link_to "⚙️ Rules", rules_path,
class: nav_link_class(rules_path) %>
<%= link_to "🛡️ WAF Policies", waf_policies_path,
class: nav_link_class(waf_policies_path) %>
<%= link_to "🌐 Network Ranges", network_ranges_path,
class: nav_link_class(network_ranges_path) %>
@@ -157,6 +159,8 @@
class: mobile_nav_link_class(events_path) %>
<%= link_to "⚙️ Rules", rules_path,
class: mobile_nav_link_class(rules_path) %>
<%= link_to "🛡️ WAF Policies", waf_policies_path,
class: mobile_nav_link_class(waf_policies_path) %>
<%= link_to "🌐 Network Ranges", network_ranges_path,
class: mobile_nav_link_class(network_ranges_path) %>

View File

@@ -22,6 +22,14 @@
</nav>
<div class="mt-2 flex items-center space-x-3">
<h1 class="text-3xl font-bold text-gray-900"><%= @network_range.cidr %></h1>
<% if @network_range.virtual? %>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Virtual
</span>
<% end %>
<% if @network_range.ipv4? %>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">IPv4</span>
<% else %>
@@ -30,8 +38,12 @@
</div>
</div>
<div class="flex space-x-3">
<% if @network_range.virtual? %>
<%= link_to "Create Network", new_network_range_path(network: @network_range.cidr), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700" %>
<% else %>
<%= link_to "Edit", edit_network_range_path(@network_range), class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
<%= link_to "Create Rule", new_rule_path(network_range_id: @network_range.id), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
<% end %>
</div>
</div>
</div>
@@ -86,6 +98,7 @@
</div>
<% end %>
<% if @network_range.persisted? %>
<div>
<dt class="text-sm font-medium text-gray-500">Source</dt>
<dd class="mt-1 text-sm text-gray-900"><%= @network_range.source %></dd>
@@ -100,6 +113,17 @@
<dt class="text-sm font-medium text-gray-500">Updated</dt>
<dd class="mt-1 text-sm text-gray-900"><%= time_ago_in_words(@network_range.updated_at) %> ago</dd>
</div>
<% else %>
<div>
<dt class="text-sm font-medium text-gray-500">Status</dt>
<dd class="mt-1 text-sm text-gray-900">Virtual Network</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Events Found</dt>
<dd class="mt-1 text-sm text-gray-900"><%= @traffic_stats[:total_requests] %> requests</dd>
</div>
<% end %>
<!-- Classification Flags -->
<div class="md:col-span-2 lg:col-span-3">
@@ -200,16 +224,21 @@
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">Associated Rules (<%= @associated_rules.count %>)</h3>
<% if @network_range.persisted? %>
<button type="button" onclick="toggleQuickCreateRule()" class="inline-flex items-center px-3 py-1.5 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<svg class="w-4 h-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Quick Create Rule
</button>
<% else %>
<span class="text-sm text-gray-500">Create this network to add rules</span>
<% end %>
</div>
</div>
<!-- Quick Create Rule Form -->
<% if @network_range.persisted? %>
<div id="quick_create_rule" class="hidden border-b border-gray-200">
<div class="px-6 py-4 bg-blue-50">
<%= form_with(model: Rule.new, url: rules_path, local: true,
@@ -384,6 +413,7 @@
<% end %>
</div>
</div>
<% end %>
<!-- Rules List -->
<% if @associated_rules.any? %>
@@ -436,8 +466,13 @@
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<% if @network_range.virtual? %>
<h3 class="mt-2 text-sm font-medium text-gray-900">Virtual Network</h3>
<p class="mt-1 text-sm text-gray-500">Create this network range to add rules and manage it permanently.</p>
<% else %>
<h3 class="mt-2 text-sm font-medium text-gray-900">No rules yet</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating a rule for this network range.</p>
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,231 @@
<% content_for :title, "WAF Policies" %>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">WAF Policies</h1>
<p class="mt-2 text-gray-600">High-level firewall policies that automatically generate rules</p>
</div>
<div class="flex space-x-3">
<%= link_to "🌍 Block Countries", new_country_waf_policies_path,
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700" %>
<%= link_to "Create Policy", new_waf_policy_path,
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
</div>
</div>
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Policies</dt>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@waf_policies.count) %></dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Active Policies</dt>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@waf_policies.active.count) %></dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Generated Rules</dt>
<dd class="text-lg font-medium text-gray-900">
<%= number_with_delimiter(Rule.policy_generated.count) %>
</dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Deny Policies</dt>
<dd class="text-lg font-medium text-gray-900">
<%= number_with_delimiter(@waf_policies.where(action: 'deny').count) %>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Policies Table -->
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">Firewall Policies</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">
High-level policies that automatically generate specific WAF rules when matching network ranges are discovered.
</p>
</div>
<div class="border-t border-gray-200">
<% if @waf_policies.any? %>
<ul class="divide-y divide-gray-200">
<% @waf_policies.each do |policy| %>
<li class="hover:bg-gray-50">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<% if policy.country_policy? %>
<span class="text-2xl">🌍</span>
<% elsif policy.asn_policy? %>
<span class="text-2xl">🏢</span>
<% elsif policy.company_policy? %>
<span class="text-2xl">🏭</span>
<% elsif policy.network_type_policy? %>
<span class="text-2xl">🌐</span>
<% end %>
</div>
<div class="ml-4">
<div class="flex items-center">
<%= link_to policy.name, waf_policy_path(policy),
class: "text-sm font-medium text-gray-900 hover:text-blue-600" %>
<!-- Status Badge -->
<% if policy.active? %>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Active
</span>
<% else %>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Inactive
</span>
<% end %>
<!-- Action Badge -->
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
<%= case policy.action
when 'deny' then 'bg-red-100 text-red-800'
when 'allow' then 'bg-green-100 text-green-800'
when 'redirect' then 'bg-yellow-100 text-yellow-800'
when 'challenge' then 'bg-purple-100 text-purple-800'
end %>">
<%= policy.action.upcase %>
</span>
</div>
<div class="mt-1 text-sm text-gray-500">
<%= policy.policy_type.humanize %> policy targeting
<% if policy.targets.length > 3 %>
<%= policy.targets.length %> items
<% else %>
<%= policy.targets.join(', ') %>
<% end %>
• <%= policy.generated_rules_count %> rules generated
</div>
<% if policy.description.present? %>
<div class="mt-1 text-sm text-gray-600">
<%= policy.description %>
</div>
<% end %>
</div>
</div>
<div class="flex items-center space-x-2">
<%= link_to "View", waf_policy_path(policy),
class: "inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50" %>
<% if policy.active? %>
<%= link_to "Deactivate", deactivate_waf_policy_path(policy),
method: :post,
data: { confirm: "Are you sure you want to deactivate this policy?" },
class: "inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50" %>
<% else %>
<%= link_to "Activate", activate_waf_policy_path(policy),
method: :post,
class: "inline-flex items-center px-3 py-1 border border-transparent shadow-sm text-xs font-medium rounded text-white bg-green-600 hover:bg-green-700" %>
<% end %>
<%= link_to "Edit", edit_waf_policy_path(policy),
class: "inline-flex items-center px-3 py-1 border border-gray-300 shadow-sm text-xs font-medium rounded text-gray-700 bg-white hover:bg-gray-50" %>
</div>
</div>
</div>
</li>
<% end %>
</ul>
<% else %>
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No policies</h3>
<p class="mt-1 text-sm text-gray-500">Get started by creating your first WAF policy.</p>
<div class="mt-6">
<%= link_to "Create Policy", new_waf_policy_path,
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
</div>
</div>
<% end %>
</div>
</div>
<!-- Pagination -->
<% if @waf_policies.respond_to?(:total_pages) && @waf_policies.total_pages > 1 %>
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="flex-1 flex justify-between sm:hidden">
<%= link_to_previous_page @waf_policies, "Previous", class: "relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" %>
<%= link_to_next_page @waf_policies, "Next", class: "ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" %>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Showing
<span class="font-medium"><%= (@waf_policies.current_page - 1) * @waf_policies.limit_value + 1 %></span>
to
<span class="font-medium"><%= [@waf_policies.current_page * @waf_policies.limit_value, @waf_policies.total_count].min %></span>
of
<span class="font-medium"><%= number_with_delimiter(@waf_policies.total_count) %></span>
results
</p>
</div>
<div>
<%== pagy_nav(@pagy) %>
</div>
</div>
</div>
<% end %>
</div>

View File

@@ -38,6 +38,7 @@ Rails.application.routes.draw do
# Analytics dashboard
get "analytics", to: "analytics#index"
get "analytics/networks", to: "analytics#networks"
# Root path - analytics dashboard
root "analytics#index"
@@ -66,4 +67,16 @@ Rails.application.routes.draw do
post :enable
end
end
# WAF Policy management
resources :waf_policies, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
member do
post :activate
post :deactivate
end
collection do
get :new_country
post :create_country
end
end
end

View File

@@ -0,0 +1,6 @@
class RemoveGeoFieldsFromEvents < ActiveRecord::Migration[8.1]
def change
remove_column :events, :country_code, :string
remove_column :events, :city, :string
end
end

View File

@@ -0,0 +1,23 @@
class CreateWafPolicies < ActiveRecord::Migration[8.1]
def change
create_table :waf_policies do |t|
t.string :name, null: false
t.text :description
t.string :policy_type, null: false, default: 'country'
t.string :action, null: false, default: 'deny'
t.json :targets, default: []
t.boolean :enabled, default: true, null: false
t.datetime :expires_at
t.references :user, null: false, foreign_key: true
t.json :additional_data, default: {}
t.timestamps
end
# Add indexes for efficient policy matching
add_index :waf_policies, [:policy_type, :enabled], name: "idx_waf_policies_type_enabled"
add_index :waf_policies, :enabled
add_index :waf_policies, :expires_at
add_index :waf_policies, :name, unique: true
end
end

View File

@@ -0,0 +1,6 @@
class AddWafPolicyToRules < ActiveRecord::Migration[8.1]
def change
add_reference :rules, :waf_policy, null: true, foreign_key: true
add_index :rules, :waf_policy_id, name: "idx_rules_waf_policy"
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -27,8 +27,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
t.string "agent_name"
t.string "agent_version"
t.text "blocked_reason"
t.string "city"
t.string "country_code"
t.datetime "created_at", null: false
t.string "environment"
t.string "event_id", null: false
@@ -140,6 +138,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
t.string "source", limit: 100, default: "manual"
t.datetime "updated_at", null: false
t.bigint "user_id"
t.bigint "waf_policy_id"
t.index ["action"], name: "index_rules_on_action"
t.index ["enabled", "expires_at"], name: "idx_rules_active"
t.index ["enabled"], name: "index_rules_on_enabled"
@@ -151,6 +150,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
t.index ["source"], name: "index_rules_on_source"
t.index ["updated_at", "id"], name: "idx_rules_sync"
t.index ["user_id"], name: "index_rules_on_user_id"
t.index ["waf_policy_id"], name: "idx_rules_waf_policy"
t.index ["waf_policy_id"], name: "index_rules_on_waf_policy_id"
end
create_table "sessions", force: :cascade do |t|
@@ -171,9 +172,30 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_08_042936) do
t.index ["email_address"], name: "index_users_on_email_address", unique: true
end
create_table "waf_policies", force: :cascade do |t|
t.string "action", default: "deny", null: false
t.json "additional_data", default: {}
t.datetime "created_at", null: false
t.text "description"
t.boolean "enabled", default: true, null: false
t.datetime "expires_at"
t.string "name", null: false
t.string "policy_type", default: "country", null: false
t.json "targets", default: []
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["enabled"], name: "index_waf_policies_on_enabled"
t.index ["expires_at"], name: "index_waf_policies_on_expires_at"
t.index ["name"], name: "index_waf_policies_on_name", unique: true
t.index ["policy_type", "enabled"], name: "idx_waf_policies_type_enabled"
t.index ["user_id"], name: "index_waf_policies_on_user_id"
end
add_foreign_key "events", "request_hosts"
add_foreign_key "network_ranges", "users"
add_foreign_key "rules", "network_ranges"
add_foreign_key "rules", "users"
add_foreign_key "rules", "waf_policies"
add_foreign_key "sessions", "users"
add_foreign_key "waf_policies", "users"
end