Compare commits

3 Commits

Author SHA1 Message Date
Dan Milne
24dc355f56 Allow filtering the rules to make finding them easy 2026-01-18 23:38:07 +11:00
Dan Milne
e2b6db2f48 Fix geo rule re-enablement bug
When rules expire and are disabled by ExpiredRulesCleanupJob, the system
was unable to re-enable them due to unique index constraints. This caused
geo-based blocking to stop working in production.

Implemented find-or-update-or-create pattern in WafPolicy#create_rule_for_network_range:
- Re-enables disabled rules and sets new expiration (7 days)
- Extends expiration for enabled rules
- Creates new rules with 7-day TTL
- Handles race conditions gracefully

Added test coverage for all three scenarios.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-18 23:06:25 +11:00
Dan Milne
dad7874352 Version bump 2026-01-18 22:26:10 +11:00
6 changed files with 331 additions and 144 deletions

View File

@@ -10,9 +10,22 @@ class RulesController < ApplicationController
# GET /rules # GET /rules
def index def index
@pagy, @rules = pagy(policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc)) # Start with base scope
rules = policy_scope(Rule).includes(:user, :network_range)
# Apply status filter
rules = apply_status_filter(rules)
# Order by creation date (newest first)
rules = rules.order(created_at: :desc)
# Paginate results
@pagy, @rules = pagy(rules)
# Load filter options for view
@waf_rule_types = Rule.waf_rule_types @waf_rule_types = Rule.waf_rule_types
@waf_actions = Rule.waf_actions @waf_actions = Rule.waf_actions
@current_status = params[:status] || 'all'
end end
# GET /rules/new # GET /rules/new
@@ -117,6 +130,21 @@ class RulesController < ApplicationController
private private
def apply_status_filter(rules)
case params[:status]
when 'enabled'
rules.enabled
when 'disabled'
rules.disabled
when 'active'
rules.active
when 'expired'
rules.expired
else
rules # 'all' or no filter
end
end
def set_rule def set_rule
@rule = Rule.find(params[:id]) @rule = Rule.find(params[:id])
end end

View File

@@ -132,7 +132,7 @@ class Event < ApplicationRecord
# Use raw SQL to bypass serializer (it expects Array but we're comparing strings) # Use raw SQL to bypass serializer (it expects Array but we're comparing strings)
where("request_segment_ids = ? OR (request_segment_ids >= ? AND request_segment_ids < ?)", where("request_segment_ids = ? OR (request_segment_ids >= ? AND request_segment_ids < ?)",
prefix_str, lower_prefix_str, upper_str) prefix_str, lower_prefix_str, upper_str)
} }
# Path depth queries # Path depth queries
@@ -145,11 +145,11 @@ class Event < ApplicationRecord
} }
# Analytics: Get response time percentiles over different time windows # Analytics: Get response time percentiles over different time windows
def self.response_time_percentiles(windows: { hour: 1.hour, day: 1.day, week: 1.week }) def self.response_time_percentiles(windows: {hour: 1.hour, day: 1.day, week: 1.week})
results = {} results = {}
windows.each do |label, duration| windows.each do |label, duration|
scope = where('timestamp >= ?', duration.ago) scope = where("timestamp >= ?", duration.ago)
stats = scope.pick( stats = scope.pick(
Arel.sql(<<~SQL.squish) Arel.sql(<<~SQL.squish)
@@ -168,7 +168,7 @@ class Event < ApplicationRecord
count: stats[3] count: stats[3]
} }
else else
{ p50: nil, p95: nil, p99: nil, count: 0 } {p50: nil, p95: nil, p99: nil, count: 0}
end end
end end
@@ -184,7 +184,7 @@ class Event < ApplicationRecord
return request_path if request_segment_ids.blank? return request_path if request_segment_ids.blank?
segments = PathSegment.where(id: request_segment_ids).index_by(&:id) segments = PathSegment.where(id: request_segment_ids).index_by(&:id)
'/' + request_segment_ids.map { |id| segments[id]&.segment }.compact.join('/') "/" + request_segment_ids.map { |id| segments[id]&.segment }.compact.join("/")
end end
# Extract key fields from payload before saving # Extract key fields from payload before saving
@@ -370,19 +370,19 @@ class Event < ApplicationRecord
end end
def blocked? def blocked?
waf_action == 'deny' # deny = 0 waf_action == "deny" # deny = 0
end end
def allowed? def allowed?
waf_action == 'allow' # allow = 1 waf_action == "allow" # allow = 1
end end
def logged? def logged?
waf_action == 'log' waf_action == "log"
end end
def challenged? def challenged?
waf_action == 'challenge' waf_action == "challenge"
end end
def rule_matched? def rule_matched?
@@ -392,7 +392,7 @@ class Event < ApplicationRecord
# New path methods for normalization # New path methods for normalization
def path_segments def path_segments
return [] unless request_path.present? return [] unless request_path.present?
request_path.split('/').reject(&:blank?) request_path.split("/").reject(&:blank?)
end end
def path_segments_array def path_segments_array
@@ -401,7 +401,11 @@ class Event < ApplicationRecord
def request_hostname def request_hostname
return nil unless request_url.present? return nil unless request_url.present?
URI.parse(request_url).hostname rescue nil begin
URI.parse(request_url).hostname
rescue
nil
end
end end
# Tag helper methods # Tag helper methods
@@ -420,7 +424,7 @@ class Event < ApplicationRecord
end end
def tag_list def tag_list
tags.join(', ') tags.join(", ")
end end
# Normalize headers to lower case keys during import phase # Normalize headers to lower case keys during import phase
@@ -510,10 +514,10 @@ class Event < ApplicationRecord
# Find rules for those ranges, ordered by priority (most specific first) # Find rules for those ranges, ordered by priority (most specific first)
Rule.network_rules Rule.network_rules
.where(network_range_id: range_ids) .where(network_range_id: range_ids)
.enabled .enabled
.includes(:network_range) .includes(:network_range)
.order('masklen(network_ranges.network) DESC') .order("masklen(network_ranges.network) DESC")
end end
def active_blocking_rules def active_blocking_rules
@@ -586,9 +590,9 @@ class Event < ApplicationRecord
# Find most specific network range with actual GeoIP data # Find most specific network range with actual GeoIP data
# This might be more specific (e.g., /25) or broader (e.g., /22) than the /24 # This might be more specific (e.g., /25) or broader (e.g., /22) than the /24
data_range = NetworkRange.where("network >>= ?", ip_string) data_range = NetworkRange.where("network >>= ?", ip_string)
.where.not(country: nil) # Must have actual data .where.not(country: nil) # Must have actual data
.order(Arel.sql("masklen(network) DESC")) .order(Arel.sql("masklen(network) DESC"))
.first .first
# Use the most specific range with data, or fall back to tracking network # Use the most specific range with data, or fall back to tracking network
range = data_range || tracking_network range = data_range || tracking_network
@@ -645,9 +649,13 @@ class Event < ApplicationRecord
# Find or create the tracking network # Find or create the tracking network
NetworkRange.find_or_create_by!(network: network_cidr) do |nr| NetworkRange.find_or_create_by!(network: network_cidr) do |nr|
nr.source = 'auto_generated' nr.source = "auto_generated"
nr.creation_reason = 'tracking unit for IPAPI deduplication' nr.creation_reason = "tracking unit for IPAPI deduplication"
nr.is_datacenter = NetworkRangeGenerator.datacenter_ip?(ip_addr) rescue false nr.is_datacenter = begin
NetworkRangeGenerator.datacenter_ip?(ip_addr)
rescue
false
end
nr.is_vpn = false nr.is_vpn = false
nr.is_proxy = false nr.is_proxy = false
end end
@@ -663,17 +671,17 @@ class Event < ApplicationRecord
# Private and reserved ranges # Private and reserved ranges
[ [
IPAddr.new('10.0.0.0/8'), IPAddr.new("10.0.0.0/8"),
IPAddr.new('172.16.0.0/12'), IPAddr.new("172.16.0.0/12"),
IPAddr.new('192.168.0.0/16'), IPAddr.new("192.168.0.0/16"),
IPAddr.new('127.0.0.0/8'), IPAddr.new("127.0.0.0/8"),
IPAddr.new('169.254.0.0/16'), IPAddr.new("169.254.0.0/16"),
IPAddr.new('224.0.0.0/4'), IPAddr.new("224.0.0.0/4"),
IPAddr.new('240.0.0.0/4'), IPAddr.new("240.0.0.0/4"),
IPAddr.new('::1/128'), IPAddr.new("::1/128"),
IPAddr.new('fc00::/7'), IPAddr.new("fc00::/7"),
IPAddr.new('fe80::/10'), IPAddr.new("fe80::/10"),
IPAddr.new('ff00::/8') IPAddr.new("ff00::/8")
].any? { |range| range.include?(ip) } ].any? { |range| range.include?(ip) }
rescue IPAddr::InvalidAddressError rescue IPAddr::InvalidAddressError
true # Treat invalid IPs as "reserved" true # Treat invalid IPs as "reserved"
@@ -711,7 +719,6 @@ class Event < ApplicationRecord
self.server_name = payload["server_name"] self.server_name = payload["server_name"]
self.environment = payload["environment"] self.environment = payload["environment"]
# Extract agent info # Extract agent info
agent_data = payload.dig("agent") || {} agent_data = payload.dig("agent") || {}
self.agent_version = agent_data["version"] self.agent_version = agent_data["version"]
@@ -742,7 +749,7 @@ class Event < ApplicationRecord
detector = DeviceDetector.new(user_agent) detector = DeviceDetector.new(user_agent)
if detector.bot? if detector.bot?
# Add bot tag with specific bot name # Add bot tag with specific bot name
bot_name = detector.bot_name&.downcase&.gsub(/\s+/, '_') || 'unknown' bot_name = detector.bot_name&.downcase&.gsub(/\s+/, "_") || "unknown"
add_tag("bot:#{bot_name}") add_tag("bot:#{bot_name}")
return true return true
end end
@@ -756,23 +763,23 @@ class Event < ApplicationRecord
range = NetworkRange.find_by(id: network_range_id) range = NetworkRange.find_by(id: network_range_id)
if range if range
# Check if the network range source indicates a bot import # Check if the network range source indicates a bot import
if range.source&.start_with?('bot_import_') if range.source&.start_with?("bot_import_")
# Extract bot type from source (e.g., 'bot_import_googlebot' -> 'googlebot') # Extract bot type from source (e.g., 'bot_import_googlebot' -> 'googlebot')
bot_type = range.source.sub('bot_import_', '') bot_type = range.source.sub("bot_import_", "")
add_tag("bot:#{bot_type}") add_tag("bot:#{bot_type}")
add_tag("network:#{range.company&.downcase&.gsub(/\s+/, '_')}") if range.company.present? add_tag("network:#{range.company&.downcase&.gsub(/\s+/, "_")}") if range.company.present?
return true return true
end end
# Check if the company is a known bot provider (from bot imports) # Check if the company is a known bot provider (from bot imports)
# Common bot companies: Google, Amazon, OpenAI, Cloudflare, Microsoft, etc. # Common bot companies: Google, Amazon, OpenAI, Cloudflare, Microsoft, etc.
known_bot_companies = ['googlebot', 'google bot', 'amazon', 'aws', 'openai', known_bot_companies = ["googlebot", "google bot", "amazon", "aws", "openai",
'anthropic', 'cloudflare', 'microsoft', 'facebook', "anthropic", "cloudflare", "microsoft", "facebook",
'meta', 'apple', 'duckduckgo'] "meta", "apple", "duckduckgo"]
company_lower = company&.downcase company_lower = company&.downcase
if company_lower && known_bot_companies.any? { |bot| company_lower.include?(bot) } if company_lower && known_bot_companies.any? { |bot| company_lower.include?(bot) }
add_tag("bot:#{company_lower.gsub(/\s+/, '_')}") add_tag("bot:#{company_lower.gsub(/\s+/, "_")}")
add_tag("network:#{company_lower.gsub(/\s+/, '_')}") add_tag("network:#{company_lower.gsub(/\s+/, "_")}")
return true return true
end end
end end
@@ -784,7 +791,7 @@ class Event < ApplicationRecord
if is_datacenter && user_agent.present? if is_datacenter && user_agent.present?
# Generic/common bot user agents in datacenter networks # Generic/common bot user agents in datacenter networks
ua_lower = user_agent.downcase ua_lower = user_agent.downcase
bot_keywords = ['bot', 'crawler', 'spider', 'scraper', 'curl', 'wget', 'python', 'go-http-client'] bot_keywords = ["bot", "crawler", "spider", "scraper", "curl", "wget", "python", "go-http-client"]
if bot_keywords.any? { |keyword| ua_lower.include?(keyword) } if bot_keywords.any? { |keyword| ua_lower.include?(keyword) }
add_tag("bot:datacenter") add_tag("bot:datacenter")
add_tag("datacenter:true") add_tag("datacenter:true")

View File

@@ -159,49 +159,23 @@ validate :targets_must_be_array
return nil return nil
end end
# Try to create the rule, handling duplicates gracefully # Find existing rule (enabled or disabled)
begin existing_rule = Rule.find_by(
rule = Rule.create!( waf_rule_type: 'network',
waf_rule_type: 'network', waf_action: policy_action,
waf_action: policy_action.to_sym, network_range: network_range,
network_range: network_range, waf_policy: self,
waf_policy: self, source: "policy"
user: user, )
source: "policy",
metadata: build_rule_metadata(network_range), if existing_rule
priority: network_range.prefix_length # Re-enable disabled rules or extend expiration for enabled rules
) update_existing_rule(existing_rule)
rescue ActiveRecord::RecordNotUnique return existing_rule
# Rule already exists (created by another job or earlier in this job)
# Find and return the existing rule
Rails.logger.debug "Rule already exists for #{network_range.cidr} with policy #{name}"
return Rule.find_by(
waf_rule_type: 'network',
waf_action: policy_action,
network_range: network_range,
waf_policy: self,
source: "policy"
)
end end
# Handle redirect/challenge specific data # Create new rule
if redirect_action? && additional_data['redirect_url'] create_new_rule(network_range)
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 end
def create_rule_for_event(event) def create_rule_for_event(event)
@@ -451,6 +425,74 @@ validate :targets_must_be_array
end end
end end
def update_existing_rule(rule)
updates = { updated_at: Time.current }
# Re-enable if disabled
unless rule.enabled?
updates[:enabled] = true
Rails.logger.info "Re-enabling rule ##{rule.id} for #{rule.network_range.cidr}"
end
# Set/extend expiration to 7 days from now
# (Can be made configurable later via policy.rule_ttl field)
updates[:expires_at] = 7.days.from_now
rule.update!(updates) unless updates.empty?
end
def create_new_rule(network_range)
begin
rule = Rule.create!(
waf_rule_type: 'network',
waf_action: policy_action.to_sym,
network_range: network_range,
waf_policy: self,
user: user,
source: "policy",
metadata: build_rule_metadata(network_range),
priority: network_range.prefix_length,
expires_at: 7.days.from_now # Set expiration for new rules
)
rescue ActiveRecord::RecordNotUnique
# Race condition: rule created between find_by and create
# Retry by finding and updating
existing_rule = Rule.find_by(
waf_rule_type: 'network',
waf_action: policy_action,
network_range: network_range,
waf_policy: self,
source: "policy"
)
if existing_rule
update_existing_rule(existing_rule)
return existing_rule
else
raise # Re-raise if we still can't find it
end
end
# 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
# Matching logic for different policy types # Matching logic for different policy types
def matches_country?(network_range) def matches_country?(network_range)
country = network_range.country || network_range.inherited_intelligence[:country] country = network_range.country || network_range.inherited_intelligence[:country]

View File

@@ -12,79 +12,120 @@
</div> </div>
</div> </div>
<!-- Filter Bar -->
<div class="bg-white shadow rounded-lg p-4">
<div class="flex flex-wrap gap-2 items-center">
<span class="text-sm font-medium text-gray-700 mr-2">Filter by Status:</span>
<%= link_to "All", rules_path(status: 'all'),
class: "px-4 py-2 rounded-lg text-sm font-medium transition-colors #{@current_status == 'all' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" %>
<%= link_to "Active", rules_path(status: 'active'),
class: "px-4 py-2 rounded-lg text-sm font-medium transition-colors #{@current_status == 'active' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" %>
<%= link_to "Enabled", rules_path(status: 'enabled'),
class: "px-4 py-2 rounded-lg text-sm font-medium transition-colors #{@current_status == 'enabled' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" %>
<%= link_to "Disabled", rules_path(status: 'disabled'),
class: "px-4 py-2 rounded-lg text-sm font-medium transition-colors #{@current_status == 'disabled' ? 'bg-gray-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" %>
<%= link_to "Expired", rules_path(status: 'expired'),
class: "px-4 py-2 rounded-lg text-sm font-medium transition-colors #{@current_status == 'expired' ? 'bg-red-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}" %>
<% if @current_status != 'all' %>
<%= link_to rules_path, class: "ml-2 text-sm text-blue-600 hover:text-blue-800" do %>
<span class="inline-flex items-center">
<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="M6 18L18 6M6 6l12 12"></path>
</svg>
Clear filter
</span>
<% end %>
<% end %>
</div>
</div>
<!-- Statistics Cards --> <!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4"> <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white overflow-hidden shadow rounded-lg"> <%= link_to rules_path(status: 'all'), class: "block" do %>
<div class="p-5"> <div class="bg-white overflow-hidden shadow rounded-lg hover:shadow-lg transition-shadow">
<div class="flex items-center"> <div class="p-5">
<div class="flex-shrink-0"> <div class="flex items-center">
<svg class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div class="flex-shrink-0">
<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 class="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <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" />
</div> </svg>
<div class="ml-5 w-0 flex-1"> </div>
<dl> <div class="ml-5 w-0 flex-1">
<dt class="text-sm font-medium text-gray-500 truncate">Total Rules</dt> <dl>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.count) %></dd> <dt class="text-sm font-medium text-gray-500 truncate">Total Rules</dt>
</dl> <dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.count) %></dd>
</dl>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <% end %>
<div class="bg-white overflow-hidden shadow rounded-lg"> <%= link_to rules_path(status: 'active'), class: "block" do %>
<div class="p-5"> <div class="bg-white overflow-hidden shadow rounded-lg hover:shadow-lg transition-shadow">
<div class="flex items-center"> <div class="p-5">
<div class="flex-shrink-0"> <div class="flex items-center">
<svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div class="flex-shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <svg class="h-6 w-6 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</div> </svg>
<div class="ml-5 w-0 flex-1"> </div>
<dl> <div class="ml-5 w-0 flex-1">
<dt class="text-sm font-medium text-gray-500 truncate">Active Block Rules</dt> <dl>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.deny.active.count) %></dd> <dt class="text-sm font-medium text-gray-500 truncate">Active Block Rules</dt>
</dl> <dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.deny.active.count) %></dd>
</dl>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <% end %>
<div class="bg-white overflow-hidden shadow rounded-lg"> <%= link_to rules_path(status: 'disabled'), class: "block" do %>
<div class="p-5"> <div class="bg-white overflow-hidden shadow rounded-lg hover:shadow-lg transition-shadow">
<div class="flex items-center"> <div class="p-5">
<div class="flex-shrink-0"> <div class="flex items-center">
<svg class="h-6 w-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div class="flex-shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728" /> <svg class="h-6 w-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728L5.636 5.636m12.728 12.728L18.364 5.636M5.636 18.364l12.728-12.728" />
</div> </svg>
<div class="ml-5 w-0 flex-1"> </div>
<dl> <div class="ml-5 w-0 flex-1">
<dt class="text-sm font-medium text-gray-500 truncate">Disabled Rules</dt> <dl>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.where(enabled: false).count) %></dd> <dt class="text-sm font-medium text-gray-500 truncate">Disabled Rules</dt>
</dl> <dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.where(enabled: false).count) %></dd>
</dl>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <% end %>
<div class="bg-white overflow-hidden shadow rounded-lg"> <%= link_to rules_path(status: 'expired'), class: "block" do %>
<div class="p-5"> <div class="bg-white overflow-hidden shadow rounded-lg hover:shadow-lg transition-shadow">
<div class="flex items-center"> <div class="p-5">
<div class="flex-shrink-0"> <div class="flex items-center">
<svg class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <div class="flex-shrink-0">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> <svg class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</div> </svg>
<div class="ml-5 w-0 flex-1"> </div>
<dl> <div class="ml-5 w-0 flex-1">
<dt class="text-sm font-medium text-gray-500 truncate">Expired Rules</dt> <dl>
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.expired.count) %></dd> <dt class="text-sm font-medium text-gray-500 truncate">Expired Rules</dt>
</dl> <dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.expired.count) %></dd>
</dl>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <% end %>
</div> </div>
<!-- Rules List --> <!-- Rules List -->

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module BaffleHub module BaffleHub
VERSION = "0.4.0" VERSION = "0.6.2"
end end

View File

@@ -442,7 +442,7 @@ class WafPolicyTest < ActiveSupport::TestCase
assert_equal 0, @policy.generated_rules_count assert_equal 0, @policy.generated_rules_count
# Create some rules # Create some rules
network_range = NetworkRange.create!(ip_range: "192.168.1.0/24") network_range = NetworkRange.create!(cidr: "192.168.1.0/24", country: "BR")
@policy.create_rule_for_network_range(network_range) @policy.create_rule_for_network_range(network_range)
assert_equal 1, @policy.generated_rules_count assert_equal 1, @policy.generated_rules_count
@@ -461,6 +461,75 @@ class WafPolicyTest < ActiveSupport::TestCase
assert_equal 2, stats[:targets_count] assert_equal 2, stats[:targets_count]
end end
# Rule creation and re-enablement tests
test "create_rule_for_network_range re-enables disabled rule" do
@policy.save!
# Create a network range that matches the policy
network_range = NetworkRange.create!(
cidr: "192.168.1.0/24",
country: "BR"
)
# Create a rule via policy
rule = @policy.create_rule_for_network_range(network_range)
assert rule.enabled?, "Rule should be enabled initially"
assert rule.expires_at.present?, "Rule should have expiration"
# Disable it (simulating expiration cleanup)
rule.update!(enabled: false)
assert_not rule.enabled?, "Rule should be disabled"
# Try to create again - should re-enable
result = @policy.create_rule_for_network_range(network_range)
assert_equal rule.id, result.id, "Should return same rule"
assert result.reload.enabled?, "Rule should be re-enabled"
assert result.expires_at.present?, "Should have new expiration"
assert result.expires_at > Time.current, "Expiration should be in the future"
end
test "create_rule_for_network_range extends enabled rule expiration" do
@policy.save!
# Create a network range that matches the policy
network_range = NetworkRange.create!(
cidr: "192.168.1.0/24",
country: "BR"
)
# Create a rule with expiration
rule = @policy.create_rule_for_network_range(network_range)
original_expires_at = rule.expires_at
travel 3.days do
# Try to create again - should extend expiration
result = @policy.create_rule_for_network_range(network_range)
assert_equal rule.id, result.id, "Should return same rule"
assert result.reload.expires_at > original_expires_at, "Expiration should be extended"
assert_in_delta 7.days.from_now, result.expires_at, 5.seconds, "Expiration should be ~7 days from now"
end
end
test "create_rule_for_network_range creates new rule when none exists" do
@policy.save!
# Create a network range that matches the policy
network_range = NetworkRange.create!(
cidr: "192.168.1.0/24",
country: "BR"
)
rule = @policy.create_rule_for_network_range(network_range)
assert rule.persisted?, "Rule should be persisted"
assert rule.enabled?, "Rule should be enabled"
assert_equal network_range, rule.network_range
assert rule.expires_at.present?, "New rule should have expiration"
assert_in_delta 7.days.from_now, rule.expires_at, 5.seconds, "Expiration should be ~7 days from now"
end
# String representations # String representations
test "to_s returns name" do test "to_s returns name" do
assert_equal @policy.name, @policy.to_s assert_equal @policy.name, @policy.to_s