path-matching #1
7
Gemfile
7
Gemfile
@@ -63,6 +63,9 @@ gem "countries"
|
||||
# Authorization library
|
||||
gem "pundit"
|
||||
|
||||
# User agent parsing
|
||||
gem "device_detector"
|
||||
|
||||
group :development, :test do
|
||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
|
||||
@@ -87,3 +90,7 @@ group :test do
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
end
|
||||
|
||||
gem "sentry-rails", "~> 6.1"
|
||||
|
||||
gem "postgresql_cursor", "~> 0.6.9"
|
||||
|
||||
16
Gemfile.lock
16
Gemfile.lock
@@ -105,12 +105,15 @@ GEM
|
||||
xpath (~> 3.2)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.4)
|
||||
countries (8.0.4)
|
||||
unaccent (~> 0.3)
|
||||
crass (1.0.6)
|
||||
csv (3.3.5)
|
||||
date (3.5.0)
|
||||
debug (1.11.0)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
device_detector (1.1.3)
|
||||
dotenv (3.1.8)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
@@ -258,6 +261,8 @@ GEM
|
||||
pg (1.6.2-arm64-darwin)
|
||||
pg (1.6.2-x86_64-linux)
|
||||
pg (1.6.2-x86_64-linux-musl)
|
||||
postgresql_cursor (0.6.9)
|
||||
activerecord (>= 6.0)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
@@ -371,6 +376,12 @@ GEM
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (6.1.0)
|
||||
railties (>= 5.2.0)
|
||||
sentry-ruby (~> 6.1.0)
|
||||
sentry-ruby (6.1.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
solid_cable (3.0.12)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
@@ -430,6 +441,7 @@ GEM
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unaccent (0.4.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.1.0)
|
||||
@@ -473,7 +485,9 @@ DEPENDENCIES
|
||||
brakeman
|
||||
bundler-audit
|
||||
capybara
|
||||
countries
|
||||
debug
|
||||
device_detector
|
||||
httparty
|
||||
image_processing (~> 1.2)
|
||||
importmap-rails
|
||||
@@ -483,12 +497,14 @@ DEPENDENCIES
|
||||
openid_connect (~> 2.2)
|
||||
pagy
|
||||
pg (>= 1.1)
|
||||
postgresql_cursor (~> 0.6.9)
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
pundit
|
||||
rails (~> 8.1.1)
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
sentry-rails (~> 6.1)
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
|
||||
85
README.md
85
README.md
@@ -20,25 +20,98 @@ Baffle Hub provides intelligent Web Application Firewall (WAF) analytics with au
|
||||
- Basic analytics dashboard
|
||||
- Background job processing system
|
||||
- Docker deployment setup
|
||||
- Forward auth endpoint implementation ( see Baffle-agent )
|
||||
|
||||
### 🚧 In Progress
|
||||
- Rule management framework
|
||||
- IP range blocking rules
|
||||
- Country-based blocking (via IP ranges)
|
||||
- Forward auth endpoint implementation
|
||||
- Path based blocking
|
||||
- Rate limiting engine
|
||||
- Real-time rule updates ( 10 - 20 second )
|
||||
|
||||
### 📋 TODO
|
||||
- Advanced pattern analysis and threat detection
|
||||
- Automatic rule generation algorithms
|
||||
- Rate limiting engine
|
||||
- Challenge/redirect mechanisms
|
||||
- Unix socket support for ultra-low latency
|
||||
- Multi-node rule synchronization
|
||||
- Advanced analytics visualizations
|
||||
- Real-time rule updates
|
||||
|
||||
### Unlikely to Do
|
||||
- Complete OSWAP capabilities
|
||||
|
||||
## Quick Start
|
||||
|
||||
### With Docker
|
||||
|
||||
```yaml
|
||||
services:
|
||||
# PostgreSQL database
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
environment:
|
||||
POSTGRES_DB: baffle_hub_production
|
||||
POSTGRES_USER: baffle_hub
|
||||
POSTGRES_PASSWORD: ${BAFFLE_HUB_DATABASE_PASSWORD:-abcbafflehub123}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U baffle_hub -d baffle_hub_production"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Web instance
|
||||
web:
|
||||
image: git.booko.info/dkam/baffle-hub:v0.1.3-dev
|
||||
environment:
|
||||
RAILS_ENV: production
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
BAFFLE_HUB_DATABASE_PASSWORD: ${BAFFLE_HUB_DATABASE_PASSWORD:-bafflehub123}
|
||||
DATABASE_URL: postgres://baffle_hub:${BAFFLE_HUB_DATABASE_PASSWORD:-bafflehub123}@postgres:5432/baffle_hub_production
|
||||
# Disable Solid Queue in Puma for web instance
|
||||
SOLID_QUEUE_IN_PUMA: false
|
||||
BAFFLE_HOST: ${BAFFLE_HOST}
|
||||
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
|
||||
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
|
||||
OIDC_DISCOVERY_URL: ${OIDC_DISCOVERY_URL}
|
||||
ports:
|
||||
- "${HOST_IP}:3003:3000"
|
||||
volumes:
|
||||
- ./log:/app/log
|
||||
- ./tmp:/app/tmp
|
||||
- ./storage:/rails/storage
|
||||
# depends_on:
|
||||
# postgres:
|
||||
# condition: service_healthy
|
||||
restart: unless-stopped
|
||||
# command: bundle exec puma -C config/puma.rb
|
||||
|
||||
# Jobs instance (Solid Queue worker)
|
||||
jobs:
|
||||
image: git.booko.info/dkam/baffle-hub:v0.1.3-dev
|
||||
environment:
|
||||
RAILS_ENV: production
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
BAFFLE_HUB_DATABASE_PASSWORD: ${BAFFLE_HUB_DATABASE_PASSWORD:-bafflehub123}
|
||||
DATABASE_URL: postgres://baffle_hub:${BAFFLE_HUB_DATABASE_PASSWORD:-bafflehub123}@postgres:5432/baffle_hub_production
|
||||
volumes:
|
||||
- ./log:/app/log
|
||||
- ./tmp:/app/tmp
|
||||
- ./storage:/rails/storage
|
||||
# depends_on:
|
||||
# postgres:
|
||||
# condition: service_healthy
|
||||
restart: unless-stopped
|
||||
command: bin/jobs
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Ruby 3.x
|
||||
@@ -64,12 +137,6 @@ rails db:create db:migrate
|
||||
rails server
|
||||
```
|
||||
|
||||
### With Docker
|
||||
|
||||
```bash
|
||||
# Build and run
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* Consider organizing styles into separate files for maintainability.
|
||||
*/
|
||||
|
||||
@import "tom-select.css";
|
||||
|
||||
/* JSON Validator Styles */
|
||||
.json-valid {
|
||||
border-color: #10b981 !important;
|
||||
|
||||
@@ -11,14 +11,38 @@ class AnalyticsController < ApplicationController
|
||||
@time_period = params[:period]&.to_sym || :day
|
||||
@start_time = calculate_start_time(@time_period)
|
||||
|
||||
# Core statistics
|
||||
@total_events = Event.where("timestamp >= ?", @start_time).count
|
||||
@total_rules = Rule.enabled.count
|
||||
@network_ranges_with_events = NetworkRange.with_events.count
|
||||
@total_network_ranges = NetworkRange.count
|
||||
# Cache TTL based on time period
|
||||
cache_ttl = case @time_period
|
||||
when :hour then 5.minutes
|
||||
when :day then 1.hour
|
||||
when :week then 6.hours
|
||||
when :month then 12.hours
|
||||
else 1.hour
|
||||
end
|
||||
|
||||
# Event breakdown by action
|
||||
@event_breakdown = Event.where("timestamp >= ?", @start_time)
|
||||
# Cache key includes period and start_time (hour-aligned for consistency)
|
||||
cache_key_base = "analytics/#{@time_period}/#{@start_time.to_i}"
|
||||
|
||||
# Core statistics - cached
|
||||
@total_events = Rails.cache.fetch("#{cache_key_base}/total_events", expires_in: cache_ttl) do
|
||||
Event.where("timestamp >= ?", @start_time).count
|
||||
end
|
||||
|
||||
@total_rules = Rails.cache.fetch("analytics/total_rules", expires_in: 5.minutes) do
|
||||
Rule.enabled.count
|
||||
end
|
||||
|
||||
@network_ranges_with_events = Rails.cache.fetch("analytics/network_ranges_with_events", expires_in: 5.minutes) do
|
||||
NetworkRange.with_events.count
|
||||
end
|
||||
|
||||
@total_network_ranges = Rails.cache.fetch("analytics/total_network_ranges", expires_in: 5.minutes) do
|
||||
NetworkRange.count
|
||||
end
|
||||
|
||||
# Event breakdown by action - cached
|
||||
@event_breakdown = Rails.cache.fetch("#{cache_key_base}/event_breakdown", expires_in: cache_ttl) do
|
||||
Event.where("timestamp >= ?", @start_time)
|
||||
.group(:waf_action)
|
||||
.count
|
||||
.transform_keys do |action_id|
|
||||
@@ -30,45 +54,63 @@ class AnalyticsController < ApplicationController
|
||||
else 'unknown'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Top countries by event count
|
||||
@top_countries = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||
.where("timestamp >= ? AND network_ranges.country IS NOT NULL", @start_time)
|
||||
.group("network_ranges.country")
|
||||
# Top countries by event count - cached (now uses denormalized country column)
|
||||
@top_countries = Rails.cache.fetch("#{cache_key_base}/top_countries", expires_in: cache_ttl) do
|
||||
Event.where("timestamp >= ? AND country IS NOT NULL", @start_time)
|
||||
.group(:country)
|
||||
.count
|
||||
.sort_by { |_, count| -count }
|
||||
.first(10)
|
||||
end
|
||||
|
||||
# Top blocked IPs
|
||||
@top_blocked_ips = Event.where("timestamp >= ?", @start_time)
|
||||
# Top blocked IPs - cached
|
||||
@top_blocked_ips = Rails.cache.fetch("#{cache_key_base}/top_blocked_ips", expires_in: cache_ttl) do
|
||||
Event.where("timestamp >= ?", @start_time)
|
||||
.where(waf_action: 1) # deny action in enum
|
||||
.group(:ip_address)
|
||||
.count
|
||||
.sort_by { |_, count| -count }
|
||||
.first(10)
|
||||
end
|
||||
|
||||
# Network range intelligence breakdown
|
||||
@network_intelligence = {
|
||||
# Network range intelligence breakdown - cached
|
||||
@network_intelligence = Rails.cache.fetch("analytics/network_intelligence", expires_in: 10.minutes) do
|
||||
{
|
||||
datacenter_ranges: NetworkRange.datacenter.count,
|
||||
vpn_ranges: NetworkRange.vpn.count,
|
||||
proxy_ranges: NetworkRange.proxy.count,
|
||||
total_ranges: NetworkRange.count
|
||||
}
|
||||
end
|
||||
|
||||
# Recent activity
|
||||
@recent_events = Event.recent.limit(10)
|
||||
@recent_rules = Rule.order(created_at: :desc).limit(5)
|
||||
# Recent activity - minimal cache for freshness
|
||||
@recent_events = Rails.cache.fetch("analytics/recent_events", expires_in: 1.minute) do
|
||||
Event.recent.limit(10).to_a
|
||||
end
|
||||
|
||||
# System health indicators
|
||||
@system_health = {
|
||||
@recent_rules = Rails.cache.fetch("analytics/recent_rules", expires_in: 5.minutes) do
|
||||
Rule.order(created_at: :desc).limit(5).to_a
|
||||
end
|
||||
|
||||
# System health indicators - cached
|
||||
@system_health = Rails.cache.fetch("#{cache_key_base}/system_health", expires_in: cache_ttl) do
|
||||
{
|
||||
total_users: User.count,
|
||||
active_rules: Rule.enabled.count,
|
||||
disabled_rules: Rule.where(enabled: false).count,
|
||||
recent_errors: Event.where("timestamp >= ? AND waf_action = ?", @start_time, 1).count # 1 = deny
|
||||
}
|
||||
end
|
||||
|
||||
# Prepare data for charts
|
||||
@chart_data = prepare_chart_data
|
||||
# Job queue statistics - short cache for near real-time
|
||||
@job_statistics = Rails.cache.fetch("analytics/job_statistics", expires_in: 30.seconds) do
|
||||
calculate_job_statistics
|
||||
end
|
||||
|
||||
# Prepare data for charts - split caching for current vs historical data
|
||||
@chart_data = prepare_chart_data_with_split_cache(cache_key_base, cache_ttl)
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
@@ -83,38 +125,40 @@ class AnalyticsController < ApplicationController
|
||||
@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")
|
||||
# Top networks by request volume (using denormalized network_range_id)
|
||||
# Use a subquery approach to avoid PostgreSQL GROUP BY issues with network_ranges.*
|
||||
event_stats = Event.where("timestamp >= ?", @start_time)
|
||||
.where.not(network_range_id: nil)
|
||||
.group(:network_range_id)
|
||||
.select("network_range_id, COUNT(*) as event_count, COUNT(DISTINCT ip_address) as unique_ips")
|
||||
|
||||
# Join the stats back to NetworkRange to get full network details
|
||||
@top_networks = NetworkRange.joins("INNER JOIN (#{event_stats.to_sql}) stats ON stats.network_range_id = network_ranges.id")
|
||||
.select("network_ranges.*, stats.event_count, stats.unique_ips")
|
||||
.order("stats.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")
|
||||
# Company breakdown for top traffic sources (using denormalized company column)
|
||||
@top_companies = Event.where("timestamp >= ? AND company IS NOT NULL", @start_time)
|
||||
.group(:company)
|
||||
.select("company, COUNT(*) as event_count, COUNT(DISTINCT ip_address) as unique_ips, COUNT(DISTINCT network_range_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")
|
||||
# ASN breakdown (using denormalized asn columns)
|
||||
@top_asns = Event.where("timestamp >= ? AND asn IS NOT NULL", @start_time)
|
||||
.group(:asn, :asn_org)
|
||||
.select("asn, asn_org, COUNT(*) as event_count, COUNT(DISTINCT ip_address) as unique_ips")
|
||||
.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")
|
||||
# Geographic breakdown (using denormalized country column)
|
||||
@top_countries = Event.where("timestamp >= ? AND country IS NOT NULL", @start_time)
|
||||
.group(:country)
|
||||
.select("country, COUNT(*) as event_count, COUNT(DISTINCT ip_address) as unique_ips")
|
||||
.order("event_count DESC")
|
||||
.limit(15)
|
||||
|
||||
@@ -130,30 +174,99 @@ class AnalyticsController < ApplicationController
|
||||
private
|
||||
|
||||
def calculate_start_time(period)
|
||||
# Snap to hour/day boundaries for cacheability
|
||||
# Instead of rolling windows that change every second, use fixed boundaries
|
||||
case period
|
||||
when :hour
|
||||
1.hour.ago
|
||||
# Last complete hour: if it's 13:45, show 12:00-13:00
|
||||
1.hour.ago.beginning_of_hour
|
||||
when :day
|
||||
24.hours.ago
|
||||
# Last 24 complete hours from current hour boundary
|
||||
24.hours.ago.beginning_of_hour
|
||||
when :week
|
||||
1.week.ago
|
||||
# Last 7 complete days from today's start
|
||||
7.days.ago.beginning_of_day
|
||||
when :month
|
||||
1.month.ago
|
||||
# Last 30 complete days from today's start
|
||||
30.days.ago.beginning_of_day
|
||||
else
|
||||
24.hours.ago
|
||||
24.hours.ago.beginning_of_hour
|
||||
end
|
||||
end
|
||||
|
||||
def prepare_chart_data
|
||||
# Events over time (hourly buckets for last 24 hours)
|
||||
events_by_hour = Event.where("timestamp >= ?", 24.hours.ago)
|
||||
def prepare_chart_data_with_split_cache(cache_key_base, cache_ttl)
|
||||
# Split timeline into historical (completed hours) and current (incomplete hour)
|
||||
# Historical hours are cached for full TTL, current hour cached briefly for freshness
|
||||
|
||||
# Cache historical hours (1-23 hours ago) - these are complete and won't change
|
||||
# No expiration - will stick around until evicted by cache store
|
||||
historical_timeline = Rails.cache.fetch("#{cache_key_base}/chart_historical") do
|
||||
historical_start = 23.hours.ago.beginning_of_hour
|
||||
events_by_hour = Event.where("timestamp >= ? AND timestamp < ?", historical_start, Time.current.beginning_of_hour)
|
||||
.group("DATE_TRUNC('hour', timestamp)")
|
||||
.count
|
||||
|
||||
# Convert to chart format - keep everything in UTC for consistency
|
||||
(1..23).map do |hour_ago|
|
||||
hour_time = hour_ago.hours.ago.beginning_of_hour
|
||||
hour_key = hour_time.utc
|
||||
{
|
||||
time_iso: hour_time.iso8601,
|
||||
total: events_by_hour[hour_key] || 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Current hour (0 hours ago) - cache very briefly since it's actively accumulating
|
||||
current_hour_data = Rails.cache.fetch("#{cache_key_base}/chart_current_hour", expires_in: 1.minute) do
|
||||
hour_time = Time.current.beginning_of_hour
|
||||
count = Event.where("timestamp >= ?", hour_time).count
|
||||
{
|
||||
time_iso: hour_time.iso8601,
|
||||
total: count
|
||||
}
|
||||
end
|
||||
|
||||
# Combine current + historical for full 24-hour timeline
|
||||
timeline_data = [current_hour_data] + historical_timeline
|
||||
|
||||
# Action distribution and other chart data (cached with main cache)
|
||||
other_chart_data = Rails.cache.fetch("#{cache_key_base}/chart_metadata", expires_in: cache_ttl) do
|
||||
action_distribution = @event_breakdown.map do |action, count|
|
||||
{
|
||||
action: action.humanize,
|
||||
count: count,
|
||||
percentage: ((count.to_f / [@total_events, 1].max) * 100).round(1)
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
actions: action_distribution,
|
||||
countries: @top_countries.map { |country, count| { country: country, count: count } },
|
||||
network_types: [
|
||||
{ type: "Datacenter", count: @network_intelligence[:datacenter_ranges] },
|
||||
{ type: "VPN", count: @network_intelligence[:vpn_ranges] },
|
||||
{ type: "Proxy", count: @network_intelligence[:proxy_ranges] },
|
||||
{ type: "Standard", count: @network_intelligence[:total_ranges] - @network_intelligence[:datacenter_ranges] - @network_intelligence[:vpn_ranges] - @network_intelligence[:proxy_ranges] }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
# Merge timeline with other chart data
|
||||
other_chart_data.merge(timeline: timeline_data)
|
||||
end
|
||||
|
||||
def prepare_chart_data
|
||||
# Legacy method - kept for reference but no longer used
|
||||
# Events over time (hourly buckets) - use @start_time for consistency
|
||||
events_by_hour = Event.where("timestamp >= ?", @start_time)
|
||||
.group("DATE_TRUNC('hour', timestamp)")
|
||||
.count
|
||||
|
||||
# Convert to chart format - snap to hour boundaries for cacheability
|
||||
timeline_data = (0..23).map do |hour_ago|
|
||||
hour_time = hour_ago.hours.ago
|
||||
hour_key = hour_time.utc.beginning_of_hour
|
||||
# Use hour boundaries instead of rolling times
|
||||
hour_time = hour_ago.hours.ago.beginning_of_hour
|
||||
hour_key = hour_time.utc
|
||||
|
||||
{
|
||||
# Store as ISO string for JavaScript to handle timezone conversion
|
||||
@@ -185,51 +298,42 @@ class AnalyticsController < ApplicationController
|
||||
end
|
||||
|
||||
def calculate_network_type_stats(start_time)
|
||||
# Get all network types with their traffic statistics
|
||||
# Get all network types with their traffic statistics using denormalized columns
|
||||
network_types = [
|
||||
{ type: 'datacenter', label: 'Datacenter' },
|
||||
{ type: 'vpn', label: 'VPN' },
|
||||
{ type: 'proxy', label: 'Proxy' }
|
||||
{ type: 'datacenter', label: 'Datacenter', column: :is_datacenter },
|
||||
{ type: 'vpn', label: 'VPN', column: :is_vpn },
|
||||
{ type: 'proxy', label: 'Proxy', column: :is_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
|
||||
# Query events directly using denormalized flags
|
||||
event_stats = Event.where("timestamp >= ? AND #{network_type[:column]} = ?", start_time, true)
|
||||
.select("COUNT(*) as event_count, COUNT(DISTINCT ip_address) as unique_ips, COUNT(DISTINCT network_range_id) as network_count")
|
||||
.reorder(nil)
|
||||
.take
|
||||
|
||||
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
|
||||
networks: event_stats.network_count || 0,
|
||||
events: event_stats.event_count || 0,
|
||||
unique_ips: event_stats.unique_ips || 0,
|
||||
percentage: total_events > 0 ? ((event_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
|
||||
standard_stats = Event.where("timestamp >= ? AND is_datacenter = ? AND is_vpn = ? AND is_proxy = ?", start_time, false, false, false)
|
||||
.select("COUNT(*) as event_count, COUNT(DISTINCT ip_address) as unique_ips, COUNT(DISTINCT network_range_id) as network_count")
|
||||
.take
|
||||
|
||||
results['standard'] = {
|
||||
label: 'Standard',
|
||||
networks: standard_stats.network_count,
|
||||
events: standard_stats.event_count,
|
||||
unique_ips: standard_stats.unique_ips,
|
||||
networks: standard_stats.network_count || 0,
|
||||
events: standard_stats.event_count || 0,
|
||||
unique_ips: standard_stats.unique_ips || 0,
|
||||
percentage: total_events > 0 ? ((standard_stats.event_count.to_f / total_events) * 100).round(1) : 0
|
||||
}
|
||||
|
||||
@@ -239,51 +343,51 @@ class AnalyticsController < ApplicationController
|
||||
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 networks (top 1% by request count) - using denormalized network_range_id
|
||||
total_networks = Event.where("timestamp >= ? AND network_range_id IS NOT NULL", start_time)
|
||||
.distinct.count(:network_range_id)
|
||||
|
||||
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)
|
||||
if total_networks > 0
|
||||
avg_events_per_network = Event.where("timestamp >= ?", start_time).count / total_networks
|
||||
high_volume_networks = Event.where("timestamp >= ? AND network_range_id IS NOT NULL", start_time)
|
||||
.group(:network_range_id)
|
||||
.having("COUNT(*) > ?", avg_events_per_network * 5)
|
||||
.count
|
||||
|
||||
patterns[:high_volume] = {
|
||||
count: high_volume_networks.count,
|
||||
networks: high_volume_networks.keys
|
||||
}
|
||||
else
|
||||
patterns[:high_volume] = { count: 0, networks: [] }
|
||||
end
|
||||
|
||||
# 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
|
||||
# Networks with high deny rates (> 50% blocked requests) - using denormalized network_range_id
|
||||
high_deny_networks = Event.where("timestamp >= ? AND network_range_id IS NOT NULL", start_time)
|
||||
.group(:network_range_id)
|
||||
.select("network_range_id,
|
||||
COUNT(CASE WHEN waf_action = 1 THEN 1 END) as denied_count,
|
||||
COUNT(*) as total_count")
|
||||
.having("COUNT(CASE WHEN waf_action = 1 THEN 1 END)::float / COUNT(*) > 0.5")
|
||||
.having("COUNT(*) >= 10") # minimum threshold
|
||||
|
||||
patterns[:high_deny_rate] = {
|
||||
count: high_deny_networks.count,
|
||||
network_ids: high_deny_networks.map(&:id)
|
||||
count: high_deny_networks.length,
|
||||
network_ids: high_deny_networks.map(&:network_range_id)
|
||||
}
|
||||
|
||||
# Networks appearing as multiple subnets (potential botnets)
|
||||
company_subnets = NetworkRange.where("company IS NOT NULL")
|
||||
.where("timestamp >= ? OR timestamp IS NULL", start_time)
|
||||
# Companies appearing with multiple IPs (potential botnets) - using denormalized company column
|
||||
company_subnets = Event.where("timestamp >= ? AND company IS NOT NULL", start_time)
|
||||
.group(:company)
|
||||
.select(:company, "COUNT(DISTINCT network) as subnet_count")
|
||||
.having("COUNT(DISTINCT network) > 5")
|
||||
.order("subnet_count DESC")
|
||||
.select("company, COUNT(DISTINCT ip_address) as ip_count")
|
||||
.having("COUNT(DISTINCT ip_address) > 5")
|
||||
.order("ip_count DESC")
|
||||
.limit(10)
|
||||
|
||||
patterns[:distributed_companies] = company_subnets.map do |company|
|
||||
patterns[:distributed_companies] = company_subnets.map do |stat|
|
||||
{
|
||||
company: company.company,
|
||||
subnets: company.subnet_count
|
||||
company: stat.company,
|
||||
subnets: stat.ip_count
|
||||
}
|
||||
end
|
||||
|
||||
@@ -311,4 +415,46 @@ class AnalyticsController < ApplicationController
|
||||
suspicious_patterns: @suspicious_patterns
|
||||
}
|
||||
end
|
||||
|
||||
def calculate_job_statistics
|
||||
# Get job queue information from SolidQueue
|
||||
begin
|
||||
total_jobs = SolidQueue::Job.count
|
||||
pending_jobs = SolidQueue::Job.where(finished_at: nil).count
|
||||
recent_jobs = SolidQueue::Job.where('created_at > ?', 1.hour.ago).count
|
||||
|
||||
# Get jobs by queue name
|
||||
queue_breakdown = SolidQueue::Job.group(:queue_name).count
|
||||
|
||||
# Get recent job activity
|
||||
recent_enqueued = SolidQueue::Job.where('created_at > ?', 1.hour.ago).count
|
||||
|
||||
# Calculate health status
|
||||
health_status = if pending_jobs > 100
|
||||
'warning'
|
||||
elsif pending_jobs > 500
|
||||
'critical'
|
||||
else
|
||||
'healthy'
|
||||
end
|
||||
|
||||
{
|
||||
total_jobs: total_jobs,
|
||||
pending_jobs: pending_jobs,
|
||||
recent_enqueued: recent_enqueued,
|
||||
queue_breakdown: queue_breakdown,
|
||||
health_status: health_status
|
||||
}
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to calculate job statistics: #{e.message}"
|
||||
{
|
||||
total_jobs: 0,
|
||||
pending_jobs: 0,
|
||||
recent_enqueued: 0,
|
||||
queue_breakdown: {},
|
||||
health_status: 'error',
|
||||
error: e.message
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -46,8 +46,23 @@ class Api::EventsController < ApplicationController
|
||||
rules = Rule.active.sync_order
|
||||
end
|
||||
|
||||
response_data[:rules] = rules.map(&:to_agent_format)
|
||||
agent_rules = rules.map(&:to_agent_format)
|
||||
response_data[:rules] = agent_rules
|
||||
response_data[:rules_changed] = true
|
||||
|
||||
# Include path segments dictionary for path_pattern rules
|
||||
path_segment_ids = agent_rules
|
||||
.select { |r| r[:waf_rule_type] == 'path_pattern' }
|
||||
.flat_map { |r| r.dig(:conditions, :segment_ids) }
|
||||
.compact
|
||||
.uniq
|
||||
|
||||
if path_segment_ids.any?
|
||||
response_data[:path_segments] = PathSegment
|
||||
.where(id: path_segment_ids)
|
||||
.pluck(:id, :segment)
|
||||
.to_h
|
||||
end
|
||||
else
|
||||
response_data[:rules_changed] = false
|
||||
end
|
||||
|
||||
@@ -2,19 +2,12 @@
|
||||
|
||||
class DsnsController < ApplicationController
|
||||
before_action :require_authentication
|
||||
before_action :set_dsn, only: [:show, :edit, :update, :disable, :enable]
|
||||
before_action :set_dsn, only: [:show, :edit, :update, :disable, :enable, :destroy]
|
||||
before_action :authorize_dsn_management, except: [:index, :show]
|
||||
|
||||
# GET /dsns
|
||||
def index
|
||||
@dsns = policy_scope(Dsn).order(created_at: :desc)
|
||||
|
||||
# Generate environment DSNs using default DSN key or first enabled DSN
|
||||
default_dsn = Dsn.enabled.first
|
||||
if default_dsn
|
||||
@external_dsn = generate_external_dsn(default_dsn.key)
|
||||
@internal_dsn = generate_internal_dsn(default_dsn.key)
|
||||
end
|
||||
end
|
||||
|
||||
# GET /dsns/new
|
||||
@@ -64,6 +57,20 @@ class DsnsController < ApplicationController
|
||||
redirect_to @dsn, notice: 'DSN was enabled.'
|
||||
end
|
||||
|
||||
# DELETE /dsns/:id
|
||||
def destroy
|
||||
# Only allow deletion of disabled DSNs for safety
|
||||
if @dsn.enabled?
|
||||
redirect_to @dsn, alert: 'Cannot delete an enabled DSN. Please disable it first.'
|
||||
return
|
||||
end
|
||||
|
||||
dsn_name = @dsn.name
|
||||
@dsn.destroy
|
||||
|
||||
redirect_to dsns_path, notice: "DSN '#{dsn_name}' was successfully deleted."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_dsn
|
||||
@@ -78,18 +85,4 @@ class DsnsController < ApplicationController
|
||||
# Only allow admins to manage DSNs
|
||||
redirect_to root_path, alert: 'Access denied' unless Current.user&.admin?
|
||||
end
|
||||
|
||||
def generate_external_dsn(key)
|
||||
host = ENV.fetch("BAFFLE_HOST", "localhost:3000")
|
||||
protocol = host.include?("localhost") ? "http" : "https"
|
||||
"#{protocol}://#{key}@#{host}"
|
||||
end
|
||||
|
||||
def generate_internal_dsn(key)
|
||||
internal_host = ENV.fetch("BAFFLE_INTERNAL_HOST", nil)
|
||||
return nil unless internal_host.present?
|
||||
|
||||
protocol = "http" # Internal connections use HTTP
|
||||
"#{protocol}://#{key}@#{internal_host}"
|
||||
end
|
||||
end
|
||||
@@ -1,18 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EventsController < ApplicationController
|
||||
def show
|
||||
@event = Event.includes(:network_range).find(params[:id])
|
||||
|
||||
# Use denormalized network_range_id if available (much faster)
|
||||
@network_range = @event.network_range
|
||||
|
||||
# Fallback to IP lookup if network_range_id is missing
|
||||
unless @network_range
|
||||
@network_range = NetworkRange.contains_ip(@event.ip_address.to_s).first
|
||||
|
||||
# Auto-generate network range if no match found
|
||||
unless @network_range
|
||||
@network_range = NetworkRangeGenerator.find_or_create_for_ip(@event.ip_address)
|
||||
Rails.logger.debug "Auto-generated network range #{@network_range&.cidr} for IP #{@event.ip_address}" if @network_range
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def index
|
||||
@events = Event.order(timestamp: :desc)
|
||||
@events = Event.includes(:network_range, :rule).order(timestamp: :desc)
|
||||
Rails.logger.debug "Found #{@events.count} total events"
|
||||
Rails.logger.debug "Action: #{params[:waf_action]}"
|
||||
|
||||
# Apply filters
|
||||
@events = @events.by_ip(params[:ip]) if params[:ip].present?
|
||||
@events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].present?
|
||||
@events = @events.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||
.where("network_ranges.country = ?", params[:country]) if params[:country].present?
|
||||
@events = @events.by_country(params[:country]) if params[:country].present?
|
||||
@events = @events.where(rule_id: params[:rule_id]) if params[:rule_id].present?
|
||||
|
||||
# Network-based filters
|
||||
# Network-based filters (now using denormalized columns)
|
||||
@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?
|
||||
@@ -26,24 +44,10 @@ 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
|
||||
# Network ranges are now preloaded via includes(:network_range)
|
||||
# The denormalized network_range_id makes this much faster than IP containment lookups
|
||||
|
||||
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
|
||||
@@ -46,24 +46,55 @@ class NetworkRangesController < ApplicationController
|
||||
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)
|
||||
# Real network - use direct IP containment for consistency with stats
|
||||
events_scope = Event.where("ip_address <<= ?", @network_range.cidr).recent
|
||||
else
|
||||
# Virtual network - find events by IP range containment
|
||||
@related_events = Event.where("ip_address <<= ?::inet", @network_range.to_s)
|
||||
.recent
|
||||
.limit(100)
|
||||
events_scope = Event.where("ip_address <<= ?::inet", @network_range.to_s).recent
|
||||
end
|
||||
|
||||
# Paginate events
|
||||
@events_pagy, @related_events = pagy(events_scope, items: 50)
|
||||
|
||||
@child_ranges = @network_range.child_ranges.limit(20)
|
||||
@parent_ranges = @network_range.parent_ranges.limit(10)
|
||||
@associated_rules = @network_range.persisted? ? @network_range.rules.includes(:user).order(created_at: :desc) : []
|
||||
|
||||
# Load rules from supernets and subnets
|
||||
@supernet_rules = @network_range.persisted? ? @network_range.supernet_rules.includes(:network_range, :user).limit(10) : []
|
||||
@subnet_rules = @network_range.persisted? ? @network_range.child_rules.includes(:network_range, :user).limit(20) : []
|
||||
|
||||
# Traffic analytics (if we have events)
|
||||
@traffic_stats = calculate_traffic_stats(@network_range)
|
||||
|
||||
# Check if we have IPAPI data (or if parent has it)
|
||||
@has_ipapi_data = @network_range.has_network_data_from?(:ipapi)
|
||||
@parent_with_ipapi = nil
|
||||
|
||||
unless @has_ipapi_data
|
||||
# Check if parent has IPAPI data
|
||||
parent = @network_range.parent_with_intelligence
|
||||
if parent&.has_network_data_from?(:ipapi)
|
||||
@parent_with_ipapi = parent
|
||||
@has_ipapi_data = true
|
||||
end
|
||||
end
|
||||
|
||||
# If we don't have IPAPI data anywhere and no parent has it, queue fetch job
|
||||
if @network_range.persisted? && @network_range.should_fetch_ipapi_data?
|
||||
@network_range.mark_as_fetching_api_data!(:ipapi)
|
||||
FetchIpapiDataJob.perform_later(network_range_id: @network_range.id)
|
||||
@ipapi_loading = true
|
||||
end
|
||||
|
||||
# Get IPAPI data for display
|
||||
@ipapi_data = if @parent_with_ipapi
|
||||
@parent_with_ipapi.network_data_for(:ipapi)
|
||||
elsif @network_range.has_network_data_from?(:ipapi)
|
||||
@network_range.network_data_for(:ipapi)
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# GET /network_ranges/new
|
||||
@@ -214,18 +245,27 @@ class NetworkRangesController < ApplicationController
|
||||
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)
|
||||
.limit(1000) # Limit the sample for performance
|
||||
# Base query for consistent IP containment logic
|
||||
base_query = Event.where("ip_address <<= ?", network_range.cidr)
|
||||
|
||||
# Use separate queries: one for grouping (without ordering), one for recent activity (with ordering)
|
||||
events_for_grouping = base_query.limit(1000)
|
||||
events_for_activity = base_query.recent.limit(20)
|
||||
|
||||
# Calculate counts properly - use consistent base_query for all counts
|
||||
total_requests = base_query.count
|
||||
unique_ips = base_query.except(:order).distinct.count(:ip_address)
|
||||
blocked_requests = base_query.blocked.count
|
||||
allowed_requests = base_query.allowed.count
|
||||
|
||||
{
|
||||
total_requests: network_range.events_count, # Use cached count
|
||||
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)
|
||||
total_requests: total_requests,
|
||||
unique_ips: unique_ips,
|
||||
blocked_requests: blocked_requests,
|
||||
allowed_requests: allowed_requests,
|
||||
top_paths: events_for_grouping.group(:request_path).count.sort_by { |_, count| -count }.first(10),
|
||||
top_user_agents: events_for_grouping.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
|
||||
recent_activity: events_for_activity
|
||||
}
|
||||
else
|
||||
# No events - return empty stats
|
||||
@@ -241,20 +281,35 @@ class NetworkRangesController < ApplicationController
|
||||
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
|
||||
base_query = Event.where("ip_address <<= ?", network_range.cidr)
|
||||
total_events = base_query.count
|
||||
|
||||
total_events = Event.where("ip_address <<= ?::inet", network_range.to_s).count
|
||||
if total_events > 0
|
||||
# Use separate queries: one for grouping (without ordering), one for recent activity (with ordering)
|
||||
events_for_grouping = base_query.limit(1000)
|
||||
events_for_activity = base_query.recent.limit(20)
|
||||
|
||||
{
|
||||
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)
|
||||
unique_ips: base_query.except(:order).distinct.count(:ip_address),
|
||||
blocked_requests: base_query.blocked.count,
|
||||
allowed_requests: base_query.allowed.count,
|
||||
top_paths: events_for_grouping.group(:request_path).count.sort_by { |_, count| -count }.first(10),
|
||||
top_user_agents: events_for_grouping.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
|
||||
recent_activity: events_for_activity
|
||||
}
|
||||
else
|
||||
# No events for virtual network
|
||||
{
|
||||
total_requests: 0,
|
||||
unique_ips: 0,
|
||||
blocked_requests: 0,
|
||||
allowed_requests: 0,
|
||||
top_paths: {},
|
||||
top_user_agents: {},
|
||||
recent_activity: []
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,8 +11,8 @@ class RulesController < ApplicationController
|
||||
# GET /rules
|
||||
def index
|
||||
@pagy, @rules = pagy(policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc))
|
||||
@rule_types = Rule::RULE_TYPES
|
||||
@actions = Rule::ACTIONS
|
||||
@waf_rule_types = Rule.waf_rule_types
|
||||
@waf_actions = Rule.waf_actions
|
||||
end
|
||||
|
||||
# GET /rules/new
|
||||
@@ -27,11 +27,11 @@ class RulesController < ApplicationController
|
||||
end
|
||||
|
||||
if params[:cidr].present?
|
||||
@rule.rule_type = 'network'
|
||||
@rule.waf_rule_type = 'network'
|
||||
end
|
||||
|
||||
@rule_types = Rule::RULE_TYPES
|
||||
@actions = Rule::ACTIONS
|
||||
@waf_rule_types = Rule.waf_rule_types
|
||||
@waf_actions = Rule.waf_actions
|
||||
end
|
||||
|
||||
# POST /rules
|
||||
@@ -39,8 +39,8 @@ class RulesController < ApplicationController
|
||||
authorize Rule
|
||||
@rule = Rule.new(rule_params)
|
||||
@rule.user = Current.user
|
||||
@rule_types = Rule::RULE_TYPES
|
||||
@actions = Rule::ACTIONS
|
||||
@waf_rule_types = Rule.waf_rule_types
|
||||
@waf_actions = Rule.waf_actions
|
||||
|
||||
# Process additional form data for quick create
|
||||
process_quick_create_parameters
|
||||
@@ -79,16 +79,26 @@ class RulesController < ApplicationController
|
||||
# GET /rules/:id/edit
|
||||
def edit
|
||||
authorize @rule
|
||||
@rule_types = Rule::RULE_TYPES
|
||||
@actions = Rule::ACTIONS
|
||||
@waf_rule_types = Rule.waf_rule_types
|
||||
@waf_actions = Rule.waf_actions
|
||||
end
|
||||
|
||||
# PATCH/PUT /rules/:id
|
||||
def update
|
||||
authorize @rule
|
||||
|
||||
# Preserve original attributes in case validation fails
|
||||
original_attributes = @rule.attributes.dup
|
||||
original_network_range_id = @rule.network_range_id
|
||||
|
||||
if @rule.update(rule_params)
|
||||
redirect_to @rule, notice: 'Rule was successfully updated.'
|
||||
else
|
||||
# Restore original attributes to preserve form state
|
||||
# This prevents network range dropdown from resetting
|
||||
@rule.attributes = original_attributes
|
||||
@rule.network_range_id = original_network_range_id
|
||||
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -116,8 +126,8 @@ class RulesController < ApplicationController
|
||||
|
||||
def rule_params
|
||||
permitted = [
|
||||
:rule_type,
|
||||
:action,
|
||||
:waf_rule_type,
|
||||
:waf_action,
|
||||
:metadata,
|
||||
:expires_at,
|
||||
:enabled,
|
||||
@@ -126,7 +136,7 @@ class RulesController < ApplicationController
|
||||
]
|
||||
|
||||
# Only include conditions for non-network rules
|
||||
if params[:rule][:rule_type] != 'network'
|
||||
if params[:rule][:waf_rule_type] != 'network'
|
||||
permitted << :conditions
|
||||
end
|
||||
|
||||
@@ -136,7 +146,7 @@ end
|
||||
def calculate_rule_priority
|
||||
return unless @rule
|
||||
|
||||
case @rule.rule_type
|
||||
case @rule.waf_rule_type
|
||||
when 'network'
|
||||
# For network rules, priority based on prefix specificity
|
||||
if @rule.network_range
|
||||
@@ -167,20 +177,10 @@ def calculate_rule_priority
|
||||
else
|
||||
@rule.priority = 100 # Default for network rules without range
|
||||
end
|
||||
when 'protocol_violation'
|
||||
@rule.priority = 95
|
||||
when 'method_enforcement'
|
||||
@rule.priority = 90
|
||||
when 'path_pattern'
|
||||
@rule.priority = 85
|
||||
when 'header_pattern', 'query_pattern'
|
||||
@rule.priority = 80
|
||||
when 'body_signature'
|
||||
@rule.priority = 75
|
||||
when 'rate_limit'
|
||||
@rule.priority = 70
|
||||
when 'composite'
|
||||
@rule.priority = 65
|
||||
else
|
||||
@rule.priority = 50 # Default priority
|
||||
end
|
||||
@@ -189,6 +189,38 @@ end
|
||||
def process_quick_create_parameters
|
||||
return unless @rule
|
||||
|
||||
# Handle path pattern parameters
|
||||
if @rule.path_pattern_rule? && params[:path_pattern].present? && params[:match_type].present?
|
||||
begin
|
||||
pattern = params[:path_pattern]
|
||||
match_type = params[:match_type]
|
||||
|
||||
# Parse pattern to segments
|
||||
segments = pattern.split('/').reject(&:blank?).map(&:downcase)
|
||||
|
||||
# Find or create PathSegment entries
|
||||
segment_ids = segments.map do |seg|
|
||||
PathSegment.find_or_create_segment(seg).id
|
||||
end
|
||||
|
||||
# Set conditions with segment IDs and match type
|
||||
@rule.conditions = {
|
||||
segment_ids: segment_ids,
|
||||
match_type: match_type,
|
||||
original_pattern: pattern
|
||||
}
|
||||
|
||||
# Add to metadata for display
|
||||
@rule.metadata ||= {}
|
||||
@rule.metadata.merge!({
|
||||
segments: segments,
|
||||
pattern_display: "/" + segments.join("/")
|
||||
})
|
||||
rescue => e
|
||||
@rule.errors.add(:base, "Failed to create path pattern: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
# Handle rate limiting parameters
|
||||
if @rule.rate_limit_rule? && params[:rate_limit].present? && params[:rate_window].present?
|
||||
rate_limit_data = {
|
||||
@@ -203,7 +235,7 @@ def process_quick_create_parameters
|
||||
end
|
||||
|
||||
# Handle redirect URL
|
||||
if @rule.action == 'redirect' && params[:redirect_url].present?
|
||||
if @rule.redirect_action? && params[:redirect_url].present?
|
||||
@rule.metadata ||= {}
|
||||
if @rule.metadata.is_a?(String)
|
||||
begin
|
||||
@@ -227,6 +259,24 @@ def process_quick_create_parameters
|
||||
end
|
||||
end
|
||||
|
||||
# Handle expires_at parsing for text input
|
||||
if params.dig(:rule, :expires_at).present?
|
||||
expires_at_str = params[:rule][:expires_at].strip
|
||||
if expires_at_str.present?
|
||||
begin
|
||||
# Try to parse various datetime formats
|
||||
@rule.expires_at = DateTime.parse(expires_at_str)
|
||||
rescue ArgumentError
|
||||
# Try specific format
|
||||
begin
|
||||
@rule.expires_at = DateTime.strptime(expires_at_str, '%Y-%m-%d %H:%M')
|
||||
rescue ArgumentError
|
||||
@rule.errors.add(:expires_at, 'must be in format YYYY-MM-DD HH:MM')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Add reason to metadata if provided
|
||||
if params.dig(:rule, :metadata).present?
|
||||
if @rule.metadata.is_a?(Hash)
|
||||
@@ -245,8 +295,8 @@ end
|
||||
|
||||
def rule_params
|
||||
permitted = [
|
||||
:rule_type,
|
||||
:action,
|
||||
:waf_rule_type,
|
||||
:waf_action,
|
||||
:metadata,
|
||||
:expires_at,
|
||||
:enabled,
|
||||
@@ -255,7 +305,7 @@ end
|
||||
]
|
||||
|
||||
# Only include conditions for non-network rules
|
||||
if params[:rule][:rule_type] != 'network'
|
||||
if params[:rule][:waf_rule_type] != 'network'
|
||||
permitted << :conditions
|
||||
end
|
||||
|
||||
@@ -265,7 +315,7 @@ end
|
||||
def calculate_rule_priority
|
||||
return unless @rule
|
||||
|
||||
case @rule.rule_type
|
||||
case @rule.waf_rule_type
|
||||
when 'network'
|
||||
# For network rules, priority based on prefix specificity
|
||||
if @rule.network_range
|
||||
@@ -296,73 +346,12 @@ end
|
||||
else
|
||||
@rule.priority = 100 # Default for network rules without range
|
||||
end
|
||||
when 'protocol_violation'
|
||||
@rule.priority = 95
|
||||
when 'method_enforcement'
|
||||
@rule.priority = 90
|
||||
when 'path_pattern'
|
||||
@rule.priority = 85
|
||||
when 'header_pattern', 'query_pattern'
|
||||
@rule.priority = 80
|
||||
when 'body_signature'
|
||||
@rule.priority = 75
|
||||
when 'rate_limit'
|
||||
@rule.priority = 70
|
||||
when 'composite'
|
||||
@rule.priority = 65
|
||||
else
|
||||
@rule.priority = 50 # Default priority
|
||||
end
|
||||
end
|
||||
|
||||
def process_quick_create_parameters
|
||||
return unless @rule
|
||||
|
||||
# Handle rate limiting parameters
|
||||
if @rule.rate_limit_rule? && params[:rate_limit].present? && params[:rate_window].present?
|
||||
rate_limit_data = {
|
||||
limit: params[:rate_limit].to_i,
|
||||
window_seconds: params[:rate_window].to_i,
|
||||
scope: 'per_ip'
|
||||
}
|
||||
|
||||
# Update conditions with rate limit data
|
||||
@rule.conditions ||= {}
|
||||
@rule.conditions.merge!(rate_limit_data)
|
||||
end
|
||||
|
||||
# Handle redirect URL
|
||||
if @rule.action == 'redirect' && params[:redirect_url].present?
|
||||
@rule.metadata ||= {}
|
||||
if @rule.metadata.is_a?(String)
|
||||
begin
|
||||
@rule.metadata = JSON.parse(@rule.metadata)
|
||||
rescue JSON::ParserError
|
||||
@rule.metadata = {}
|
||||
end
|
||||
end
|
||||
@rule.metadata.merge!({
|
||||
redirect_url: params[:redirect_url],
|
||||
redirect_status: 302
|
||||
})
|
||||
end
|
||||
|
||||
# Parse metadata if it's a string that looks like JSON
|
||||
if @rule.metadata.is_a?(String) && @rule.metadata.starts_with?('{')
|
||||
begin
|
||||
@rule.metadata = JSON.parse(@rule.metadata)
|
||||
rescue JSON::ParserError
|
||||
# Keep as string if not valid JSON
|
||||
end
|
||||
end
|
||||
|
||||
# Add reason to metadata if provided
|
||||
if params.dig(:rule, :metadata).present?
|
||||
if @rule.metadata.is_a?(Hash)
|
||||
@rule.metadata['reason'] = params[:rule][:metadata]
|
||||
else
|
||||
@rule.metadata = { 'reason' => params[:rule][:metadata] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
31
app/controllers/settings_controller.rb
Normal file
31
app/controllers/settings_controller.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SettingsController < ApplicationController
|
||||
before_action :require_authentication
|
||||
before_action :authorize_settings_management
|
||||
|
||||
# GET /settings
|
||||
def index
|
||||
@settings = Setting.all.index_by(&:key)
|
||||
end
|
||||
|
||||
# PATCH /settings
|
||||
def update
|
||||
setting_key = params[:key]
|
||||
setting_value = params[:value]
|
||||
|
||||
if setting_key.present?
|
||||
Setting.set(setting_key, setting_value)
|
||||
redirect_to settings_path, notice: 'Setting was successfully updated.'
|
||||
else
|
||||
redirect_to settings_path, alert: 'Invalid setting key.'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authorize_settings_management
|
||||
# Only allow admins to manage settings
|
||||
redirect_to root_path, alert: 'Access denied' unless Current.user&.admin?
|
||||
end
|
||||
end
|
||||
@@ -24,7 +24,7 @@ class WafPoliciesController < ApplicationController
|
||||
|
||||
# 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.policy_action = params[:policy_action] if params[:policy_action].present?
|
||||
@waf_policy.targets = params[:targets] if params[:targets].present?
|
||||
end
|
||||
|
||||
@@ -37,9 +37,6 @@ class WafPoliciesController < ApplicationController
|
||||
@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
|
||||
@@ -64,11 +61,6 @@ class WafPoliciesController < ApplicationController
|
||||
@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
|
||||
@@ -89,9 +81,6 @@ class WafPoliciesController < ApplicationController
|
||||
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
|
||||
|
||||
@@ -105,7 +94,7 @@ class WafPoliciesController < ApplicationController
|
||||
# GET /waf_policies/new_country
|
||||
def new_country
|
||||
authorize WafPolicy
|
||||
@waf_policy = WafPolicy.new(policy_type: 'country', action: 'deny')
|
||||
@waf_policy = WafPolicy.new(policy_type: 'country', policy_action: 'deny')
|
||||
@policy_types = WafPolicy::POLICY_TYPES
|
||||
@actions = WafPolicy::ACTIONS
|
||||
end
|
||||
@@ -115,24 +104,28 @@ class WafPoliciesController < ApplicationController
|
||||
authorize WafPolicy
|
||||
|
||||
countries = params[:countries]&.reject(&:blank?) || []
|
||||
action = params[:action] || 'deny'
|
||||
policy_action = params[:policy_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,
|
||||
# Build the options hash with additional_data if present
|
||||
options = {
|
||||
policy_action: policy_action,
|
||||
user: Current.user,
|
||||
description: params[:description]
|
||||
)
|
||||
}
|
||||
|
||||
# Add additional_data if provided (for redirect/challenge actions)
|
||||
if params[:additional_data].present?
|
||||
options[:additional_data] = params[:additional_data].to_unsafe_hash
|
||||
end
|
||||
|
||||
@waf_policy = WafPolicy.create_country_policy(countries, **options)
|
||||
|
||||
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
|
||||
@@ -144,18 +137,30 @@ class WafPoliciesController < ApplicationController
|
||||
private
|
||||
|
||||
def set_waf_policy
|
||||
@waf_policy = WafPolicy.find(params[:id])
|
||||
# First try to find by ID (standard Rails behavior)
|
||||
if params[:id] =~ /^\d+$/
|
||||
@waf_policy = WafPolicy.find_by(id: params[:id])
|
||||
end
|
||||
|
||||
# If not found by ID, try to find by parameterized name
|
||||
unless @waf_policy
|
||||
# Try direct parameterized comparison by parameterizing existing policy names
|
||||
@waf_policy = WafPolicy.all.find { |policy| policy.to_param == params[:id] }
|
||||
end
|
||||
|
||||
if @waf_policy
|
||||
authorize @waf_policy
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
else
|
||||
redirect_to waf_policies_path, alert: 'WAF policy not found.'
|
||||
end
|
||||
end
|
||||
|
||||
def waf_policy_params
|
||||
params.require(:waf_policy).permit(
|
||||
:name,
|
||||
:description,
|
||||
:policy_type,
|
||||
:action,
|
||||
:policy_action,
|
||||
:enabled,
|
||||
:expires_at,
|
||||
targets: [],
|
||||
|
||||
@@ -89,4 +89,65 @@ module ApplicationHelper
|
||||
|
||||
raw html
|
||||
end
|
||||
|
||||
# Helper methods for job queue status colors
|
||||
def job_queue_status_color(status)
|
||||
case status.to_s
|
||||
when 'healthy'
|
||||
'bg-green-500'
|
||||
when 'warning'
|
||||
'bg-yellow-500'
|
||||
when 'critical'
|
||||
'bg-red-500'
|
||||
when 'error'
|
||||
'bg-gray-500'
|
||||
else
|
||||
'bg-blue-500'
|
||||
end
|
||||
end
|
||||
|
||||
def job_queue_status_text_color(status)
|
||||
case status.to_s
|
||||
when 'healthy'
|
||||
'text-green-600'
|
||||
when 'warning'
|
||||
'text-yellow-600'
|
||||
when 'critical'
|
||||
'text-red-600'
|
||||
when 'error'
|
||||
'text-gray-600'
|
||||
else
|
||||
'text-blue-600'
|
||||
end
|
||||
end
|
||||
|
||||
# Parse user agent string into readable components
|
||||
def parse_user_agent(user_agent)
|
||||
return nil if user_agent.blank?
|
||||
|
||||
client = DeviceDetector.new(user_agent)
|
||||
|
||||
{
|
||||
name: client.name,
|
||||
version: client.full_version,
|
||||
os_name: client.os_name,
|
||||
os_version: client.os_full_version,
|
||||
device_type: client.device_type || "desktop",
|
||||
device_name: client.device_name,
|
||||
bot: client.bot?,
|
||||
bot_name: client.bot_name,
|
||||
raw: user_agent
|
||||
}
|
||||
end
|
||||
|
||||
# Convert country code to flag emoji
|
||||
def country_flag(country_code)
|
||||
return "" if country_code.blank?
|
||||
|
||||
# Convert ISO 3166-1 alpha-2 country code to flag emoji
|
||||
# Each letter is converted to its regional indicator symbol
|
||||
country_code.upcase.chars.map { |c| (c.ord + 127397).chr(Encoding::UTF_8) }.join
|
||||
rescue
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,7 +8,9 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.startRefreshing()
|
||||
// TEMPORARILY DISABLED: Auto-refresh causes performance issues with slow queries (30s+ load times)
|
||||
// TODO: Re-enable after optimizing analytics queries
|
||||
// this.startRefreshing()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
|
||||
122
app/javascript/controllers/quick_create_rule_controller.js
Normal file
122
app/javascript/controllers/quick_create_rule_controller.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// QuickCreateRuleController - Handles the quick create rule form functionality
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["form", "toggle", "ruleTypeSelect", "actionSelect", "patternFields", "rateLimitFields", "redirectFields", "helpText", "conditionsField", "expiresAtField"]
|
||||
|
||||
connect() {
|
||||
console.log("QuickCreateRuleController connected")
|
||||
this.initializeFieldVisibility()
|
||||
}
|
||||
|
||||
toggle() {
|
||||
console.log("Toggle method called")
|
||||
console.log("Form target:", this.formTarget)
|
||||
|
||||
if (this.formTarget) {
|
||||
this.formTarget.classList.toggle("hidden")
|
||||
console.log("Toggled hidden class, now:", this.formTarget.classList.contains("hidden"))
|
||||
|
||||
if (this.formTarget.classList.contains("hidden")) {
|
||||
this.resetForm()
|
||||
} else {
|
||||
// Form is being shown, clear the expires_at field for Safari
|
||||
this.clearExpiresAtField()
|
||||
}
|
||||
} else {
|
||||
console.error("Form target not found!")
|
||||
}
|
||||
}
|
||||
|
||||
updateRuleTypeFields() {
|
||||
if (!this.hasRuleTypeSelectTarget || !this.hasActionSelectTarget) return
|
||||
|
||||
const ruleType = this.ruleTypeSelectTarget.value
|
||||
const action = this.actionSelectTarget.value
|
||||
|
||||
// Hide all optional fields
|
||||
this.hideOptionalFields()
|
||||
|
||||
// Show relevant fields based on rule type
|
||||
if (["path_pattern"].includes(ruleType)) {
|
||||
if (this.hasPatternFieldsTarget) {
|
||||
this.patternFieldsTarget.classList.remove("hidden")
|
||||
this.updatePatternHelpText(ruleType)
|
||||
}
|
||||
} else if (ruleType === "rate_limit") {
|
||||
if (this.hasRateLimitFieldsTarget) {
|
||||
this.rateLimitFieldsTarget.classList.remove("hidden")
|
||||
}
|
||||
}
|
||||
|
||||
// Show redirect fields if action is redirect
|
||||
if (action === "redirect") {
|
||||
if (this.hasRedirectFieldsTarget) {
|
||||
this.redirectFieldsTarget.classList.remove("hidden")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePatternHelpText(ruleType) {
|
||||
if (!this.hasHelpTextTarget || !this.hasConditionsFieldTarget) return
|
||||
|
||||
const helpTexts = {
|
||||
path_pattern: {
|
||||
text: "Regex pattern to match URL paths (e.g.,\\.env$|wp-admin|phpmyadmin)",
|
||||
placeholder: "Example: \\.env$|\\.git|config\\.php|wp-admin"
|
||||
}
|
||||
}
|
||||
|
||||
const config = helpTexts[ruleType]
|
||||
if (config) {
|
||||
this.helpTextTarget.textContent = config.text
|
||||
this.conditionsFieldTarget.placeholder = config.placeholder
|
||||
}
|
||||
}
|
||||
|
||||
hideOptionalFields() {
|
||||
if (this.hasPatternFieldsTarget) this.patternFieldsTarget.classList.add("hidden")
|
||||
if (this.hasRateLimitFieldsTarget) this.rateLimitFieldsTarget.classList.add("hidden")
|
||||
if (this.hasRedirectFieldsTarget) this.redirectFieldsTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
clearExpiresAtField() {
|
||||
// Clear the expires_at field - much simpler with text field
|
||||
if (this.hasExpiresAtFieldTarget) {
|
||||
this.expiresAtFieldTarget.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
if (this.formTarget) {
|
||||
// Find the actual form element within the form target div
|
||||
const formElement = this.formTarget.querySelector('form')
|
||||
if (formElement) {
|
||||
formElement.reset()
|
||||
|
||||
// Explicitly clear the expires_at field since browser reset might not clear datetime-local fields properly
|
||||
this.clearExpiresAtField()
|
||||
|
||||
// Reset rule type to default
|
||||
if (this.hasRuleTypeSelectTarget) {
|
||||
this.ruleTypeSelectTarget.value = "network"
|
||||
this.updateRuleTypeFields()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
setupEventListeners() {
|
||||
// Event listeners are handled via data-action attributes in the HTML
|
||||
// No manual event listeners needed
|
||||
}
|
||||
|
||||
initializeFieldVisibility() {
|
||||
// Initialize field visibility on page load
|
||||
if (this.hasRuleTypeSelectTarget) {
|
||||
this.updateRuleTypeFields()
|
||||
}
|
||||
}
|
||||
}
|
||||
55
app/javascript/controllers/waf_policy_form_controller.js
Normal file
55
app/javascript/controllers/waf_policy_form_controller.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class WafPolicyFormController extends Controller {
|
||||
static targets = ["policyTypeSelect", "policyActionSelect", "countryTargets", "asnTargets",
|
||||
"companyTargets", "networkTypeTargets", "redirectConfig", "challengeConfig"]
|
||||
|
||||
connect() {
|
||||
this.updateTargetsVisibility()
|
||||
this.updateActionConfig()
|
||||
}
|
||||
|
||||
updateTargetsVisibility() {
|
||||
const selectedType = this.policyTypeSelectTarget.value
|
||||
|
||||
// Hide all target sections
|
||||
this.countryTargetsTarget.classList.add('hidden')
|
||||
this.asnTargetsTarget.classList.add('hidden')
|
||||
this.companyTargetsTarget.classList.add('hidden')
|
||||
this.networkTypeTargetsTarget.classList.add('hidden')
|
||||
|
||||
// Show relevant target section
|
||||
switch(selectedType) {
|
||||
case 'country':
|
||||
this.countryTargetsTarget.classList.remove('hidden')
|
||||
break
|
||||
case 'asn':
|
||||
this.asnTargetsTarget.classList.remove('hidden')
|
||||
break
|
||||
case 'company':
|
||||
this.companyTargetsTarget.classList.remove('hidden')
|
||||
break
|
||||
case 'network_type':
|
||||
this.networkTypeTargetsTarget.classList.remove('hidden')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
updateActionConfig() {
|
||||
const selectedAction = this.policyActionSelectTarget.value
|
||||
|
||||
// Hide all config sections
|
||||
this.redirectConfigTarget.classList.add('hidden')
|
||||
this.challengeConfigTarget.classList.add('hidden')
|
||||
|
||||
// Show relevant config section
|
||||
switch(selectedAction) {
|
||||
case 'redirect':
|
||||
this.redirectConfigTarget.classList.remove('hidden')
|
||||
break
|
||||
case 'challenge':
|
||||
this.challengeConfigTarget.classList.remove('hidden')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
75
app/jobs/fetch_ipapi_data_job.rb
Normal file
75
app/jobs/fetch_ipapi_data_job.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
class FetchIpapiDataJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
# Fetches IPAPI enrichment data for a NetworkRange
|
||||
# @param network_range_id [Integer] ID of the tracking NetworkRange (usually /24)
|
||||
def perform(network_range_id:)
|
||||
tracking_network = NetworkRange.find_by(id: network_range_id)
|
||||
return unless tracking_network
|
||||
|
||||
# Use the network address (first IP in range) as the representative IP
|
||||
sample_ip = tracking_network.network_address.split('/').first
|
||||
|
||||
Rails.logger.info "Fetching IPAPI data for #{tracking_network.cidr} using IP #{sample_ip}"
|
||||
|
||||
ipapi_data = Ipapi.lookup(sample_ip)
|
||||
|
||||
if ipapi_data.present? && !ipapi_data.key?('error')
|
||||
# Check if IPAPI returned a different route than our tracking network
|
||||
ipapi_route = ipapi_data.dig('asn', 'route')
|
||||
target_network = tracking_network
|
||||
|
||||
if ipapi_route.present? && ipapi_route != tracking_network.cidr
|
||||
# IPAPI returned a different CIDR - find or create that network range
|
||||
Rails.logger.info "IPAPI returned different route: #{ipapi_route} (requested: #{tracking_network.cidr})"
|
||||
|
||||
target_network = NetworkRange.find_or_create_by(network: ipapi_route) do |nr|
|
||||
nr.source = 'api_imported'
|
||||
nr.creation_reason = "Created from IPAPI lookup for #{tracking_network.cidr}"
|
||||
end
|
||||
|
||||
Rails.logger.info "Storing IPAPI data on correct network: #{target_network.cidr}"
|
||||
end
|
||||
|
||||
# Store data on the target network (wherever IPAPI said it belongs)
|
||||
target_network.set_network_data(:ipapi, ipapi_data)
|
||||
target_network.last_api_fetch = Time.current
|
||||
target_network.save!
|
||||
|
||||
# Mark the tracking network as having been queried, with the CIDR that was returned
|
||||
tracking_network.mark_ipapi_queried!(target_network.cidr)
|
||||
|
||||
Rails.logger.info "Successfully fetched IPAPI data for #{tracking_network.cidr} (stored on #{target_network.cidr})"
|
||||
|
||||
# Broadcast to the tracking network
|
||||
broadcast_ipapi_update(tracking_network, ipapi_data)
|
||||
else
|
||||
Rails.logger.warn "IPAPI returned error for #{tracking_network.cidr}: #{ipapi_data}"
|
||||
# Still mark as queried to avoid retrying immediately
|
||||
tracking_network.mark_ipapi_queried!(tracking_network.cidr)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to fetch IPAPI data for network_range #{network_range_id}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
ensure
|
||||
# Always clear the fetching status when done
|
||||
tracking_network&.clear_fetching_status!(:ipapi)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def broadcast_ipapi_update(network_range, ipapi_data)
|
||||
# Broadcast to a stream specific to this network range
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
"network_range_#{network_range.id}",
|
||||
target: "ipapi_data_section",
|
||||
partial: "network_ranges/ipapi_data",
|
||||
locals: {
|
||||
ipapi_data: ipapi_data,
|
||||
network_range: network_range,
|
||||
parent_with_ipapi: nil,
|
||||
ipapi_loading: false
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,163 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class GenerateWafRulesJob < ApplicationJob
|
||||
queue_as :waf_rules
|
||||
|
||||
def perform(event_id:)
|
||||
event = Event.find(event_id)
|
||||
|
||||
# Only analyze blocked events for rule generation
|
||||
return unless event.blocked?
|
||||
|
||||
# Generate different types of rules based on patterns
|
||||
generate_ip_rules(event)
|
||||
generate_path_rules(event)
|
||||
generate_user_agent_rules(event)
|
||||
generate_parameter_rules(event)
|
||||
|
||||
# Broadcast rule updates globally
|
||||
ActionCable.server.broadcast("rules", { type: "refresh" })
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Error generating WAF rules: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_ip_rules(event)
|
||||
return unless event.ip_address.present?
|
||||
|
||||
# Check if this IP has multiple violations
|
||||
violation_count = Event
|
||||
.by_ip(event.ip_address)
|
||||
.blocked
|
||||
.where(timestamp: 24.hours.ago..Time.current)
|
||||
.count
|
||||
|
||||
# Log high-violation IPs - no automatic blocking without projects
|
||||
if violation_count >= 10
|
||||
Rails.logger.info "IP with high violation count: #{event.ip_address} (#{violation_count} violations in 24 hours)"
|
||||
end
|
||||
end
|
||||
|
||||
def generate_path_rules(event)
|
||||
return unless event.request_path.present?
|
||||
|
||||
# Look for repeated attack patterns on specific paths
|
||||
path_violations = project.events
|
||||
.where(request_path: event.request_path)
|
||||
.blocked
|
||||
.where(timestamp: 1.hour.ago..Time.current)
|
||||
.count
|
||||
|
||||
# Suggest path rules if 20+ violations on same path
|
||||
if path_violations >= 20
|
||||
suggest_path_rule(project, event.request_path, path_violations)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_user_agent_rules(event)
|
||||
return unless event.user_agent.present?
|
||||
|
||||
# Look for malicious user agents
|
||||
ua_violations = project.events
|
||||
.by_user_agent(event.user_agent)
|
||||
.blocked
|
||||
.where(timestamp: 1.hour.ago..Time.current)
|
||||
.count
|
||||
|
||||
# Suggest user agent rules if 15+ violations from same UA
|
||||
if ua_violations >= 15
|
||||
suggest_user_agent_rule(project, event.user_agent, ua_violations)
|
||||
end
|
||||
end
|
||||
|
||||
def generate_parameter_rules(event)
|
||||
params = event.query_params
|
||||
return unless params.present?
|
||||
|
||||
# Look for suspicious parameter patterns
|
||||
params.each do |key, value|
|
||||
next unless value.is_a?(String)
|
||||
|
||||
# Check for common attack patterns in parameter values
|
||||
if contains_attack_pattern?(value)
|
||||
param_violations = project.events
|
||||
.where("payload LIKE ?", "%#{key}%#{value}%")
|
||||
.blocked
|
||||
.where(timestamp: 6.hours.ago..Time.current)
|
||||
.count
|
||||
|
||||
if param_violations >= 5
|
||||
suggest_parameter_rule(project, key, value, param_violations)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def suggest_path_rule(project, path, violation_count)
|
||||
# Create an issue for manual review
|
||||
Issue.create!(
|
||||
project: project,
|
||||
title: "Suggested Path Rule",
|
||||
description: "Path '#{path}' has #{violation_count} violations in 1 hour",
|
||||
severity: "low",
|
||||
metadata: {
|
||||
type: "path_rule",
|
||||
path: path,
|
||||
violation_count: violation_count,
|
||||
suggested_action: "block"
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def suggest_user_agent_rule(project, user_agent, violation_count)
|
||||
# Create an issue for manual review
|
||||
Issue.create!(
|
||||
project: project,
|
||||
title: "Suggested User Agent Rule",
|
||||
description: "User Agent '#{user_agent}' has #{violation_count} violations in 1 hour",
|
||||
severity: "low",
|
||||
metadata: {
|
||||
type: "user_agent_rule",
|
||||
user_agent: user_agent,
|
||||
violation_count: violation_count,
|
||||
suggested_action: "block"
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def suggest_parameter_rule(project, param_name, param_value, violation_count)
|
||||
# Create an issue for manual review
|
||||
Issue.create!(
|
||||
project: project,
|
||||
title: "Suggested Parameter Rule",
|
||||
description: "Parameter '#{param_name}' with suspicious values has #{violation_count} violations",
|
||||
severity: "medium",
|
||||
metadata: {
|
||||
type: "parameter_rule",
|
||||
param_name: param_name,
|
||||
param_value: param_value,
|
||||
violation_count: violation_count,
|
||||
suggested_action: "block"
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def contains_attack_pattern?(value)
|
||||
# Common attack patterns
|
||||
attack_patterns = [
|
||||
/<script/i, # XSS
|
||||
/union.*select/i, # SQL injection
|
||||
/\.\./, # Directory traversal
|
||||
/\/etc\/passwd/i, # File inclusion
|
||||
/cmd\.exe/i, # Command injection
|
||||
/eval\(/i, # Code injection
|
||||
/javascript:/i, # JavaScript protocol
|
||||
/onload=/i, # Event handler injection
|
||||
]
|
||||
|
||||
attack_patterns.any? { |pattern| value.match?(pattern) }
|
||||
end
|
||||
end
|
||||
@@ -89,12 +89,13 @@ class GeoliteAsnImportJob < ApplicationJob
|
||||
temp_file.write(file.read)
|
||||
end
|
||||
|
||||
temp_file.close
|
||||
# Close but keep the file on disk (false prevents auto-deletion)
|
||||
temp_file.close(false)
|
||||
temp_file.path
|
||||
rescue => e
|
||||
Rails.logger.error "Error downloading file: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
temp_file&.close
|
||||
temp_file&.close(false)
|
||||
temp_file&.unlink
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -89,12 +89,13 @@ class GeoliteCountryImportJob < ApplicationJob
|
||||
temp_file.write(file.read)
|
||||
end
|
||||
|
||||
temp_file.close
|
||||
# Close but keep the file on disk (false prevents auto-deletion)
|
||||
temp_file.close(false)
|
||||
temp_file.path
|
||||
rescue => e
|
||||
Rails.logger.error "Error downloading file: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
temp_file&.close
|
||||
temp_file&.close(false)
|
||||
temp_file&.unlink
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class ProcessWafAnalyticsJob < ApplicationJob
|
||||
queue_as :waf_analytics
|
||||
|
||||
def perform(event_id:)
|
||||
event = Event.find(event_id)
|
||||
|
||||
# Analyze event patterns
|
||||
analyze_traffic_patterns(event)
|
||||
analyze_geographic_distribution(event)
|
||||
analyze_attack_vectors(event)
|
||||
|
||||
# Update global analytics cache
|
||||
update_analytics_cache
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Error processing WAF analytics: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def analyze_traffic_patterns(event)
|
||||
# Look for unusual traffic spikes
|
||||
recent_events = Event.where(timestamp: 5.minutes.ago..Time.current)
|
||||
|
||||
# Use a default threshold since we no longer have project-specific thresholds
|
||||
threshold = 1000 # Default threshold
|
||||
if recent_events.count > threshold * 5
|
||||
# High traffic detected - create an issue
|
||||
Issue.create!(
|
||||
title: "High Traffic Spike Detected",
|
||||
description: "Detected #{recent_events.count} requests in the last 5 minutes",
|
||||
severity: "medium",
|
||||
event_id: event.id,
|
||||
metadata: {
|
||||
event_count: recent_events.count,
|
||||
time_window: "5 minutes",
|
||||
threshold: threshold * 5
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_geographic_distribution(event)
|
||||
return unless event.has_geo_data?
|
||||
|
||||
country_code = event.lookup_country
|
||||
return unless country_code.present?
|
||||
|
||||
# Check if this country is unusual globally by joining through network ranges
|
||||
country_events = Event
|
||||
.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||
.where("network_ranges.country = ?", country_code)
|
||||
.where(timestamp: 1.hour.ago..Time.current)
|
||||
|
||||
# If this is the first event from this country or unusual spike
|
||||
if country_events.count == 1 || country_events.count > 100
|
||||
Rails.logger.info "Unusual geographic activity from #{country_code}"
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_attack_vectors(event)
|
||||
return unless event.blocked?
|
||||
|
||||
# Analyze common attack patterns
|
||||
analyze_ip_reputation(event)
|
||||
analyze_user_agent_patterns(event)
|
||||
analyze_path_attacks(event)
|
||||
end
|
||||
|
||||
def analyze_ip_reputation(event)
|
||||
return unless event.ip_address.present?
|
||||
|
||||
# Count recent blocks from this IP
|
||||
recent_blocks = Event
|
||||
.by_ip(event.ip_address)
|
||||
.blocked
|
||||
.where(timestamp: 1.hour.ago..Time.current)
|
||||
|
||||
if recent_blocks.count >= 5
|
||||
# Log IP reputation issue - no automatic IP blocking without projects
|
||||
Rails.logger.warn "IP with poor reputation detected: #{event.ip_address} (#{recent_blocks.count} blocks in 1 hour)"
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_user_agent_patterns(event)
|
||||
return unless event.user_agent.present?
|
||||
|
||||
# Look for common bot/user agent patterns
|
||||
suspicious_patterns = [
|
||||
/bot/i, /crawler/i, /spider/i, /scanner/i,
|
||||
/python/i, /curl/i, /wget/i, /nmap/i
|
||||
]
|
||||
|
||||
if suspicious_patterns.any? { |pattern| event.user_agent.match?(pattern) }
|
||||
# Log suspicious user agent for potential rule generation
|
||||
Rails.logger.info "Suspicious user agent detected: #{event.user_agent}"
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_path_attacks(event)
|
||||
return unless event.request_path.present?
|
||||
|
||||
# Look for common attack paths
|
||||
attack_patterns = [
|
||||
/\.\./, # Directory traversal
|
||||
/admin/i, # Admin panel access
|
||||
/wp-admin/i, # WordPress admin
|
||||
/\.php/i, # PHP files
|
||||
/union.*select/i, # SQL injection
|
||||
/script.*>/i # XSS attempts
|
||||
]
|
||||
|
||||
if attack_patterns.any? { |pattern| event.request_path.match?(pattern) }
|
||||
Rails.logger.info "Potential attack path detected: #{event.request_path}"
|
||||
end
|
||||
end
|
||||
|
||||
def update_analytics_cache
|
||||
# Update cached analytics for faster dashboard loading
|
||||
Rails.cache.delete("global_analytics")
|
||||
end
|
||||
end
|
||||
@@ -10,57 +10,83 @@ class ProcessWafEventJob < ApplicationJob
|
||||
if event_data.key?('events') && event_data['events'].is_a?(Array)
|
||||
# Multiple events in an array
|
||||
events_to_process = event_data['events']
|
||||
elsif event_data.key?('event_id')
|
||||
# Single event
|
||||
elsif event_data.key?('request_id') || event_data.key?('event_id') || event_data.key?('correlation_id')
|
||||
# Single event (support new and old field names)
|
||||
events_to_process = [event_data]
|
||||
else
|
||||
Rails.logger.warn "Invalid event data format: missing event_id or events array"
|
||||
Rails.logger.warn "Invalid event data format: missing request_id/event_id/correlation_id or events array"
|
||||
return
|
||||
end
|
||||
|
||||
events_to_process.each do |single_event_data|
|
||||
begin
|
||||
event_start = Time.current
|
||||
|
||||
# Generate unique event ID if not provided
|
||||
event_id = single_event_data['event_id'] || SecureRandom.uuid
|
||||
# Support both new (request_id) and old (event_id, correlation_id) field names during cutover
|
||||
request_id = single_event_data['request_id'] ||
|
||||
single_event_data['event_id'] ||
|
||||
single_event_data['correlation_id'] ||
|
||||
SecureRandom.uuid
|
||||
|
||||
# Skip if event already exists (duplicate in batch or retry)
|
||||
if Event.exists?(request_id: request_id)
|
||||
Rails.logger.debug "Skipping duplicate event #{request_id}"
|
||||
next
|
||||
end
|
||||
|
||||
# Create the WAF event record
|
||||
event = Event.create_from_waf_payload!(event_id, single_event_data)
|
||||
create_start = Time.current
|
||||
event = Event.create_from_waf_payload!(request_id, single_event_data)
|
||||
Rails.logger.debug "Event creation took #{((Time.current - create_start) * 1000).round(2)}ms"
|
||||
|
||||
# Log geo-location data status (uses NetworkRange delegation)
|
||||
if event.ip_address.present?
|
||||
# Process network intelligence and policies
|
||||
# Note: Event.before_save already created the /24 tracking network
|
||||
# and stored it in event.network_range_id
|
||||
if event.network_range_id.present?
|
||||
begin
|
||||
unless event.has_geo_data?
|
||||
Rails.logger.debug "No geo data available for event #{event.id} with IP #{event.ip_address}"
|
||||
network_start = Time.current
|
||||
# The tracking network was already created in Event.before_save
|
||||
tracking_network = event.network_range
|
||||
Rails.logger.debug "Using tracking network #{tracking_network.cidr} (created in before_save)"
|
||||
|
||||
# Queue IPAPI enrichment based on /24 tracking
|
||||
# The tracking network is the /24 that stores ipapi_queried_at
|
||||
if NetworkRange.should_fetch_ipapi_for_ip?(event.ip_address)
|
||||
# Use tracking network for fetch status to avoid race conditions
|
||||
if tracking_network.is_fetching_api_data?(:ipapi)
|
||||
Rails.logger.info "Skipping IPAPI fetch for #{tracking_network.cidr} - already being fetched"
|
||||
else
|
||||
tracking_network.mark_as_fetching_api_data!(:ipapi)
|
||||
Rails.logger.info "Queueing IPAPI fetch for IP #{event.ip_address} (tracking network: #{tracking_network.cidr})"
|
||||
FetchIpapiDataJob.perform_later(network_range_id: tracking_network.id)
|
||||
end
|
||||
else
|
||||
Rails.logger.debug "Skipping IPAPI fetch for IP #{event.ip_address} - already queried recently"
|
||||
end
|
||||
|
||||
# Evaluate WAF policies inline if needed (lazy evaluation)
|
||||
# Only runs when: network never evaluated OR policies changed since last evaluation
|
||||
if tracking_network.needs_policy_evaluation?
|
||||
policy_start = Time.current
|
||||
result = WafPolicyMatcher.evaluate_and_mark!(event)
|
||||
Rails.logger.debug "Policy evaluation took #{((Time.current - policy_start) * 1000).round(2)}ms"
|
||||
|
||||
if result[:generated_rules].any?
|
||||
Rails.logger.info "Generated #{result[:generated_rules].length} rules for event #{event.id} (network: #{tracking_network.cidr})"
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.debug "Network processing took #{((Time.current - network_start) * 1000).round(2)}ms"
|
||||
rescue => e
|
||||
Rails.logger.warn "Failed to check geo data for event #{event.id}: #{e.message}"
|
||||
Rails.logger.warn "Failed to process network range for event #{event.id}: #{e.message}"
|
||||
end
|
||||
elsif event.ip_address.present?
|
||||
Rails.logger.warn "Event #{event.id} has IP but no network_range_id (private IP?)"
|
||||
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)
|
||||
|
||||
# Check for automatic rule generation opportunities
|
||||
GenerateWafRulesJob.perform_later(event_id: event.id)
|
||||
|
||||
Rails.logger.info "Processed WAF event #{event_id}"
|
||||
total_time = ((Time.current - event_start) * 1000).round(2)
|
||||
Rails.logger.info "Processed WAF event #{request_id} in #{total_time}ms"
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
Rails.logger.error "Failed to create WAF event: #{e.message}"
|
||||
Rails.logger.error e.record.errors.full_messages.join(", ")
|
||||
@@ -70,9 +96,6 @@ class ProcessWafEventJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
# Broadcast real-time updates once per batch
|
||||
ActionCable.server.broadcast("events", { type: "refresh" })
|
||||
|
||||
Rails.logger.info "Processed #{events_to_process.count} WAF events"
|
||||
end
|
||||
end
|
||||
@@ -9,16 +9,20 @@ class ProcessWafPoliciesJob < ApplicationJob
|
||||
|
||||
retry_on StandardError, wait: 5.seconds, attempts: 3
|
||||
|
||||
def perform(network_range_id:, event_id: nil)
|
||||
# Find the network range
|
||||
network_range = NetworkRange.find_by(id: network_range_id)
|
||||
def perform(network_range:, event: nil)
|
||||
# network_range and event are passed as Global IDs and automatically deserialized
|
||||
return if network_range.nil?
|
||||
|
||||
Rails.logger.debug "Processing WAF policies for network range #{network_range.cidr}"
|
||||
|
||||
# Use WafPolicyMatcher to find and generate rules
|
||||
begin
|
||||
matcher = WafPolicyMatcher.new(network_range: network_range)
|
||||
result = matcher.match_and_generate_rules
|
||||
rescue => e
|
||||
Rails.logger.error "WafPolicyMatcher failed for network range #{network_range.cidr}: #{e.message}"
|
||||
result = { matching_policies: [], generated_rules: [] }
|
||||
end
|
||||
|
||||
# Log results
|
||||
if result[:matching_policies].any?
|
||||
@@ -33,7 +37,7 @@ class ProcessWafPoliciesJob < ApplicationJob
|
||||
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})"
|
||||
Rails.logger.info " - Rule: #{rule.waf_rule_type} #{rule.waf_action} for #{rule.network_range&.cidr} (ID: #{rule.id})"
|
||||
|
||||
# Log if this is a redirect or challenge rule
|
||||
if rule.redirect_action?
|
||||
@@ -42,21 +46,26 @@ class ProcessWafPoliciesJob < ApplicationJob
|
||||
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
|
||||
|
||||
# Mark network range as evaluated
|
||||
network_range.update_column(:policies_evaluated_at, Time.current)
|
||||
|
||||
# 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({
|
||||
# Handle potential nil payload or type issues
|
||||
current_payload = event.payload || {}
|
||||
|
||||
# Ensure payload is a hash before merging
|
||||
unless current_payload.is_a?(Hash)
|
||||
Rails.logger.warn "Event #{event.id} has invalid payload type: #{current_payload.class}, resetting to hash"
|
||||
current_payload = {}
|
||||
end
|
||||
|
||||
event.update!(payload: current_payload.merge({
|
||||
policy_matches: {
|
||||
matching_policies_count: result[:matching_policies].length,
|
||||
generated_rules_count: result[:generated_rules].length,
|
||||
@@ -65,12 +74,12 @@ class ProcessWafPoliciesJob < ApplicationJob
|
||||
}))
|
||||
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)
|
||||
network_range = NetworkRange.find_by(id: network_range_id)
|
||||
perform_later(network_range: network_range) if network_range
|
||||
end
|
||||
end
|
||||
|
||||
@@ -95,7 +104,7 @@ class ProcessWafPoliciesJob < ApplicationJob
|
||||
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)
|
||||
perform_later(network_range: network_range)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -12,14 +12,14 @@ class Dsn < ApplicationRecord
|
||||
|
||||
def full_dsn_url
|
||||
# Generate a complete DSN URL like Sentry does
|
||||
# Format: https://{key}@{domain}/api/events
|
||||
# Format: https://{key}@{domain}
|
||||
domain = ENV['BAFFLE_HOST'] ||
|
||||
Rails.application.config.action_mailer.default_url_options[:host] ||
|
||||
ENV['RAILS_HOST'] ||
|
||||
'localhost:3000'
|
||||
|
||||
protocol = Rails.env.development? ? 'http' : 'https'
|
||||
"#{protocol}://#{key}@#{domain}/api/events"
|
||||
"#{protocol}://#{key}@#{domain}"
|
||||
end
|
||||
|
||||
def api_endpoint_url
|
||||
@@ -30,7 +30,7 @@ class Dsn < ApplicationRecord
|
||||
'localhost:3000'
|
||||
|
||||
protocol = Rails.env.development? ? 'http' : 'https'
|
||||
"#{protocol}://#{domain}/api/events"
|
||||
"#{protocol}://#{domain}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -4,6 +4,10 @@ class Event < ApplicationRecord
|
||||
# Normalized association for hosts (most valuable compression)
|
||||
belongs_to :request_host, optional: true
|
||||
|
||||
# WAF rule associations
|
||||
belongs_to :rule, optional: true
|
||||
has_one :waf_policy, through: :rule
|
||||
|
||||
# Enums for fixed value sets
|
||||
enum :waf_action, {
|
||||
allow: 0, # allow/pass
|
||||
@@ -29,7 +33,7 @@ class Event < ApplicationRecord
|
||||
# This provides direct array access and efficient indexing
|
||||
attribute :tags, :json, default: -> { [] }
|
||||
|
||||
validates :event_id, presence: true, uniqueness: true
|
||||
validates :request_id, presence: true, uniqueness: true
|
||||
validates :timestamp, presence: true
|
||||
|
||||
scope :recent, -> { order(timestamp: :desc) }
|
||||
@@ -55,32 +59,42 @@ class Event < ApplicationRecord
|
||||
where("tags @> ARRAY[?]", tag_array)
|
||||
}
|
||||
|
||||
# Network-based filtering scopes
|
||||
# Network-based filtering scopes - now using denormalized columns
|
||||
scope :by_company, ->(company) {
|
||||
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||
.where("network_ranges.company ILIKE ?", "%#{company}%")
|
||||
where("company ILIKE ?", "%#{company}%")
|
||||
}
|
||||
|
||||
scope :by_country, ->(country) {
|
||||
where(country: country)
|
||||
}
|
||||
|
||||
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 }
|
||||
case type.to_s.downcase
|
||||
when "datacenter"
|
||||
where(is_datacenter: true)
|
||||
when "vpn"
|
||||
where(is_vpn: true)
|
||||
when "proxy"
|
||||
where(is_proxy: true)
|
||||
when "standard"
|
||||
where(is_datacenter: false, is_vpn: false, is_proxy: false)
|
||||
else
|
||||
none
|
||||
end
|
||||
}
|
||||
|
||||
scope :by_asn, ->(asn) {
|
||||
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||
.where("network_ranges.asn = ?", asn.to_i)
|
||||
where(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)
|
||||
# This still requires a join since we need to match CIDR
|
||||
joins(:network_range).where("network_ranges.network = ?", cidr)
|
||||
}
|
||||
|
||||
# Add association for the optional network_range_id
|
||||
belongs_to :network_range, optional: true
|
||||
|
||||
# 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,6 +126,37 @@ class Event < ApplicationRecord
|
||||
where("json_array_length(request_segment_ids) > ?", depth)
|
||||
}
|
||||
|
||||
# Analytics: Get response time percentiles over different time windows
|
||||
def self.response_time_percentiles(windows: { hour: 1.hour, day: 1.day, week: 1.week })
|
||||
results = {}
|
||||
|
||||
windows.each do |label, duration|
|
||||
scope = where('timestamp >= ?', duration.ago)
|
||||
|
||||
stats = scope.pick(
|
||||
Arel.sql(<<~SQL.squish)
|
||||
percentile_cont(0.5) WITHIN GROUP (ORDER BY response_time_ms) as p50,
|
||||
percentile_cont(0.95) WITHIN GROUP (ORDER BY response_time_ms) as p95,
|
||||
percentile_cont(0.99) WITHIN GROUP (ORDER BY response_time_ms) as p99,
|
||||
COUNT(*) as count
|
||||
SQL
|
||||
)
|
||||
|
||||
results[label] = if stats
|
||||
{
|
||||
p50: stats[0]&.round(2),
|
||||
p95: stats[1]&.round(2),
|
||||
p99: stats[2]&.round(2),
|
||||
count: stats[3]
|
||||
}
|
||||
else
|
||||
{ p50: nil, p95: nil, p99: nil, count: 0 }
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
# Helper methods
|
||||
def path_depth
|
||||
request_segment_ids&.length || 0
|
||||
@@ -130,13 +175,39 @@ class Event < ApplicationRecord
|
||||
# Normalize event fields after extraction
|
||||
after_validation :normalize_event_fields, if: :should_normalize?
|
||||
|
||||
def self.create_from_waf_payload!(event_id, payload)
|
||||
# Populate network intelligence from IP address
|
||||
before_save :populate_network_intelligence, if: :should_populate_network_intelligence?
|
||||
|
||||
# Backfill network intelligence for all events
|
||||
def self.backfill_network_intelligence!(batch_size: 10_000)
|
||||
total = where(country: nil).count
|
||||
return 0 if total.zero?
|
||||
|
||||
puts "Backfilling network intelligence for #{total} events..."
|
||||
processed = 0
|
||||
|
||||
where(country: nil).find_in_batches(batch_size: batch_size) do |batch|
|
||||
batch.each(&:save) # Triggers before_save callback
|
||||
processed += batch.size
|
||||
puts " Processed #{processed}/#{total} (#{(processed.to_f / total * 100).round(1)}%)"
|
||||
end
|
||||
|
||||
processed
|
||||
end
|
||||
|
||||
# Backfill network intelligence for a specific event
|
||||
def backfill_network_intelligence!
|
||||
populate_network_intelligence
|
||||
save!
|
||||
end
|
||||
|
||||
def self.create_from_waf_payload!(request_id, payload)
|
||||
# Normalize headers in payload during import phase
|
||||
normalized_payload = normalize_payload_headers(payload)
|
||||
|
||||
# Create the WAF request event
|
||||
create!(
|
||||
event_id: event_id,
|
||||
request_id: request_id,
|
||||
timestamp: parse_timestamp(normalized_payload["timestamp"]),
|
||||
payload: normalized_payload,
|
||||
|
||||
@@ -150,7 +221,8 @@ class Event < ApplicationRecord
|
||||
response_status: normalized_payload.dig("response", "status_code"),
|
||||
response_time_ms: normalized_payload.dig("response", "duration_ms"),
|
||||
waf_action: normalize_action(normalized_payload["waf_action"]), # Normalize incoming action values
|
||||
rule_matched: normalized_payload["rule_matched"],
|
||||
# Support both new (rule_id) and old (rule_matched) field names during cutover
|
||||
rule_id: normalized_payload["rule_id"] || normalized_payload["rule_matched"],
|
||||
blocked_reason: normalized_payload["blocked_reason"],
|
||||
|
||||
# Server/Environment info
|
||||
@@ -283,7 +355,7 @@ class Event < ApplicationRecord
|
||||
end
|
||||
|
||||
def rule_matched?
|
||||
rule_matched.present?
|
||||
rule_id.present?
|
||||
end
|
||||
|
||||
# New path methods for normalization
|
||||
@@ -343,40 +415,39 @@ class Event < ApplicationRecord
|
||||
end
|
||||
|
||||
def most_specific_range
|
||||
matching_network_ranges.first
|
||||
# Use the cached network_range_id if available (much faster)
|
||||
return NetworkRange.find_by(id: network_range_id) if network_range_id.present?
|
||||
|
||||
# Fallback to expensive lookup
|
||||
matching_network_ranges.first&.dig(:range)
|
||||
end
|
||||
|
||||
def broadest_range
|
||||
matching_network_ranges.last
|
||||
matching_network_ranges.last&.dig(:range)
|
||||
end
|
||||
|
||||
def network_intelligence
|
||||
most_specific_range&.dig(:intelligence) || {}
|
||||
# Use denormalized fields instead of expensive lookup
|
||||
{
|
||||
country: country,
|
||||
company: company,
|
||||
asn: asn,
|
||||
asn_org: asn_org,
|
||||
is_datacenter: is_datacenter,
|
||||
is_vpn: is_vpn,
|
||||
is_proxy: is_proxy
|
||||
}
|
||||
end
|
||||
|
||||
def company
|
||||
network_intelligence[:company]
|
||||
end
|
||||
|
||||
def asn
|
||||
network_intelligence[:asn]
|
||||
end
|
||||
|
||||
def asn_org
|
||||
network_intelligence[:asn_org]
|
||||
end
|
||||
|
||||
def is_datacenter?
|
||||
network_intelligence[:is_datacenter] || false
|
||||
end
|
||||
|
||||
def is_proxy?
|
||||
network_intelligence[:is_proxy] || false
|
||||
end
|
||||
|
||||
def is_vpn?
|
||||
network_intelligence[:is_vpn] || false
|
||||
end
|
||||
# Denormalized attribute accessors - these now use the columns directly
|
||||
# No need to override - Rails provides these automatically:
|
||||
# - country (column)
|
||||
# - company (column)
|
||||
# - asn (column)
|
||||
# - asn_org (column)
|
||||
# - is_datacenter (column)
|
||||
# - is_vpn (column)
|
||||
# - is_proxy (column)
|
||||
|
||||
# IP validation
|
||||
def valid_ipv4?
|
||||
@@ -415,7 +486,7 @@ class Event < ApplicationRecord
|
||||
end
|
||||
|
||||
def active_blocking_rules
|
||||
matching_rules.where(action: 'deny')
|
||||
matching_rules.where(waf_action: :deny)
|
||||
end
|
||||
|
||||
def has_blocking_rules?
|
||||
@@ -462,6 +533,121 @@ class Event < ApplicationRecord
|
||||
Rails.logger.error "Failed to normalize event #{id}: #{e.message}"
|
||||
end
|
||||
|
||||
def should_populate_network_intelligence?
|
||||
# Only populate if IP is present and country is not yet set
|
||||
# Also repopulate if IP address changed (rare case)
|
||||
ip_address.present? && (country.blank? || ip_address_changed?)
|
||||
end
|
||||
|
||||
def populate_network_intelligence
|
||||
return unless ip_address.present?
|
||||
|
||||
# Convert IPAddr to string for PostgreSQL query
|
||||
ip_string = ip_address.to_s
|
||||
|
||||
# CRITICAL: Always find_or_create /24 tracking network for public IPs
|
||||
# This /24 serves as:
|
||||
# 1. The tracking unit for IPAPI deduplication (stores ipapi_queried_at)
|
||||
# 2. The reference point for preventing duplicate API calls
|
||||
# 3. The fallback network if no more specific GeoIP data exists
|
||||
tracking_network = find_or_create_tracking_network(ip_string)
|
||||
|
||||
# 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
|
||||
data_range = NetworkRange.where("network >>= ?", ip_string)
|
||||
.where.not(country: nil) # Must have actual data
|
||||
.order(Arel.sql("masklen(network) DESC"))
|
||||
.first
|
||||
|
||||
# Use the most specific range with data, or fall back to tracking network
|
||||
range = data_range || tracking_network
|
||||
|
||||
if range
|
||||
# Populate all network intelligence fields from the range
|
||||
self.country = range.country
|
||||
self.company = range.company
|
||||
self.asn = range.asn
|
||||
self.asn_org = range.asn_org
|
||||
self.is_datacenter = range.is_datacenter || false
|
||||
self.is_vpn = range.is_vpn || false
|
||||
self.is_proxy = range.is_proxy || false
|
||||
else
|
||||
# No range at all (shouldn't happen, but defensive)
|
||||
self.is_datacenter = false
|
||||
self.is_vpn = false
|
||||
self.is_proxy = false
|
||||
end
|
||||
|
||||
# ALWAYS set network_range_id to the tracking /24
|
||||
# This is what FetchIpapiDataJob uses to check ipapi_queried_at
|
||||
# and prevent duplicate API calls
|
||||
self.network_range_id = tracking_network&.id
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to populate network intelligence for event #{id}: #{e.message}"
|
||||
# Set defaults on error to prevent null values
|
||||
self.is_datacenter = false
|
||||
self.is_vpn = false
|
||||
self.is_proxy = false
|
||||
end
|
||||
|
||||
# Find or create the /24 tracking network for this IP
|
||||
# This is the fundamental unit for IPAPI deduplication
|
||||
def find_or_create_tracking_network(ip_string)
|
||||
return nil if private_or_reserved_ip?(ip_string)
|
||||
|
||||
ip_addr = IPAddr.new(ip_string)
|
||||
|
||||
# Calculate /24 for IPv4, /64 for IPv6
|
||||
if ip_addr.ipv4?
|
||||
prefix_length = 24
|
||||
mask = (2**32 - 1) ^ ((2**(32 - prefix_length)) - 1)
|
||||
network_int = ip_addr.to_i & mask
|
||||
network_base = IPAddr.new(network_int, Socket::AF_INET)
|
||||
network_cidr = "#{network_base}/#{prefix_length}" # e.g., "1.2.3.0/24"
|
||||
else
|
||||
prefix_length = 64
|
||||
mask = (2**128 - 1) ^ ((2**(128 - prefix_length)) - 1)
|
||||
network_int = ip_addr.to_i & mask
|
||||
network_base = IPAddr.new(network_int, Socket::AF_INET6)
|
||||
network_cidr = "#{network_base}/#{prefix_length}" # e.g., "2001:db8::/64"
|
||||
end
|
||||
|
||||
# Find or create the tracking network
|
||||
NetworkRange.find_or_create_by!(network: network_cidr) do |nr|
|
||||
nr.source = 'auto_generated'
|
||||
nr.creation_reason = 'tracking unit for IPAPI deduplication'
|
||||
nr.is_datacenter = NetworkRangeGenerator.datacenter_ip?(ip_addr) rescue false
|
||||
nr.is_vpn = false
|
||||
nr.is_proxy = false
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to create tracking network for IP #{ip_string}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# Check if IP is private or reserved (should not create network ranges)
|
||||
def private_or_reserved_ip?(ip_string = nil)
|
||||
ip_str = ip_string || ip_address.to_s
|
||||
ip = IPAddr.new(ip_str)
|
||||
|
||||
# Private and reserved ranges
|
||||
[
|
||||
IPAddr.new('10.0.0.0/8'),
|
||||
IPAddr.new('172.16.0.0/12'),
|
||||
IPAddr.new('192.168.0.0/16'),
|
||||
IPAddr.new('127.0.0.0/8'),
|
||||
IPAddr.new('169.254.0.0/16'),
|
||||
IPAddr.new('224.0.0.0/4'),
|
||||
IPAddr.new('240.0.0.0/4'),
|
||||
IPAddr.new('::1/128'),
|
||||
IPAddr.new('fc00::/7'),
|
||||
IPAddr.new('fe80::/10'),
|
||||
IPAddr.new('ff00::/8')
|
||||
].any? { |range| range.include?(ip) }
|
||||
rescue IPAddr::InvalidAddressError
|
||||
true # Treat invalid IPs as "reserved"
|
||||
end
|
||||
|
||||
def extract_fields_from_payload
|
||||
return unless payload.present?
|
||||
|
||||
@@ -480,7 +666,8 @@ class Event < ApplicationRecord
|
||||
self.request_url = request_data["url"]
|
||||
self.response_status = response_data["status_code"]
|
||||
self.response_time_ms = response_data["duration_ms"]
|
||||
self.rule_matched = payload["rule_matched"]
|
||||
# Support both new (rule_id) and old (rule_matched) field names during cutover
|
||||
self.rule_id = payload["rule_id"] || payload["rule_matched"]
|
||||
self.blocked_reason = payload["blocked_reason"]
|
||||
|
||||
# Store original values for normalization only if they don't exist yet
|
||||
|
||||
@@ -116,7 +116,7 @@ class NetworkRange < ApplicationRecord
|
||||
|
||||
# Parent/child relationships
|
||||
def parent_ranges
|
||||
NetworkRange.where("network << ?::inet AND masklen(network) < ?", network.to_s, prefix_length)
|
||||
NetworkRange.where("?::inet << network AND masklen(network) < ?", network.to_s, prefix_length)
|
||||
.order("masklen(network) DESC")
|
||||
end
|
||||
|
||||
@@ -133,29 +133,68 @@ class NetworkRange < ApplicationRecord
|
||||
|
||||
# Find nearest parent with intelligence data
|
||||
def parent_with_intelligence
|
||||
# Use Postgres network operators to find parent ranges directly
|
||||
cidr_str = network.to_s
|
||||
if cidr_str.include?('/')
|
||||
addr_parts = network_address.split('.')
|
||||
case addr_parts.length
|
||||
when 4 # IPv4
|
||||
new_prefix = [prefix_length - 8, 16].max
|
||||
parent_cidr = "#{addr_parts[0]}.#{addr_parts[1]}.#{addr_parts[2]}.0/#{new_prefix}"
|
||||
else # IPv6 - skip for now
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
return nil unless parent_cidr
|
||||
|
||||
NetworkRange.where("network <<= ?::inet AND masklen(network) < ?", parent_cidr, prefix_length)
|
||||
.where.not(asn: nil)
|
||||
# Find all parent ranges (networks that contain this network)
|
||||
# and look for any with intelligence data, ordered by specificity
|
||||
NetworkRange.where("?::inet <<= network", network.to_s)
|
||||
.where("masklen(network) < ?", prefix_length)
|
||||
.where("(asn IS NOT NULL OR company IS NOT NULL OR country IS NOT NULL OR is_datacenter = true OR is_vpn = true OR is_proxy = true)")
|
||||
.order("masklen(network) DESC")
|
||||
.first
|
||||
end
|
||||
|
||||
# Check if this network or any parent has IPAPI data
|
||||
def has_ipapi_data_available?
|
||||
return true if has_network_data_from?(:ipapi)
|
||||
|
||||
parent_ranges.any? { |parent| parent.has_network_data_from?(:ipapi) }
|
||||
end
|
||||
|
||||
# Generic API fetching status management
|
||||
def is_fetching_api_data?(source)
|
||||
fetching_status = network_data&.dig('fetching_status') || {}
|
||||
fetching_status[source.to_s] &&
|
||||
fetching_status[source.to_s]['started_at'] &&
|
||||
fetching_status[source.to_s]['started_at'] > 5.minutes.ago.to_f
|
||||
end
|
||||
|
||||
def mark_as_fetching_api_data!(source)
|
||||
self.network_data ||= {}
|
||||
self.network_data['fetching_status'] ||= {}
|
||||
self.network_data['fetching_status'][source.to_s] = {
|
||||
'started_at' => Time.current.to_f,
|
||||
'job_id' => SecureRandom.hex(8)
|
||||
}
|
||||
save!
|
||||
end
|
||||
|
||||
def clear_fetching_status!(source)
|
||||
if network_data&.dig('fetching_status')&.dig(source.to_s)
|
||||
self.network_data['fetching_status'].delete(source.to_s)
|
||||
# Clean up empty fetching_status hash
|
||||
self.network_data.delete('fetching_status') if self.network_data['fetching_status'].empty?
|
||||
save!
|
||||
end
|
||||
end
|
||||
|
||||
# Check if we should fetch API data (not available and not currently being fetched)
|
||||
def should_fetch_api_data?(source)
|
||||
return false if send("has_network_data_from?(#{source})") if respond_to?("has_network_data_from?(#{source})")
|
||||
return false if is_fetching_api_data?(source)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Check if this network or any parent has IPAPI data available and no active fetch
|
||||
def should_fetch_ipapi_data?
|
||||
return false if has_ipapi_data_available?
|
||||
return false if is_fetching_api_data?(:ipapi)
|
||||
|
||||
# Also check if any parent is currently fetching IPAPI data
|
||||
return false if parent_ranges.any? { |parent| parent.is_fetching_api_data?(:ipapi) }
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def inherited_intelligence
|
||||
return own_intelligence if has_intelligence?
|
||||
|
||||
@@ -182,6 +221,12 @@ class NetworkRange < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def agent_tally
|
||||
# Rails.cache.fetch("#{to_s}:agent_tally", expires_in: 5.minutes) do
|
||||
events.map(&:user_agent).tally
|
||||
# end
|
||||
end
|
||||
|
||||
# Geographic lookup
|
||||
def geo_lookup_country!
|
||||
return if country.present?
|
||||
@@ -203,6 +248,12 @@ class NetworkRange < ApplicationRecord
|
||||
where("network && ?", range_cidr)
|
||||
end
|
||||
|
||||
def self.findd(cidr)
|
||||
cidr = cidr.gsub("_", "/")
|
||||
cidr = "#{cidr}/24" unless cidr.include?("/")
|
||||
find_by(network: cidr)
|
||||
end
|
||||
|
||||
def self.find_or_create_by_cidr(cidr, user: nil, source: nil, reason: nil)
|
||||
find_or_create_by(network: cidr) do |range|
|
||||
range.user = user
|
||||
@@ -238,6 +289,85 @@ class NetworkRange < ApplicationRecord
|
||||
self.additional_data = hash.to_json
|
||||
end
|
||||
|
||||
# Network data accessors for different data sources
|
||||
# network_data is a JSONB column with namespaced data:
|
||||
# {
|
||||
# geolite: {...}, # MaxMind GeoLite2 data
|
||||
# ipapi: {...}, # IPAPI.is enrichment data
|
||||
# abuseipdb: {...}, # Future: AbuseIPDB data
|
||||
# shodan: {...} # Future: Shodan data
|
||||
# }
|
||||
def network_data_for(source)
|
||||
network_data&.dig(source.to_s) || {}
|
||||
end
|
||||
|
||||
def set_network_data(source, data)
|
||||
self.network_data ||= {}
|
||||
self.network_data[source.to_s] = data
|
||||
end
|
||||
|
||||
# Check if we have network data from a specific source
|
||||
def has_network_data_from?(source)
|
||||
network_data&.key?(source.to_s) && network_data[source.to_s].present?
|
||||
end
|
||||
|
||||
# IPAPI tracking at /24 granularity
|
||||
# Find or create the /24 network for a given IP address
|
||||
def self.find_or_create_tracking_network_for_ip(ip_address)
|
||||
ip = IPAddr.new(ip_address.to_s)
|
||||
|
||||
# Create /24 network for IPv4, /64 for IPv6
|
||||
tracking_cidr = if ip.ipv4?
|
||||
"#{ip.mask(24)}/24"
|
||||
else
|
||||
"#{ip.mask(64)}/64"
|
||||
end
|
||||
|
||||
find_or_create_by(network: tracking_cidr) do |range|
|
||||
range.source = 'auto_generated'
|
||||
range.creation_reason = 'IPAPI tracking network'
|
||||
end
|
||||
end
|
||||
|
||||
# Check if we should fetch IPAPI data for a given IP address
|
||||
# Uses /24 networks as the tracking unit
|
||||
def self.should_fetch_ipapi_for_ip?(ip_address)
|
||||
tracking_network = find_or_create_tracking_network_for_ip(ip_address)
|
||||
|
||||
# Check if /24 has been queried recently
|
||||
queried_at = tracking_network.network_data&.dig('ipapi_queried_at')
|
||||
return true if queried_at.nil?
|
||||
|
||||
# Check if IPAPI returned a CIDR that actually covers this IP
|
||||
# (handles edge case where IPAPI returns /25 or more specific)
|
||||
returned_cidr = tracking_network.network_data&.dig('ipapi_returned_cidr')
|
||||
if returned_cidr.present?
|
||||
begin
|
||||
returned_range = IPAddr.new(returned_cidr)
|
||||
ip = IPAddr.new(ip_address.to_s)
|
||||
# If the IP is NOT covered by what IPAPI returned, fetch again
|
||||
return true unless returned_range.include?(ip)
|
||||
rescue IPAddr::InvalidAddressError => e
|
||||
Rails.logger.warn "Invalid CIDR stored in ipapi_returned_cidr: #{returned_cidr}"
|
||||
end
|
||||
end
|
||||
|
||||
# Re-query after 1 year
|
||||
Time.at(queried_at) < 1.year.ago
|
||||
rescue => e
|
||||
Rails.logger.error "Error checking IPAPI fetch status for #{ip_address}: #{e.message}"
|
||||
true # Default to fetching on error
|
||||
end
|
||||
|
||||
# Mark that we've queried IPAPI for this /24 network
|
||||
# @param returned_cidr [String] The CIDR that IPAPI actually returned (may be more specific than /24)
|
||||
def mark_ipapi_queried!(returned_cidr)
|
||||
self.network_data ||= {}
|
||||
self.network_data['ipapi_queried_at'] = Time.current.to_i
|
||||
self.network_data['ipapi_returned_cidr'] = returned_cidr
|
||||
save!
|
||||
end
|
||||
|
||||
# String representations
|
||||
def to_s
|
||||
cidr
|
||||
@@ -253,20 +383,74 @@ class NetworkRange < ApplicationRecord
|
||||
self[:events_count] || 0
|
||||
end
|
||||
|
||||
def events
|
||||
Event.where("ip_address <<= ?", cidr)
|
||||
end
|
||||
|
||||
def recent_events(limit: 100)
|
||||
Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address])
|
||||
.recent
|
||||
.limit(limit)
|
||||
events.recent.limit(limit)
|
||||
end
|
||||
|
||||
def blocking_rules
|
||||
rules.where(action: 'deny', enabled: true)
|
||||
rules.where(waf_action: :deny, enabled: true)
|
||||
end
|
||||
|
||||
def active_rules
|
||||
rules.enabled.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
||||
end
|
||||
|
||||
# Find all network ranges that are contained by this network and have enabled rules
|
||||
# Used when creating a supernet rule to identify redundant child rules
|
||||
def child_network_ranges_with_rules
|
||||
NetworkRange
|
||||
.where("network << ?::inet", network.to_s) # network is strictly contained by this network
|
||||
.joins(:rules)
|
||||
.where(rules: { enabled: true })
|
||||
.distinct
|
||||
end
|
||||
|
||||
# Find all enabled rules on child network ranges (more specific networks)
|
||||
# Used after creating a rule to expire redundant child rules
|
||||
def child_rules
|
||||
Rule
|
||||
.joins(:network_range)
|
||||
.where("network_ranges.network << ?::inet", cidr)
|
||||
.where(enabled: true)
|
||||
end
|
||||
|
||||
# Find all network ranges that contain this network and have enabled rules
|
||||
# Used to check if creating a rule would be redundant
|
||||
def parent_network_ranges_with_rules
|
||||
NetworkRange
|
||||
.where("?::inet << network", cidr) # this network is strictly contained by parent
|
||||
.joins(:rules)
|
||||
.where(rules: { enabled: true })
|
||||
.distinct
|
||||
end
|
||||
|
||||
# Find all enabled rules on parent network ranges (less specific networks)
|
||||
# Used before creating a rule to check if it would be redundant
|
||||
def supernet_rules
|
||||
Rule
|
||||
.joins(:network_range)
|
||||
.where("?::inet << network_ranges.network", cidr)
|
||||
.where(enabled: true)
|
||||
.order("masklen(network_ranges.network) DESC") # Most specific supernet first
|
||||
end
|
||||
|
||||
# Check if this network range needs WAF policy evaluation
|
||||
# Returns true if:
|
||||
# - Never been evaluated, OR
|
||||
# - Any WafPolicy has been updated since last evaluation
|
||||
def needs_policy_evaluation?
|
||||
return true if policies_evaluated_at.nil?
|
||||
|
||||
latest_policy_update = WafPolicy.maximum(:updated_at)
|
||||
return false if latest_policy_update.nil? # No policies exist
|
||||
|
||||
policies_evaluated_at < latest_policy_update
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_default_source
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
# Rules define actions to take for matching traffic conditions.
|
||||
# Network rules are associated with NetworkRange objects for rich context.
|
||||
class Rule < ApplicationRecord
|
||||
# Rule types and actions
|
||||
# Rule enums (prefix needed to avoid rate_limit collision)
|
||||
enum :waf_action, { allow: 0, deny: 1, rate_limit: 2, redirect: 3, log: 4, challenge: 5 }, prefix: :action
|
||||
enum :waf_rule_type, { network: 0, rate_limit: 1, path_pattern: 2 }, prefix: :type
|
||||
|
||||
# Legacy string constants for backward compatibility
|
||||
RULE_TYPES = %w[network rate_limit path_pattern].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
|
||||
@@ -14,35 +18,49 @@ class Rule < ApplicationRecord
|
||||
belongs_to :user
|
||||
belongs_to :network_range, optional: true
|
||||
belongs_to :waf_policy, optional: true
|
||||
has_many :events, dependent: :nullify
|
||||
|
||||
# Validations
|
||||
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
|
||||
validates :action, presence: true, inclusion: { in: ACTIONS }
|
||||
validates :waf_rule_type, presence: true, inclusion: { in: waf_rule_types.keys }
|
||||
validates :waf_action, presence: true, inclusion: { in: waf_actions.keys }
|
||||
validates :conditions, presence: true, unless: :network_rule?
|
||||
validates :enabled, inclusion: { in: [true, false] }
|
||||
validates :source, inclusion: { in: SOURCES }
|
||||
|
||||
# Legacy enum definitions (disabled to prevent conflicts)
|
||||
# enum :action, { allow: "allow", deny: "deny", rate_limit: "rate_limit", redirect: "redirect", log: "log", challenge: "challenge" }, scopes: false
|
||||
# enum :rule_type, { network: "network", rate_limit: "rate_limit", path_pattern: "path_pattern" }, scopes: false
|
||||
|
||||
# Legacy validations for backward compatibility during transition
|
||||
# validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }, allow_nil: true
|
||||
# validates :action, presence: true, inclusion: { in: ACTIONS }, allow_nil: true
|
||||
|
||||
# Custom validations
|
||||
validate :validate_conditions_by_type
|
||||
validate :validate_metadata_by_action
|
||||
validate :network_range_required_for_network_rules
|
||||
validate :validate_network_consistency, if: :network_rule?
|
||||
validate :no_supernet_rule_exists, if: :should_check_supernet?
|
||||
|
||||
# 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(rule_type: type) }
|
||||
scope :network_rules, -> { where(rule_type: "network") }
|
||||
scope :rate_limit_rules, -> { where(rule_type: "rate_limit") }
|
||||
scope :path_pattern_rules, -> { where(rule_type: "path_pattern") }
|
||||
scope :by_type, ->(type) { where(waf_rule_type: type) }
|
||||
scope :network_rules, -> { where(waf_rule_type: :network) }
|
||||
scope :rate_limit_rules, -> { where(waf_rule_type: :rate_limit) }
|
||||
scope :path_pattern_rules, -> { where(waf_rule_type: :path_pattern) }
|
||||
scope :by_source, ->(source) { where(source: source) }
|
||||
scope :surgical_blocks, -> { where(source: "manual:surgical_block") }
|
||||
scope :surgical_exceptions, -> { where(source: "manual:surgical_exception") }
|
||||
scope :policy_generated, -> { where(source: "policy") }
|
||||
scope :from_waf_policy, ->(waf_policy) { where(waf_policy: waf_policy) }
|
||||
|
||||
# Action scopes (manual to avoid enum collision with rate_limit)
|
||||
scope :deny, -> { where(waf_action: :deny) }
|
||||
scope :allow, -> { where(waf_action: :allow) }
|
||||
|
||||
# Sync queries
|
||||
scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) }
|
||||
scope :sync_order, -> { order(:updated_at, :id) }
|
||||
@@ -51,18 +69,19 @@ class Rule < ApplicationRecord
|
||||
before_validation :set_defaults
|
||||
before_validation :parse_json_fields
|
||||
before_save :calculate_priority_for_network_rules
|
||||
after_create :expire_redundant_child_rules, if: :should_expire_child_rules?
|
||||
|
||||
# Rule type checks
|
||||
def network_rule?
|
||||
rule_type == "network"
|
||||
type_network?
|
||||
end
|
||||
|
||||
def rate_limit_rule?
|
||||
rule_type == "rate_limit"
|
||||
type_rate_limit?
|
||||
end
|
||||
|
||||
def path_pattern_rule?
|
||||
rule_type == "path_pattern"
|
||||
type_path_pattern?
|
||||
end
|
||||
|
||||
# Network-specific methods
|
||||
@@ -104,16 +123,16 @@ class Rule < ApplicationRecord
|
||||
|
||||
# Action-specific methods
|
||||
def redirect_action?
|
||||
action == "redirect"
|
||||
action_redirect?
|
||||
end
|
||||
|
||||
def challenge_action?
|
||||
action == "challenge"
|
||||
action_challenge?
|
||||
end
|
||||
|
||||
# Redirect/challenge convenience methods
|
||||
def redirect_url
|
||||
metadata&.dig('redirect_url')
|
||||
metadata_hash['redirect_url']
|
||||
end
|
||||
|
||||
def redirect_status
|
||||
@@ -162,12 +181,13 @@ class Rule < ApplicationRecord
|
||||
end
|
||||
|
||||
def disable!(reason: nil)
|
||||
update!(
|
||||
enabled: false,
|
||||
metadata: metadata.merge(
|
||||
new_metadata = metadata_hash.merge(
|
||||
disabled_at: Time.current.iso8601,
|
||||
disabled_reason: reason
|
||||
)
|
||||
update!(
|
||||
enabled: false,
|
||||
metadata: new_metadata
|
||||
)
|
||||
end
|
||||
|
||||
@@ -180,8 +200,8 @@ class Rule < ApplicationRecord
|
||||
def to_agent_format
|
||||
format = {
|
||||
id: id,
|
||||
rule_type: rule_type,
|
||||
waf_action: action, # Agents expect 'waf_action' field
|
||||
waf_rule_type: waf_rule_type,
|
||||
waf_action: waf_action, # Use the enum field directly
|
||||
conditions: agent_conditions,
|
||||
priority: agent_priority,
|
||||
expires_at: expires_at&.to_i, # Agents expect Unix timestamps
|
||||
@@ -216,29 +236,92 @@ class Rule < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# For path_pattern rules, include segment IDs and match type
|
||||
if path_pattern_rule?
|
||||
format[:conditions] = {
|
||||
segment_ids: path_segment_ids,
|
||||
match_type: path_match_type
|
||||
}
|
||||
end
|
||||
|
||||
format
|
||||
end
|
||||
|
||||
# Path pattern rule helper methods
|
||||
def path_segment_ids
|
||||
conditions&.dig("segment_ids") || []
|
||||
end
|
||||
|
||||
def path_match_type
|
||||
conditions&.dig("match_type")
|
||||
end
|
||||
|
||||
def path_segments_text
|
||||
return [] if path_segment_ids.empty?
|
||||
PathSegment.where(id: path_segment_ids).order(:id).pluck(:segment)
|
||||
end
|
||||
|
||||
def path_pattern_display
|
||||
return nil unless path_pattern_rule?
|
||||
"/" + path_segments_text.join("/")
|
||||
end
|
||||
|
||||
# Class methods for rule creation
|
||||
def self.create_network_rule(cidr, action: 'deny', user: nil, **options)
|
||||
network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created')
|
||||
|
||||
create!(
|
||||
rule_type: 'network',
|
||||
action: action,
|
||||
waf_rule_type: 'network',
|
||||
waf_action: action,
|
||||
network_range: network_range,
|
||||
user: user,
|
||||
**options
|
||||
)
|
||||
end
|
||||
|
||||
def self.create_path_pattern_rule(pattern:, match_type:, action: 'deny', user: nil, **options)
|
||||
# Parse pattern string to segments (case-insensitive)
|
||||
segments = pattern.split('/').reject(&:blank?).map(&:downcase)
|
||||
|
||||
if segments.empty?
|
||||
raise ArgumentError, "Pattern must contain at least one path segment"
|
||||
end
|
||||
|
||||
unless %w[exact prefix suffix contains].include?(match_type)
|
||||
raise ArgumentError, "Match type must be one of: exact, prefix, suffix, contains"
|
||||
end
|
||||
|
||||
# Find or create PathSegment entries
|
||||
segment_ids = segments.map do |seg|
|
||||
PathSegment.find_or_create_segment(seg).id
|
||||
end
|
||||
|
||||
create!(
|
||||
waf_rule_type: 'path_pattern',
|
||||
waf_action: action,
|
||||
conditions: {
|
||||
segment_ids: segment_ids,
|
||||
match_type: match_type,
|
||||
original_pattern: pattern
|
||||
},
|
||||
metadata: {
|
||||
segments: segments,
|
||||
pattern_display: "/" + segments.join("/")
|
||||
},
|
||||
user: user,
|
||||
source: options[:source] || 'manual',
|
||||
priority: options[:priority] || 50,
|
||||
**options.except(:source, :priority)
|
||||
)
|
||||
end
|
||||
|
||||
def self.create_surgical_block(ip_address, parent_cidr, user: nil, reason: nil, **options)
|
||||
# Create block rule for parent range
|
||||
network_range = NetworkRange.find_or_create_by_cidr(parent_cidr, user: user, source: 'user_created')
|
||||
|
||||
block_rule = create!(
|
||||
rule_type: 'network',
|
||||
action: 'deny',
|
||||
waf_rule_type: 'network',
|
||||
waf_action: 'deny',
|
||||
network_range: network_range,
|
||||
source: 'manual:surgical_block',
|
||||
user: user,
|
||||
@@ -255,8 +338,8 @@ class Rule < ApplicationRecord
|
||||
ip_network_range = NetworkRange.find_or_create_by_cidr("#{ip_address}/#{ip_address.include?(':') ? '128' : '32'}", user: user, source: 'user_created')
|
||||
|
||||
exception_rule = create!(
|
||||
rule_type: 'network',
|
||||
action: 'allow',
|
||||
waf_rule_type: 'network',
|
||||
waf_action: 'allow',
|
||||
network_range: ip_network_range,
|
||||
source: 'manual:surgical_exception',
|
||||
user: user,
|
||||
@@ -277,8 +360,8 @@ class Rule < ApplicationRecord
|
||||
network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created')
|
||||
|
||||
create!(
|
||||
rule_type: 'rate_limit',
|
||||
action: 'rate_limit',
|
||||
waf_rule_type: 'rate_limit',
|
||||
waf_action: 'rate_limit',
|
||||
network_range: network_range,
|
||||
conditions: { cidr: cidr, scope: 'ip' },
|
||||
metadata: {
|
||||
@@ -307,7 +390,7 @@ class Rule < ApplicationRecord
|
||||
|
||||
# This would need efficient IP range queries
|
||||
# For now, simple IP match
|
||||
Event.where(ip_address: network_range.network_address)
|
||||
Event.where("ip_address <<= ?", network_range.cidr)
|
||||
.recent
|
||||
.limit(limit)
|
||||
end
|
||||
@@ -324,6 +407,18 @@ class Rule < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
# Helper method to safely access metadata as hash
|
||||
def metadata_hash
|
||||
case metadata
|
||||
when Hash
|
||||
metadata
|
||||
when String
|
||||
metadata.present? ? (JSON.parse(metadata) rescue {}) : {}
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_defaults
|
||||
@@ -361,7 +456,7 @@ class Rule < ApplicationRecord
|
||||
end
|
||||
|
||||
def validate_conditions_by_type
|
||||
case rule_type
|
||||
case waf_rule_type
|
||||
when "network"
|
||||
# Network rules don't need conditions in DB - stored in network_range
|
||||
true
|
||||
@@ -386,15 +481,29 @@ class Rule < ApplicationRecord
|
||||
end
|
||||
|
||||
def validate_path_pattern_conditions
|
||||
patterns = conditions&.dig("patterns")
|
||||
segment_ids = conditions&.dig("segment_ids")
|
||||
match_type = conditions&.dig("match_type")
|
||||
|
||||
if patterns.blank? || !patterns.is_a?(Array)
|
||||
errors.add(:conditions, "must include 'patterns' array for path_pattern rules")
|
||||
if segment_ids.blank? || !segment_ids.is_a?(Array)
|
||||
errors.add(:conditions, "must include 'segment_ids' array for path_pattern rules")
|
||||
end
|
||||
|
||||
unless %w[exact prefix suffix contains].include?(match_type)
|
||||
errors.add(:conditions, "match_type must be one of: exact, prefix, suffix, contains")
|
||||
end
|
||||
|
||||
# Validate that all segment IDs exist
|
||||
if segment_ids.is_a?(Array) && segment_ids.any?
|
||||
existing_ids = PathSegment.where(id: segment_ids).pluck(:id)
|
||||
missing_ids = segment_ids - existing_ids
|
||||
if missing_ids.any?
|
||||
errors.add(:conditions, "references non-existent path segment IDs: #{missing_ids.join(', ')}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_metadata_by_action
|
||||
case action
|
||||
case waf_action
|
||||
when "redirect"
|
||||
unless metadata&.dig("redirect_url").present?
|
||||
errors.add(:metadata, "must include 'redirect_url' for redirect action")
|
||||
@@ -457,4 +566,52 @@ class Rule < ApplicationRecord
|
||||
self.metadata ||= {}
|
||||
end
|
||||
|
||||
# Supernet/subnet redundancy checking
|
||||
def should_check_supernet?
|
||||
network_rule? && network_range.present? && new_record?
|
||||
end
|
||||
|
||||
def no_supernet_rule_exists
|
||||
return unless network_range
|
||||
|
||||
supernet_rule = network_range.supernet_rules.first
|
||||
if supernet_rule
|
||||
errors.add(
|
||||
:base,
|
||||
"A supernet rule already covers this network. " \
|
||||
"Rule ##{supernet_rule.id} for #{supernet_rule.network_range.cidr} " \
|
||||
"(action: #{supernet_rule.waf_action}) makes this rule redundant."
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def should_expire_child_rules?
|
||||
network_rule? && network_range.present? && enabled?
|
||||
end
|
||||
|
||||
def expire_redundant_child_rules
|
||||
return unless network_range
|
||||
|
||||
child_rules = network_range.child_rules
|
||||
return if child_rules.empty?
|
||||
|
||||
expired_count = 0
|
||||
child_rules.find_each do |child_rule|
|
||||
# Disable the child rule and mark it as redundant
|
||||
child_rule.update!(
|
||||
enabled: false,
|
||||
metadata: child_rule.metadata_hash.merge(
|
||||
disabled_at: Time.current.iso8601,
|
||||
disabled_reason: "Redundant - covered by supernet rule ##{id} (#{network_range.cidr})",
|
||||
superseded_by_rule_id: id
|
||||
)
|
||||
)
|
||||
expired_count += 1
|
||||
end
|
||||
|
||||
if expired_count > 0
|
||||
Rails.logger.info "Rule ##{id}: Expired #{expired_count} redundant child rule(s) for #{network_range.cidr}"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
18
app/models/setting.rb
Normal file
18
app/models/setting.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class Setting < ApplicationRecord
|
||||
validates :key, presence: true, uniqueness: true
|
||||
|
||||
# Get a setting value by key, with optional fallback
|
||||
def self.get(key, default = nil)
|
||||
find_by(key: key)&.value || default
|
||||
end
|
||||
|
||||
# Set a setting value by key
|
||||
def self.set(key, value)
|
||||
find_or_initialize_by(key: key).update(value: value)
|
||||
end
|
||||
|
||||
# Convenience method for ipapi.is API key
|
||||
def self.ipapi_key
|
||||
get('ipapi_key', ENV['IPAPI_KEY'])
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,7 @@
|
||||
# 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
|
||||
POLICY_TYPES = %w[country asn company network_type path_pattern].freeze
|
||||
|
||||
# Actions - what to do when traffic matches this policy
|
||||
ACTIONS = %w[allow deny redirect challenge].freeze
|
||||
@@ -18,13 +18,13 @@ class WafPolicy < ApplicationRecord
|
||||
# Validations
|
||||
validates :name, presence: true, uniqueness: true
|
||||
validates :policy_type, presence: true, inclusion: { in: POLICY_TYPES }
|
||||
validates :action, presence: true, inclusion: { in: ACTIONS }
|
||||
validates :policy_action, presence: true, inclusion: { in: ACTIONS }
|
||||
validates :targets, presence: true
|
||||
validate :targets_must_be_array
|
||||
validates :user, presence: true
|
||||
validate :validate_targets_by_type
|
||||
validate :validate_redirect_configuration, if: :redirect_action?
|
||||
validate :validate_challenge_configuration, if: :challenge_action?
|
||||
validate :validate_redirect_configuration, if: :redirect_policy_action?
|
||||
validate :validate_challenge_configuration, if: :challenge_policy_action?
|
||||
|
||||
# Scopes
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
@@ -57,21 +57,42 @@ validate :targets_must_be_array
|
||||
policy_type == 'network_type'
|
||||
end
|
||||
|
||||
def path_pattern_policy?
|
||||
policy_type == 'path_pattern'
|
||||
end
|
||||
|
||||
# Action methods
|
||||
def allow_action?
|
||||
action == 'allow'
|
||||
policy_action == 'allow'
|
||||
end
|
||||
|
||||
def deny_action?
|
||||
action == 'deny'
|
||||
policy_action == 'deny'
|
||||
end
|
||||
|
||||
def redirect_action?
|
||||
action == 'redirect'
|
||||
policy_action == 'redirect'
|
||||
end
|
||||
|
||||
def challenge_action?
|
||||
action == 'challenge'
|
||||
policy_action == 'challenge'
|
||||
end
|
||||
|
||||
# Policy action methods (to avoid confusion with Rails' action methods)
|
||||
def allow_policy_action?
|
||||
policy_action == 'allow'
|
||||
end
|
||||
|
||||
def deny_policy_action?
|
||||
policy_action == 'deny'
|
||||
end
|
||||
|
||||
def redirect_policy_action?
|
||||
policy_action == 'redirect'
|
||||
end
|
||||
|
||||
def challenge_policy_action?
|
||||
policy_action == 'challenge'
|
||||
end
|
||||
|
||||
# Lifecycle methods
|
||||
@@ -113,12 +134,27 @@ validate :targets_must_be_array
|
||||
end
|
||||
end
|
||||
|
||||
# Event matching methods (for path patterns)
|
||||
def matches_event?(event)
|
||||
return false unless active?
|
||||
|
||||
case policy_type
|
||||
when 'country', 'asn', 'company', 'network_type'
|
||||
# For network-based policies, use the event's network range
|
||||
event.network_range && matches_network_range?(event.network_range)
|
||||
when 'path_pattern'
|
||||
matches_path_patterns?(event)
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def create_rule_for_network_range(network_range)
|
||||
return nil unless matches_network_range?(network_range)
|
||||
|
||||
rule = Rule.create!(
|
||||
rule_type: 'network',
|
||||
action: action,
|
||||
action: policy_action,
|
||||
network_range: network_range,
|
||||
waf_policy: self,
|
||||
user: user,
|
||||
@@ -147,46 +183,110 @@ validate :targets_must_be_array
|
||||
rule
|
||||
end
|
||||
|
||||
def create_rule_for_event(event)
|
||||
return nil unless matches_event?(event)
|
||||
|
||||
# For path pattern policies, create a path_pattern rule
|
||||
if path_pattern_policy?
|
||||
# Check for existing path_pattern rule with same policy and patterns
|
||||
existing_rule = Rule.find_by(
|
||||
waf_rule_type: 'path_pattern',
|
||||
waf_action: policy_action,
|
||||
waf_policy: self,
|
||||
enabled: true
|
||||
)
|
||||
|
||||
if existing_rule
|
||||
Rails.logger.debug "Path pattern rule already exists for policy #{name}"
|
||||
return existing_rule
|
||||
end
|
||||
|
||||
rule = Rule.create!(
|
||||
waf_rule_type: 'path_pattern',
|
||||
waf_action: policy_action,
|
||||
waf_policy: self,
|
||||
user: user,
|
||||
source: "policy",
|
||||
conditions: build_path_pattern_conditions(event),
|
||||
metadata: build_path_pattern_metadata(event),
|
||||
priority: 50 # Default priority for path rules
|
||||
)
|
||||
|
||||
# Handle redirect/challenge 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
|
||||
else
|
||||
# For network-based policies, fall back to network range rule creation
|
||||
create_rule_for_network_range(event.network_range)
|
||||
end
|
||||
end
|
||||
|
||||
# Class methods for creating common policies
|
||||
def self.create_country_policy(countries, action: 'deny', user:, **options)
|
||||
def self.create_country_policy(countries, policy_action: 'deny', user:, **options)
|
||||
create!(
|
||||
name: "#{action.capitalize} #{countries.join(', ')}",
|
||||
name: "#{policy_action.capitalize} #{countries.join(', ')}",
|
||||
policy_type: 'country',
|
||||
targets: Array(countries),
|
||||
action: action,
|
||||
policy_action: policy_action,
|
||||
user: user,
|
||||
**options
|
||||
)
|
||||
end
|
||||
|
||||
def self.create_asn_policy(asns, action: 'deny', user:, **options)
|
||||
def self.create_asn_policy(asns, policy_action: 'deny', user:, **options)
|
||||
create!(
|
||||
name: "#{action.capitalize} ASNs #{asns.join(', ')}",
|
||||
name: "#{policy_action.capitalize} ASNs #{asns.join(', ')}",
|
||||
policy_type: 'asn',
|
||||
targets: Array(asns).map(&:to_i),
|
||||
action: action,
|
||||
policy_action: policy_action,
|
||||
user: user,
|
||||
**options
|
||||
)
|
||||
end
|
||||
|
||||
def self.create_company_policy(companies, action: 'deny', user:, **options)
|
||||
def self.create_company_policy(companies, policy_action: 'deny', user:, **options)
|
||||
create!(
|
||||
name: "#{action.capitalize} #{companies.join(', ')}",
|
||||
name: "#{policy_action.capitalize} #{companies.join(', ')}",
|
||||
policy_type: 'company',
|
||||
targets: Array(companies),
|
||||
action: action,
|
||||
policy_action: policy_action,
|
||||
user: user,
|
||||
**options
|
||||
)
|
||||
end
|
||||
|
||||
def self.create_network_type_policy(types, action: 'deny', user:, **options)
|
||||
def self.create_network_type_policy(types, policy_action: 'deny', user:, **options)
|
||||
create!(
|
||||
name: "#{action.capitalize} #{types.join(', ')}",
|
||||
name: "#{policy_action.capitalize} #{types.join(', ')}",
|
||||
policy_type: 'network_type',
|
||||
targets: Array(types),
|
||||
action: action,
|
||||
policy_action: policy_action,
|
||||
user: user,
|
||||
**options
|
||||
)
|
||||
end
|
||||
|
||||
def self.create_path_pattern_policy(patterns, policy_action: 'deny', user:, **options)
|
||||
create!(
|
||||
name: "#{policy_action.capitalize} path patterns: #{Array(patterns).join(', ')}",
|
||||
policy_type: 'path_pattern',
|
||||
targets: Array(patterns),
|
||||
policy_action: policy_action,
|
||||
user: user,
|
||||
**options
|
||||
)
|
||||
@@ -226,7 +326,7 @@ validate :targets_must_be_array
|
||||
active_rules: active_rules_count,
|
||||
rules_last_7_days: recent_rules.count,
|
||||
policy_type: policy_type,
|
||||
action: action,
|
||||
policy_action: policy_action,
|
||||
targets_count: targets&.length || 0
|
||||
}
|
||||
end
|
||||
@@ -266,6 +366,8 @@ validate :targets_must_be_array
|
||||
validate_company_targets
|
||||
when 'network_type'
|
||||
validate_network_type_targets
|
||||
when 'path_pattern'
|
||||
validate_path_pattern_targets
|
||||
end
|
||||
end
|
||||
|
||||
@@ -294,6 +396,24 @@ validate :targets_must_be_array
|
||||
end
|
||||
end
|
||||
|
||||
def validate_path_pattern_targets
|
||||
unless targets.all? { |target| target.is_a?(String) && target.present? }
|
||||
errors.add(:targets, "must be valid path pattern strings")
|
||||
end
|
||||
|
||||
# Validate path patterns format (basic validation)
|
||||
targets.each do |pattern|
|
||||
begin
|
||||
# Basic validation - ensure it's a reasonable pattern
|
||||
unless pattern.match?(/\A[a-zA-Z0-9\-\._\*\/\?\[\]\{\}]+\z/)
|
||||
errors.add(:targets, "contains invalid characters in pattern: #{pattern}")
|
||||
end
|
||||
rescue => e
|
||||
errors.add(:targets, "invalid path pattern: #{pattern} - #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def validate_redirect_configuration
|
||||
if additional_data['redirect_url'].blank?
|
||||
errors.add(:additional_data, "must include 'redirect_url' for redirect action")
|
||||
@@ -396,4 +516,62 @@ validate :targets_must_be_array
|
||||
types.join(',') || 'standard'
|
||||
end
|
||||
end
|
||||
|
||||
# Path pattern matching methods
|
||||
def matches_path_patterns?(event)
|
||||
return false if event.request_path.blank?
|
||||
|
||||
path = event.request_path.downcase
|
||||
targets.any? { |pattern| matches_path_pattern?(pattern, path) }
|
||||
end
|
||||
|
||||
def matches_path_pattern?(pattern, path)
|
||||
pattern = pattern.downcase
|
||||
|
||||
# Handle different pattern types
|
||||
case pattern
|
||||
when /\*/, /\?/, /\[/
|
||||
# Glob patterns - simple matching
|
||||
match_glob_pattern(pattern, path)
|
||||
when /\.php$/, /\.exe$/, /\.js$/
|
||||
# File extension patterns
|
||||
path.end_with?(pattern)
|
||||
when /\A\//
|
||||
# Exact path match
|
||||
path == pattern
|
||||
else
|
||||
# Simple substring match
|
||||
path.include?(pattern)
|
||||
end
|
||||
end
|
||||
|
||||
def match_glob_pattern(pattern, path)
|
||||
# Convert simple glob patterns to regex
|
||||
regex_pattern = pattern
|
||||
.gsub('*', '.*')
|
||||
.gsub('?', '.')
|
||||
.gsub('[', '\[')
|
||||
.gsub(']', '\]')
|
||||
|
||||
path.match?(/\A#{regex_pattern}\z/)
|
||||
end
|
||||
|
||||
def build_path_pattern_conditions(event)
|
||||
{
|
||||
"patterns" => targets,
|
||||
"match_type" => "path_pattern"
|
||||
}
|
||||
end
|
||||
|
||||
def build_path_pattern_metadata(event)
|
||||
base_metadata = {
|
||||
generated_by_policy: id,
|
||||
policy_name: name,
|
||||
policy_type: policy_type,
|
||||
matched_path: event.request_path,
|
||||
generated_from: "event"
|
||||
}
|
||||
|
||||
base_metadata.merge!(additional_data || {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,41 +2,51 @@
|
||||
|
||||
class WafPolicyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
true # All authenticated users can view policies
|
||||
!user.viewer? # All authenticated users except viewers can view policies
|
||||
end
|
||||
|
||||
def show?
|
||||
true # All authenticated users can view policy details
|
||||
!user.viewer? # All authenticated users except viewers can view policy details
|
||||
end
|
||||
|
||||
def new?
|
||||
user.admin? || user.editor?
|
||||
!user.viewer? # All authenticated users except viewers can create policies
|
||||
end
|
||||
|
||||
def create?
|
||||
user.admin? || user.editor?
|
||||
!user.viewer? # All authenticated users except viewers can create policies
|
||||
end
|
||||
|
||||
def edit?
|
||||
user.admin? || (user.editor? && record.user == user)
|
||||
!user.viewer? # All authenticated users except viewers can edit policies
|
||||
end
|
||||
|
||||
def update?
|
||||
user.admin? || (user.editor? && record.user == user)
|
||||
!user.viewer? # All authenticated users except viewers can update policies
|
||||
end
|
||||
|
||||
def destroy?
|
||||
user.admin? || (user.editor? && record.user == user)
|
||||
!user.viewer? # All authenticated users except viewers can destroy policies
|
||||
end
|
||||
|
||||
def activate?
|
||||
user.admin? || (user.editor? && record.user == user)
|
||||
!user.viewer? # All authenticated users except viewers can activate policies
|
||||
end
|
||||
|
||||
def deactivate?
|
||||
user.admin? || (user.editor? && record.user == user)
|
||||
!user.viewer? # All authenticated users except viewers can deactivate policies
|
||||
end
|
||||
|
||||
# Path pattern policy permissions
|
||||
def new_path_pattern?
|
||||
create?
|
||||
end
|
||||
|
||||
def create_path_pattern?
|
||||
create?
|
||||
end
|
||||
|
||||
# Country policy permissions
|
||||
def new_country?
|
||||
create?
|
||||
end
|
||||
@@ -45,14 +55,38 @@ class WafPolicyPolicy < ApplicationPolicy
|
||||
create?
|
||||
end
|
||||
|
||||
# ASN policy permissions
|
||||
def new_asn?
|
||||
create?
|
||||
end
|
||||
|
||||
def create_asn?
|
||||
create?
|
||||
end
|
||||
|
||||
# Company policy permissions
|
||||
def new_company?
|
||||
create?
|
||||
end
|
||||
|
||||
def create_company?
|
||||
create?
|
||||
end
|
||||
|
||||
# Network type policy permissions
|
||||
def new_network_type?
|
||||
create?
|
||||
end
|
||||
|
||||
def create_network_type?
|
||||
create?
|
||||
end
|
||||
|
||||
class Scope < ApplicationPolicy::Scope
|
||||
def resolve
|
||||
if user.admin?
|
||||
# All authenticated users except viewers can view all policies
|
||||
# since WAF policies are system-wide security rules
|
||||
scope.all
|
||||
else
|
||||
# Non-admin users can only see their own policies
|
||||
scope.where(user: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -31,7 +31,8 @@ class EventNormalizer
|
||||
return unless hostname
|
||||
|
||||
host = RequestHost.find_or_create_host(hostname)
|
||||
host.increment_usage! unless host.new_record?
|
||||
# NOTE: usage_count increment removed for performance (was adding ~50ms per event)
|
||||
# Can be recalculated with: RequestHost.all.each { |h| h.update(usage_count: h.events.count) }
|
||||
@event.request_host = host
|
||||
end
|
||||
|
||||
@@ -83,7 +84,8 @@ class EventNormalizer
|
||||
|
||||
segment_ids = segments.map do |segment|
|
||||
path_segment = PathSegment.find_or_create_segment(segment)
|
||||
path_segment.increment_usage! unless path_segment.new_record?
|
||||
# NOTE: usage_count increment removed for performance (was adding ~100ms per event for paths with many segments)
|
||||
# Can be recalculated with: PathSegment.all.each { |ps| ps.update(usage_count: Event.where("request_segment_ids @> ARRAY[?]", ps.id).count) }
|
||||
path_segment.id
|
||||
end
|
||||
|
||||
|
||||
@@ -138,15 +138,38 @@ class GeoliteAsnImporter
|
||||
# Validate network format
|
||||
IPAddr.new(network) # This will raise if invalid
|
||||
|
||||
# Store raw GeoLite ASN data in network_data
|
||||
geolite_asn_data = {
|
||||
asn: {
|
||||
autonomous_system_number: asn,
|
||||
autonomous_system_organization: asn_org
|
||||
}
|
||||
}
|
||||
|
||||
# Use upsert with JSONB merge
|
||||
# COALESCE handles the case where network_data might be NULL
|
||||
# || is PostgreSQL's JSONB concatenation/merge operator
|
||||
# jsonb_set merges the nested geolite data
|
||||
NetworkRange.upsert(
|
||||
{
|
||||
network: network,
|
||||
asn: asn,
|
||||
asn_org: asn_org,
|
||||
source: 'geolite_asn',
|
||||
network_data: { geolite: geolite_asn_data },
|
||||
updated_at: Time.current
|
||||
},
|
||||
unique_by: :index_network_ranges_on_network_unique
|
||||
unique_by: :index_network_ranges_on_network_unique,
|
||||
on_duplicate: Arel.sql("
|
||||
asn = EXCLUDED.asn,
|
||||
asn_org = EXCLUDED.asn_org,
|
||||
network_data = COALESCE(network_ranges.network_data, '{}'::jsonb) ||
|
||||
jsonb_build_object('geolite',
|
||||
COALESCE(network_ranges.network_data->'geolite', '{}'::jsonb) ||
|
||||
EXCLUDED.network_data->'geolite'
|
||||
),
|
||||
updated_at = EXCLUDED.updated_at
|
||||
")
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -210,28 +210,46 @@ class GeoliteCountryImporter
|
||||
# Get location data - prefer geoname_id, then registered_country_geoname_id
|
||||
location_data = @locations_cache[geoname_id] || @locations_cache[registered_country_geoname_id] || {}
|
||||
|
||||
additional_data = {
|
||||
# Store raw GeoLite country data in network_data[:geolite]
|
||||
geolite_country_data = {
|
||||
country: {
|
||||
geoname_id: geoname_id,
|
||||
registered_country_geoname_id: registered_country_geoname_id,
|
||||
represented_country_geoname_id: row[:represented_country_geoname_id],
|
||||
continent_code: location_data[:continent_code],
|
||||
continent_name: location_data[:continent_name],
|
||||
country_name: location_data[:country_name],
|
||||
country_iso_code: location_data[:country_iso_code],
|
||||
is_in_european_union: location_data[:is_in_european_union],
|
||||
is_anonymous_proxy: is_anonymous_proxy,
|
||||
is_satellite_provider: is_satellite_provider,
|
||||
is_anycast: is_anycast
|
||||
}
|
||||
}.compact
|
||||
|
||||
# Use upsert with JSONB merge
|
||||
# COALESCE handles the case where network_data might be NULL
|
||||
# || is PostgreSQL's JSONB concatenation/merge operator
|
||||
NetworkRange.upsert(
|
||||
{
|
||||
network: network,
|
||||
country: location_data[:country_iso_code],
|
||||
is_proxy: is_anonymous_proxy,
|
||||
source: 'geolite_country',
|
||||
additional_data: additional_data,
|
||||
network_data: { geolite: geolite_country_data },
|
||||
updated_at: Time.current
|
||||
},
|
||||
unique_by: :index_network_ranges_on_network_unique
|
||||
unique_by: :index_network_ranges_on_network_unique,
|
||||
on_duplicate: Arel.sql("
|
||||
country = EXCLUDED.country,
|
||||
is_proxy = EXCLUDED.is_proxy,
|
||||
network_data = COALESCE(network_ranges.network_data, '{}'::jsonb) ||
|
||||
jsonb_build_object('geolite',
|
||||
COALESCE(network_ranges.network_data->'geolite', '{}'::jsonb) ||
|
||||
EXCLUDED.network_data->'geolite'
|
||||
),
|
||||
updated_at = EXCLUDED.updated_at
|
||||
")
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ class IpRangeResolver
|
||||
|
||||
Rule.network_rules
|
||||
.where(network_range_id: range_ids)
|
||||
.where(action: 'deny')
|
||||
.where(waf_action: :deny)
|
||||
.enabled
|
||||
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
||||
.exists?
|
||||
@@ -158,7 +158,7 @@ class IpRangeResolver
|
||||
|
||||
Rule.network_rules
|
||||
.where(network_range_id: range_ids)
|
||||
.where(action: 'deny')
|
||||
.where(waf_action: :deny)
|
||||
.enabled
|
||||
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
||||
.includes(:network_range)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
class Ipapi
|
||||
include HTTParty
|
||||
BASE_URL = "https://api.ipapi.is/"
|
||||
API_KEY = Rails.application.credentials.ipapi_key
|
||||
|
||||
def api_key = Setting.ipapi_key
|
||||
|
||||
def lookup(ip)
|
||||
response = self.class.get("#{BASE_URL}", query: { q: ip, key: API_KEY })
|
||||
return unless api_key.present?
|
||||
response = self.class.get("#{BASE_URL}", query: { q: ip, key: api_key })
|
||||
response.parsed_response
|
||||
end
|
||||
|
||||
@@ -19,7 +21,7 @@ end
|
||||
if ip.is_a?(Array)
|
||||
post_data(ip)
|
||||
else
|
||||
response = self.class.get("#{BASE_URL}", query: { q: ip, key: API_KEY })
|
||||
response = self.class.get("#{BASE_URL}", query: { q: ip, key: api_key })
|
||||
response.parsed_response
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
@@ -28,7 +30,7 @@ end
|
||||
|
||||
def post_data(ips)
|
||||
response = self.class.post("#{BASE_URL}",
|
||||
query: { key: API_KEY },
|
||||
query: { key: api_key },
|
||||
body: { ips: ips }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
@@ -39,7 +41,13 @@ end
|
||||
IPAddr.new(ip)
|
||||
cidr = data.dig("asn", "route")
|
||||
|
||||
NetworkRange.add_network(cidr).tap { |acl| acl&.update(ip_api_data: data) }
|
||||
network_range = NetworkRange.add_network(cidr)
|
||||
if network_range
|
||||
network_range.set_network_data(:ipapi, data)
|
||||
network_range.last_api_fetch = Time.current
|
||||
network_range.save
|
||||
end
|
||||
network_range
|
||||
rescue IPAddr::InvalidAddressError
|
||||
puts "Skipping #{ip}"
|
||||
next
|
||||
|
||||
@@ -24,22 +24,6 @@ class NetworkRangeGenerator
|
||||
IPAddr.new('ff00::/8') # IPv6 multicast
|
||||
].freeze
|
||||
|
||||
# Special network ranges to avoid
|
||||
RESERVED_RANGES = [
|
||||
IPAddr.new('10.0.0.0/8'), # Private
|
||||
IPAddr.new('172.16.0.0/12'), # Private
|
||||
IPAddr.new('192.168.0.0/16'), # Private
|
||||
IPAddr.new('127.0.0.0/8'), # Loopback
|
||||
IPAddr.new('169.254.0.0/16'), # Link-local
|
||||
IPAddr.new('224.0.0.0/4'), # Multicast
|
||||
IPAddr.new('240.0.0.0/4'), # Reserved
|
||||
IPAddr.new('::1/128'), # IPv6 loopback
|
||||
IPAddr.new('fc00::/7'), # IPv6 private
|
||||
IPAddr.new('fe80::/10'), # IPv6 link-local
|
||||
IPAddr.new('ff00::/8') # IPv6 multicast
|
||||
].freeze
|
||||
|
||||
|
||||
class << self
|
||||
# Find or create a network range for the given IP address
|
||||
def find_or_create_for_ip(ip_address, user: nil)
|
||||
|
||||
@@ -1,41 +1,48 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# WafPolicyMatcher - Service to match NetworkRanges against active WafPolicies
|
||||
# WafPolicyMatcher - Service to match Events against active WafPolicies
|
||||
#
|
||||
# This service provides efficient matching of network ranges against firewall policies
|
||||
# and can generate rules when matches are found.
|
||||
# This service provides efficient matching of events against firewall policies
|
||||
# (both network-based and path-based) and can generate rules when matches are found.
|
||||
class WafPolicyMatcher
|
||||
include ActiveModel::Model
|
||||
include ActiveModel::Attributes
|
||||
|
||||
attr_accessor :network_range
|
||||
attr_accessor :event
|
||||
attr_reader :matching_policies, :generated_rules
|
||||
|
||||
def initialize(network_range:)
|
||||
@network_range = network_range
|
||||
def initialize(event:)
|
||||
@event = event
|
||||
@matching_policies = []
|
||||
@generated_rules = []
|
||||
end
|
||||
|
||||
# Find all active policies that match the given network range
|
||||
def find_matching_policies
|
||||
return [] unless network_range.present?
|
||||
|
||||
@matching_policies = active_policies.select do |policy|
|
||||
policy.matches_network_range?(network_range)
|
||||
# Helper method to get network range from event
|
||||
def network_range
|
||||
event&.network_range
|
||||
end
|
||||
|
||||
# Sort by priority: country > asn > company > network_type, then by creation date
|
||||
# Find all active policies that match the given event (network or path-based)
|
||||
def find_matching_policies
|
||||
return [] unless event.present?
|
||||
|
||||
@matching_policies = active_policies.select do |policy|
|
||||
policy.matches_event?(event)
|
||||
end
|
||||
|
||||
# Sort by priority: path_pattern > country > asn > company > network_type, then by creation date
|
||||
@matching_policies.sort_by do |policy|
|
||||
priority_score = case policy.policy_type
|
||||
when 'path_pattern'
|
||||
1 # Highest priority for path-specific rules
|
||||
when 'country'
|
||||
1
|
||||
when 'asn'
|
||||
2
|
||||
when 'company'
|
||||
when 'asn'
|
||||
3
|
||||
when 'network_type'
|
||||
when 'company'
|
||||
4
|
||||
when 'network_type'
|
||||
5
|
||||
else
|
||||
99
|
||||
end
|
||||
@@ -49,22 +56,21 @@ class WafPolicyMatcher
|
||||
return [] if matching_policies.empty?
|
||||
|
||||
@generated_rules = matching_policies.map do |policy|
|
||||
# Check if rule already exists for this network range and policy
|
||||
existing_rule = Rule.find_by(
|
||||
network_range: network_range,
|
||||
waf_policy: policy,
|
||||
enabled: true
|
||||
)
|
||||
# Use the policy's event-based rule creation method
|
||||
rule = policy.create_rule_for_event(event)
|
||||
|
||||
if existing_rule
|
||||
Rails.logger.debug "Rule already exists for network_range #{network_range.cidr} and policy #{policy.name}"
|
||||
existing_rule
|
||||
else
|
||||
rule = policy.create_rule_for_network_range(network_range)
|
||||
if rule
|
||||
Rails.logger.info "Generated rule for network_range #{network_range.cidr} from policy #{policy.name}"
|
||||
end
|
||||
if rule.persisted?
|
||||
Rails.logger.info "Generated rule for event #{event.id} from policy #{policy.name}"
|
||||
rule
|
||||
else
|
||||
# Rule creation failed validation
|
||||
Rails.logger.warn "Failed to create rule for event #{event.id}: #{rule.errors.full_messages.join(', ')}"
|
||||
nil
|
||||
end
|
||||
else
|
||||
# Policy didn't match or returned nil (e.g., supernet already exists)
|
||||
nil
|
||||
end
|
||||
end.compact
|
||||
end
|
||||
@@ -73,14 +79,65 @@ class WafPolicyMatcher
|
||||
def match_and_generate_rules
|
||||
find_matching_policies
|
||||
generate_rules
|
||||
|
||||
# Return hash format expected by ProcessWafPoliciesJob
|
||||
{
|
||||
matching_policies: @matching_policies,
|
||||
generated_rules: @generated_rules
|
||||
}
|
||||
end
|
||||
|
||||
# Class methods for batch processing
|
||||
def self.process_network_range(network_range)
|
||||
matcher = new(network_range: network_range)
|
||||
def self.process_event(event)
|
||||
matcher = new(event: event)
|
||||
matcher.match_and_generate_rules
|
||||
end
|
||||
|
||||
# Legacy method for backward compatibility - converts network range to event
|
||||
def self.process_network_range(network_range)
|
||||
# Find the most recent event for this network range
|
||||
sample_event = network_range.events.order(created_at: :desc).first
|
||||
if sample_event
|
||||
process_event(sample_event)
|
||||
else
|
||||
# No events exist for this network range, return empty results
|
||||
# Network-based policies need real events to trigger rule creation
|
||||
{ matching_policies: [], generated_rules: [] }
|
||||
end
|
||||
end
|
||||
|
||||
# Evaluate an event against policies and mark its network range as evaluated
|
||||
# This is the main entry point for inline policy evaluation
|
||||
def self.evaluate_and_mark!(event)
|
||||
return { matching_policies: [], generated_rules: [] } unless event
|
||||
|
||||
matcher = new(event: event)
|
||||
result = matcher.match_and_generate_rules
|
||||
|
||||
# Mark the event's network range as evaluated
|
||||
if event.network_range
|
||||
event.network_range.update_column(:policies_evaluated_at, Time.current)
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# Legacy method for backward compatibility
|
||||
def self.evaluate_and_mark_network_range!(network_range)
|
||||
return { matching_policies: [], generated_rules: [] } unless network_range
|
||||
|
||||
# Find the most recent event for this network range
|
||||
sample_event = network_range.events.order(created_at: :desc).first
|
||||
if sample_event
|
||||
evaluate_and_mark!(sample_event)
|
||||
else
|
||||
# No events exist, use the old network-range based evaluation
|
||||
process_network_range(network_range)
|
||||
network_range.update_column(:policies_evaluated_at, Time.current)
|
||||
{ matching_policies: [], generated_rules: [] }
|
||||
end
|
||||
end
|
||||
|
||||
def self.batch_process_network_ranges(network_ranges)
|
||||
results = []
|
||||
|
||||
@@ -138,8 +195,19 @@ class WafPolicyMatcher
|
||||
potential_ranges.find_each do |network_range|
|
||||
matcher = new(network_range: network_range)
|
||||
if waf_policy.matches_network_range?(network_range)
|
||||
# Check for supernet rules before creating
|
||||
if network_range.supernet_rules.any?
|
||||
supernet = network_range.supernet_rules.first
|
||||
Rails.logger.info "Skipping rule for #{network_range.cidr} - covered by supernet rule ##{supernet.id}"
|
||||
next
|
||||
end
|
||||
|
||||
rule = waf_policy.create_rule_for_network_range(network_range)
|
||||
results << { network_range: network_range, generated_rule: rule } if rule
|
||||
if rule&.persisted?
|
||||
results << { network_range: network_range, generated_rule: rule }
|
||||
elsif rule
|
||||
Rails.logger.warn "Failed to create rule for #{network_range.cidr}: #{rule.errors.full_messages.join(', ')}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -160,7 +228,7 @@ class WafPolicyMatcher
|
||||
{
|
||||
policy_name: waf_policy.name,
|
||||
policy_type: waf_policy.policy_type,
|
||||
action: waf_policy.action,
|
||||
action: waf_policy.policy_action,
|
||||
rules_generated: rules.count,
|
||||
active_rules: rules.active.count,
|
||||
networks_protected: rules.joins(:network_range).count('distinct network_ranges.id'),
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<!-- Key Statistics Cards -->
|
||||
<div class="space-y-6">
|
||||
<div id="dashboard-stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div id="dashboard-stats" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||||
<!-- Total Events -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
@@ -148,6 +148,35 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Queue -->
|
||||
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 <%= job_queue_status_color(@job_statistics[:health_status]) %> rounded-md flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Job Queue</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@job_statistics[:pending_jobs]) %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-5 py-3">
|
||||
<div class="text-sm">
|
||||
<span class="<%= job_queue_status_text_color(@job_statistics[:health_status]) %> font-medium">
|
||||
<%= @job_statistics[:health_status].humanize %>
|
||||
</span>
|
||||
<span class="text-gray-500"> · <%= @job_statistics[:recent_enqueued] %> recent</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts and Detailed Analytics -->
|
||||
@@ -214,7 +243,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Secondary Information Rows -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
<!-- Top Countries -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
@@ -283,6 +312,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Queue Details -->
|
||||
<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">Job Queue Details</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-3">
|
||||
<% if @job_statistics[:queue_breakdown].any? %>
|
||||
<% @job_statistics[:queue_breakdown].each do |queue, count| %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="w-2 h-2 rounded-full mr-2
|
||||
<%= case queue.to_s
|
||||
when 'default' then 'bg-blue-500'
|
||||
when 'waf_policies' then 'bg-purple-500'
|
||||
when 'waf_events' then 'bg-green-500'
|
||||
else 'bg-gray-500'
|
||||
end %>"></div>
|
||||
<span class="text-sm text-gray-900"><%= queue.to_s.humanize %></span>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-gray-900"><%= number_with_delimiter(count) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="text-gray-500 text-center py-4">No job queue data available</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Blocked IPs -->
|
||||
|
||||
@@ -215,9 +215,10 @@
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<% unless data_import.processing? %>
|
||||
<%= link_to "Delete", data_import, method: :delete,
|
||||
<%= link_to "Delete", data_import,
|
||||
data: {
|
||||
confirm: "Are you sure you want to delete this import?"
|
||||
turbo_method: :delete,
|
||||
turbo_confirm: "Are you sure you want to delete this import?"
|
||||
},
|
||||
class: "text-red-600 hover:text-red-900" %>
|
||||
<% else %>
|
||||
|
||||
@@ -38,9 +38,10 @@
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= link_to "← Back to Imports", data_imports_path, class: "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 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" %>
|
||||
<% unless @data_import.processing? %>
|
||||
<%= link_to "Delete", @data_import, method: :delete,
|
||||
<%= link_to "Delete", @data_import,
|
||||
data: {
|
||||
confirm: "Are you sure you want to delete this import record?"
|
||||
turbo_method: :delete,
|
||||
turbo_confirm: "Are you sure you want to delete this import record?"
|
||||
},
|
||||
class: "inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
|
||||
<% end %>
|
||||
|
||||
@@ -16,58 +16,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Environment DSNs -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md mb-8">
|
||||
<div class="px-4 py-5 sm:px-6">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Environment DSNs</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">
|
||||
Default DSNs configured via environment variables for agent connectivity.
|
||||
</p>
|
||||
</div>
|
||||
<div class="border-t border-gray-200">
|
||||
<dl>
|
||||
<!-- BAFFLE_HOST DSN -->
|
||||
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">External DSN (BAFFLE_HOST)</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm font-mono">
|
||||
<%= @external_dsn %>
|
||||
</code>
|
||||
<button onclick="copyToClipboard('<%= @external_dsn %>')" class="text-blue-600 hover:text-blue-800 text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">Host: <%= ENV['BAFFLE_HOST'] || 'localhost:3000' %></p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<% if @internal_dsn.present? %>
|
||||
<!-- BAFFLE_INTERNAL_HOST DSN -->
|
||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Internal DSN (BAFFLE_INTERNAL_HOST)</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded text-sm font-mono">
|
||||
<%= @internal_dsn %>
|
||||
</code>
|
||||
<button onclick="copyToClipboard('<%= @internal_dsn %>')" class="text-blue-600 hover:text-blue-800 text-sm">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">Host: <%= ENV['BAFFLE_INTERNAL_HOST'] %></p>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database DSNs -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
@@ -120,6 +68,13 @@
|
||||
<%= link_to "Enable", enable_dsn_path(dsn), method: :post,
|
||||
class: "text-green-600 hover:text-green-900 text-sm font-medium" %>
|
||||
<% end %>
|
||||
<% if policy(dsn).destroy? && !dsn.enabled? %>
|
||||
<%= button_to "Delete", dsn, method: :delete,
|
||||
data: {
|
||||
confirm: "Are you sure you want to delete '#{dsn.name}'? This action cannot be undone."
|
||||
},
|
||||
class: "text-red-700 hover:text-red-900 text-sm font-medium font-semibold bg-transparent border-none cursor-pointer p-0" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -17,6 +17,13 @@
|
||||
<% if policy(@dsn).edit? %>
|
||||
<%= link_to "Edit", edit_dsn_path(@dsn), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700" %>
|
||||
<% end %>
|
||||
<% if policy(@dsn).destroy? && !@dsn.enabled? %>
|
||||
<%= button_to "Delete", @dsn, method: :delete,
|
||||
data: {
|
||||
confirm: "Are you sure you want to delete '#{@dsn.name}'? This action cannot be undone and the DSN key will be permanently removed."
|
||||
},
|
||||
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" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,7 +148,7 @@
|
||||
|
||||
<h4 class="mt-4">Query Parameter Authentication</h4>
|
||||
<p>Include the DSN key as a query parameter:</p>
|
||||
<pre class="bg-gray-100 p-3 rounded text-sm"><code>/api/events?baffle_key=<%= @dsn.key %></code></pre>
|
||||
<pre class="bg-gray-100 p-3 rounded text-sm"><code>/api/v1/events?baffle_key=<%= @dsn.key %></code></pre>
|
||||
|
||||
<h4 class="mt-4">X-Baffle-Auth Header</h4>
|
||||
<p>Use the custom Baffle authentication header:</p>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% @events.each do |event| %>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<tr class="hover:bg-gray-50 cursor-pointer" onclick="window.location='<%= event_path(event) %>'">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div class="text-gray-900" data-timeline-target="timestamp" data-iso="<%= event.timestamp.iso8601 %>">
|
||||
<%= event.timestamp.strftime("%H:%M:%S") %>
|
||||
@@ -133,11 +133,10 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
<% network_range = @network_ranges_by_ip[event.ip_address.to_s] %>
|
||||
<% network_range = event.network_range %>
|
||||
<% if network_range %>
|
||||
<%= link_to event.ip_address, network_range_path(network_range),
|
||||
<%= link_to event.ip_address, network_range_path(event.ip_address),
|
||||
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? %>
|
||||
@@ -161,7 +160,7 @@
|
||||
<% end %>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
<%= network_range.cidr %>
|
||||
<%= link_to network_range.cidr, network_range_path(network_range) %>
|
||||
<% if network_range.asn.present? %>
|
||||
• ASN <%= network_range.asn %>
|
||||
<% end %>
|
||||
@@ -184,8 +183,10 @@
|
||||
<%= event.waf_action %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 max-w-xs truncate" title="<%= event.request_path %>">
|
||||
<td class="px-6 py-4 text-sm font-mono text-gray-900">
|
||||
<div class="max-w-md break-all">
|
||||
<%= event.request_path || '-' %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<%= event.request_method ? event.request_method.upcase : '-' %>
|
||||
@@ -202,8 +203,35 @@
|
||||
<span class="text-gray-400">-</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900 max-w-xs truncate" title="<%= event.user_agent %>">
|
||||
<%= event.user_agent&.truncate(50) || '-' %>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
<% if event.user_agent.present? %>
|
||||
<% ua = parse_user_agent(event.user_agent) %>
|
||||
<div class="space-y-0.5" title="<%= ua[:raw] %>">
|
||||
<div class="font-medium text-gray-900">
|
||||
<%= ua[:name] if ua[:name].present? %>
|
||||
<% if ua[:version].present? && ua[:name].present? %>
|
||||
<span class="text-gray-500 font-normal"><%= ua[:version] %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if ua[:os_name].present? %>
|
||||
<div class="text-xs text-gray-500">
|
||||
<%= ua[:os_name] %>
|
||||
<% if ua[:os_version].present? %>
|
||||
<%= ua[:os_version] %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if ua[:bot] %>
|
||||
<div class="text-xs">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">
|
||||
🤖 <%= ua[:bot_name] || 'Bot' %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-gray-400">-</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
|
||||
344
app/views/events/show.html.erb
Normal file
344
app/views/events/show.html.erb
Normal file
@@ -0,0 +1,344 @@
|
||||
<% content_for :title, "Event #{@event.request_id} - Baffle Hub" %>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8" data-controller="timeline" data-timeline-mode-value="events">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<nav class="flex" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-4">
|
||||
<li>
|
||||
<%= link_to "Events", events_path, class: "text-gray-500 hover:text-gray-700" %>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center">
|
||||
<svg class="flex-shrink-0 h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="ml-4 text-gray-700 font-medium"><%= @event.request_id %></span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="mt-2 flex items-center space-x-3">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Event Details</h1>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||
<%= case @event.waf_action
|
||||
when 'allow' then 'bg-green-100 text-green-800'
|
||||
when 'deny' then 'bg-red-100 text-red-800'
|
||||
when 'redirect' then 'bg-blue-100 text-blue-800'
|
||||
when 'challenge' then 'bg-yellow-100 text-yellow-800'
|
||||
else 'bg-gray-100 text-gray-800'
|
||||
end %>">
|
||||
<%= @event.waf_action.upcase %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex space-x-3">
|
||||
<%= link_to "Back to Events", events_path, 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" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Overview -->
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Event Overview</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Request ID</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono break-all"><%= @event.request_id %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Timestamp</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<div data-timeline-target="timestamp" data-iso="<%= @event.timestamp.iso8601 %>">
|
||||
<%= @event.timestamp.strftime("%Y-%m-%d %H:%M:%S %Z") %>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-1">
|
||||
<%= time_ago_in_words(@event.timestamp) %> ago
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Action</dt>
|
||||
<dd class="mt-1">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||
<%= case @event.waf_action
|
||||
when 'allow' then 'bg-green-100 text-green-800'
|
||||
when 'deny' then 'bg-red-100 text-red-800'
|
||||
when 'redirect' then 'bg-blue-100 text-blue-800'
|
||||
when 'challenge' then 'bg-yellow-100 text-yellow-800'
|
||||
else 'bg-gray-100 text-gray-800'
|
||||
end %>">
|
||||
<%= @event.waf_action %>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<% if @event.rule.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Rule Matched</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<%= link_to "Rule ##{@event.rule.id}", @event.rule, class: "text-blue-600 hover:text-blue-800" %>
|
||||
<span class="text-gray-500">(<%= @event.rule.waf_rule_type %>)</span>
|
||||
<% if @event.waf_policy.present? %>
|
||||
<br>
|
||||
<span class="text-xs text-gray-500">Policy: <%= link_to @event.waf_policy.name, @event.waf_policy, class: "text-blue-600 hover:text-blue-800" %></span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @event.blocked_reason.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Blocked Reason</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @event.blocked_reason %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @event.response_status.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Response Status</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @event.response_status %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @event.response_time_ms.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Response Time</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @event.response_time_ms %> ms</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request Details -->
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Request Details</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Request URL</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono break-all"><%= @event.request_url || @event.request_path %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Request Path</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono break-all"><%= @event.request_path %></dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Method</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono"><%= @event.request_method ? @event.request_method.upcase : '-' %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Protocol</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono"><%= @event.request_protocol || '-' %></dd>
|
||||
</div>
|
||||
<% if @event.request_host %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Host</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono"><%= @event.request_host.hostname %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Intelligence -->
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Network Intelligence</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">IP Address</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if @network_range %>
|
||||
<%= link_to @event.ip_address, network_range_path(@event.ip_address),
|
||||
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono" %>
|
||||
<% else %>
|
||||
<span class="font-mono"><%= @event.ip_address %></span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% if @network_range %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Network Range</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<%= link_to @network_range.cidr, network_range_path(@network_range),
|
||||
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono" %>
|
||||
</dd>
|
||||
</div>
|
||||
<% if @network_range.company.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Company</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @network_range.company %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @network_range.asn.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">ASN</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<%= link_to "#{@network_range.asn} (#{@network_range.asn_org})", network_ranges_path(asn: @network_range.asn),
|
||||
class: "text-blue-600 hover:text-blue-900 hover:underline" %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @network_range.country.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Country</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<%= link_to @network_range.country, events_path(country: @network_range.country),
|
||||
class: "text-blue-600 hover:text-blue-900 hover:underline" %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @network_range.is_datacenter? || @network_range.is_vpn? || @network_range.is_proxy? %>
|
||||
<div class="md:col-span-2 lg:col-span-3">
|
||||
<dt class="text-sm font-medium text-gray-500 mb-2">Classification</dt>
|
||||
<dd class="flex flex-wrap gap-2">
|
||||
<% if @network_range.is_datacenter? %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">Datacenter</span>
|
||||
<% end %>
|
||||
<% if @network_range.is_vpn? %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">VPN</span>
|
||||
<% end %>
|
||||
<% if @network_range.is_proxy? %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">Proxy</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Agent -->
|
||||
<% if @event.user_agent.present? %>
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">User Agent</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<% ua = parse_user_agent(@event.user_agent) %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Browser</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if ua[:name].present? %>
|
||||
<%= ua[:name] %>
|
||||
<% if ua[:version].present? %>
|
||||
<span class="text-gray-500"><%= ua[:version] %></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="text-gray-400">-</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Operating System</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<% if ua[:os_name].present? %>
|
||||
<%= ua[:os_name] %>
|
||||
<% if ua[:os_version].present? %>
|
||||
<span class="text-gray-500"><%= ua[:os_version] %></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="text-gray-400">-</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Device Type</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= ua[:device_type]&.humanize || "-" %></dd>
|
||||
</div>
|
||||
<% if ua[:bot] %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Bot Detection</dt>
|
||||
<dd class="mt-1">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">
|
||||
🤖 <%= ua[:bot_name] || 'Bot' %>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="md:col-span-2 lg:col-span-3">
|
||||
<dt class="text-sm font-medium text-gray-500">Raw User Agent</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono break-all bg-gray-50 p-3 rounded"><%= @event.user_agent %></dd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Tags -->
|
||||
<% if @event.tags.any? %>
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Tags</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<% @event.tags.each do |tag| %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
<%= tag %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Server Information -->
|
||||
<% if @event.server_name.present? || @event.environment.present? || @event.agent_name.present? %>
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Server & Agent Information</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<% if @event.server_name.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Server Name</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @event.server_name %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @event.environment.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Environment</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @event.environment %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @event.agent_name.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Agent</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<%= @event.agent_name %>
|
||||
<% if @event.agent_version.present? %>
|
||||
<span class="text-gray-500">v<%= @event.agent_version %></span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Raw Payload -->
|
||||
<% if @event.payload.present? %>
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Raw Event Payload</h3>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<pre class="bg-gray-50 p-4 rounded-md text-xs overflow-x-auto"><%= JSON.pretty_generate(@event.payload) %></pre>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -21,9 +21,6 @@
|
||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
||||
|
||||
<%# Tom Select CSS for enhanced multi-select %>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" data-turbo-track="reload">
|
||||
|
||||
<%= javascript_importmap_tags %>
|
||||
|
||||
<style>
|
||||
@@ -122,6 +119,10 @@
|
||||
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
|
||||
👥 Manage Users
|
||||
<% end %>
|
||||
<%= link_to settings_path,
|
||||
class: "block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" do %>
|
||||
🔧 Settings
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="border-t border-gray-100"></div>
|
||||
|
||||
87
app/views/network_ranges/_geolite_data.html.erb
Normal file
87
app/views/network_ranges/_geolite_data.html.erb
Normal file
@@ -0,0 +1,87 @@
|
||||
<% geolite_data = network_range.network_data_for(:geolite) %>
|
||||
|
||||
<% if geolite_data.present? %>
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">MaxMind GeoLite2 Data</h3>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- ASN Data -->
|
||||
<% if geolite_data['asn'].present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">ASN (MaxMind)</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
AS<%= geolite_data['asn']['autonomous_system_number'] %>
|
||||
<% if geolite_data['asn']['autonomous_system_organization'].present? %>
|
||||
<div class="text-xs text-gray-600"><%= geolite_data['asn']['autonomous_system_organization'] %></div>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Country Data -->
|
||||
<% if geolite_data['country'].present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Country (MaxMind)</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<%= geolite_data['country']['country_name'] || geolite_data['country']['country_iso_code'] %>
|
||||
<% if geolite_data['country']['country_iso_code'].present? %>
|
||||
<span class="ml-2 text-lg"><%= country_flag(geolite_data['country']['country_iso_code']) %></span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<% if geolite_data['country']['continent_name'].present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Continent</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<%= geolite_data['country']['continent_name'] %>
|
||||
<span class="text-xs text-gray-500">(<%= geolite_data['country']['continent_code'] %>)</span>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if geolite_data['country']['geoname_id'].present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">GeoName ID</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 font-mono">
|
||||
<%= geolite_data['country']['geoname_id'] %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Flags -->
|
||||
<div class="md:col-span-2 lg:col-span-3">
|
||||
<dt class="text-sm font-medium text-gray-500 mb-2">MaxMind Flags</dt>
|
||||
<dd class="flex flex-wrap gap-2">
|
||||
<% if geolite_data['country']['is_anonymous_proxy'] %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">Anonymous Proxy</span>
|
||||
<% end %>
|
||||
<% if geolite_data['country']['is_satellite_provider'] %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">Satellite Provider</span>
|
||||
<% end %>
|
||||
<% if geolite_data['country']['is_anycast'] %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">Anycast</span>
|
||||
<% end %>
|
||||
<% if geolite_data['country']['is_in_european_union'] == "1" %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-600 text-white">🇪🇺 EU Member</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Raw GeoLite Data (collapsible) -->
|
||||
<details class="mt-6 pt-6 border-t border-gray-200">
|
||||
<summary class="cursor-pointer text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
Show Raw MaxMind Data
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
<pre class="bg-gray-50 p-3 rounded-md text-xs overflow-x-auto"><%= JSON.pretty_generate(geolite_data) %></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
112
app/views/network_ranges/_ipapi_data.html.erb
Normal file
112
app/views/network_ranges/_ipapi_data.html.erb
Normal file
@@ -0,0 +1,112 @@
|
||||
<div id="ipapi_data_section" class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">IPAPI Enrichment Data</h3>
|
||||
</div>
|
||||
|
||||
<% if ipapi_loading %>
|
||||
<div class="px-6 py-8 text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p class="mt-2 text-sm text-gray-500">Fetching enrichment data...</p>
|
||||
</div>
|
||||
<% elsif ipapi_data.present? %>
|
||||
<div class="px-6 py-4">
|
||||
<% if parent_with_ipapi %>
|
||||
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-blue-600 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-sm text-blue-800">
|
||||
Data inherited from parent network <%= link_to parent_with_ipapi.cidr, network_range_path(parent_with_ipapi), class: "font-mono font-medium hover:underline" %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<% if ipapi_data['asn'].present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">ASN (IPAPI)</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
AS<%= ipapi_data['asn']['asn'] %>
|
||||
<% if ipapi_data['asn']['org'].present? %>
|
||||
<div class="text-xs text-gray-600"><%= ipapi_data['asn']['org'] %></div>
|
||||
<% end %>
|
||||
<% if ipapi_data['asn']['route'].present? %>
|
||||
<div class="text-xs text-gray-500 font-mono"><%= ipapi_data['asn']['route'] %></div>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if ipapi_data['location'].present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Location</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<%= [ipapi_data['location']['city'], ipapi_data['location']['state'], ipapi_data['location']['country']].compact.join(', ') %>
|
||||
<% if ipapi_data['location']['country_code'].present? %>
|
||||
<span class="ml-2 text-lg"><%= country_flag(ipapi_data['location']['country_code']) %></span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if ipapi_data['company'].present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Company (IPAPI)</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<%= ipapi_data['company']['name'] %>
|
||||
<% if ipapi_data['company']['type'].present? %>
|
||||
<div class="text-xs text-gray-600"><%= ipapi_data['company']['type'].humanize %></div>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if ipapi_data['is_datacenter'] || ipapi_data['is_vpn'] || ipapi_data['is_proxy'] || ipapi_data['is_tor'] %>
|
||||
<div class="md:col-span-2 lg:col-span-3">
|
||||
<dt class="text-sm font-medium text-gray-500 mb-2">IPAPI Flags</dt>
|
||||
<dd class="flex flex-wrap gap-2">
|
||||
<% if ipapi_data['is_datacenter'] %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">Datacenter</span>
|
||||
<% end %>
|
||||
<% if ipapi_data['is_vpn'] %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">VPN</span>
|
||||
<% end %>
|
||||
<% if ipapi_data['is_proxy'] %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">Proxy</span>
|
||||
<% end %>
|
||||
<% if ipapi_data['is_tor'] %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-800 text-white">Tor</span>
|
||||
<% end %>
|
||||
<% if ipapi_data['is_abuser'] %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-600 text-white">Abuser</span>
|
||||
<% end %>
|
||||
<% if ipapi_data['is_bogon'] %>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">Bogon</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Raw IPAPI Data (collapsible) -->
|
||||
<details class="mt-6 pt-6 border-t border-gray-200">
|
||||
<summary class="cursor-pointer text-sm font-medium text-gray-700 hover:text-gray-900">
|
||||
Show Raw IPAPI Data
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
<pre class="bg-gray-50 p-3 rounded-md text-xs overflow-x-auto"><%= JSON.pretty_generate(ipapi_data) %></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-6 py-8 text-center text-gray-500">
|
||||
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No IPAPI data available</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Enrichment data will be fetched automatically.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,5 +1,9 @@
|
||||
<% content_for :title, "#{@network_range.cidr} - Network Range Details" %>
|
||||
|
||||
<% if @network_range.persisted? %>
|
||||
<%= turbo_stream_from "network_range_#{@network_range.id}" %>
|
||||
<% end %>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
@@ -48,6 +52,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IPAPI Enrichment Data -->
|
||||
<% if @network_range.persisted? %>
|
||||
<%= render partial: "network_ranges/ipapi_data", locals: {
|
||||
ipapi_data: @ipapi_data,
|
||||
network_range: @network_range,
|
||||
parent_with_ipapi: @parent_with_ipapi,
|
||||
ipapi_loading: @ipapi_loading || false
|
||||
} %>
|
||||
<% end %>
|
||||
|
||||
<!-- MaxMind GeoLite2 Data -->
|
||||
<% if @network_range.persisted? %>
|
||||
<%= render partial: "network_ranges/geolite_data", locals: {
|
||||
network_range: @network_range
|
||||
} %>
|
||||
<% end %>
|
||||
|
||||
<!-- Network Intelligence Card -->
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
@@ -68,6 +89,21 @@
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @network_range.ipv4? ? "IPv4" : "IPv6" %></dd>
|
||||
</div>
|
||||
|
||||
<!-- Supernet display -->
|
||||
<% parent_with_intelligence = @network_range.parent_with_intelligence %>
|
||||
<% if parent_with_intelligence && parent_with_intelligence.cidr != @network_range.cidr %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Supernet</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900">
|
||||
<%= link_to parent_with_intelligence.cidr, network_range_path(parent_with_intelligence),
|
||||
class: "text-blue-600 hover:text-blue-800 hover:underline font-mono" %>
|
||||
<% if parent_with_intelligence.company.present? %>
|
||||
<span class="ml-2 text-xs text-gray-500">(<%= parent_with_intelligence.company %>)</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @network_range.asn.present? %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">ASN</dt>
|
||||
@@ -215,17 +251,50 @@
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @network_range.persisted? && @network_range.agent_tally.any? %>
|
||||
<div class="border-t border-gray-200 pt-4">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">Top User Agents</h4>
|
||||
<div class="space-y-1">
|
||||
<% @network_range.agent_tally.sort_by { |ua, count| -count }.first(5).each do |user_agent, count| %>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600 truncate" title="<%= user_agent %>">
|
||||
<% if user_agent.present? %>
|
||||
<% ua = parse_user_agent(user_agent) %>
|
||||
<% if ua[:name].present? %>
|
||||
<%= ua[:name] %>
|
||||
<% if ua[:version].present? %>
|
||||
<span class="text-gray-400">(<%= ua[:version] %>)</span>
|
||||
<% end %>
|
||||
<% if ua[:bot] %>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-orange-100 text-orange-800 ml-1">
|
||||
🤖 <%= ua[:bot_name] || 'Bot' %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= truncate(user_agent, length: 50) %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<em class="text-gray-400">Unknown</em>
|
||||
<% end %>
|
||||
</span>
|
||||
<span class="font-medium"><%= count %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Associated Rules -->
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="bg-white shadow rounded-lg mb-6" data-controller="quick-create-rule">
|
||||
<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">
|
||||
<button type="button" data-action="click->quick-create-rule#toggle" data-quick-create-rule-target="toggle" 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>
|
||||
@@ -239,7 +308,7 @@
|
||||
|
||||
<!-- Quick Create Rule Form -->
|
||||
<% if @network_range.persisted? %>
|
||||
<div id="quick_create_rule" class="hidden border-b border-gray-200">
|
||||
<div id="quick_create_rule" data-quick-create-rule-target="form" 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,
|
||||
class: "space-y-4",
|
||||
@@ -276,39 +345,39 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Rule Type -->
|
||||
<div>
|
||||
<%= form.label :rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :rule_type,
|
||||
<%= form.label :waf_rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :waf_rule_type,
|
||||
options_for_select([
|
||||
['Network - IP/CIDR based blocking', 'network'],
|
||||
['Rate Limit - Request rate limiting', 'rate_limit'],
|
||||
['Path Pattern - URL path filtering', 'path_pattern'],
|
||||
['Header Pattern - HTTP header filtering', 'header_pattern'],
|
||||
['Query Pattern - Query parameter filtering', 'query_pattern'],
|
||||
['Body Signature - Request body filtering', 'body_signature']
|
||||
['Path Pattern - URL path filtering', 'path_pattern']
|
||||
], 'network'),
|
||||
{ },
|
||||
{
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
id: "quick_rule_type_select",
|
||||
onchange: "toggleRuleTypeFields()"
|
||||
data: { quick_create_rule_target: "ruleTypeSelect", action: "change->quick-create-rule#updateRuleTypeFields" }
|
||||
} %>
|
||||
<p class="mt-1 text-xs text-gray-500">Select the type of rule to create</p>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div>
|
||||
<%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :action,
|
||||
<%= form.label :waf_action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :waf_action,
|
||||
options_for_select([
|
||||
['Deny - Block requests', 'deny'],
|
||||
['Allow - Whitelist requests', 'allow'],
|
||||
['Rate Limit - Throttle requests', 'rate_limit'],
|
||||
['Redirect - Redirect to URL', 'redirect'],
|
||||
['Challenge - Present CAPTCHA', 'challenge'],
|
||||
['Monitor - Log but allow', 'monitor']
|
||||
['Log - Log but allow', 'log']
|
||||
], 'deny'),
|
||||
{ },
|
||||
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||
{
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
data: { quick_create_rule_target: "actionSelect", action: "change->quick-create-rule#updateRuleTypeFields" }
|
||||
} %>
|
||||
<p class="mt-1 text-xs text-gray-500">Action to take when rule matches</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,9 +386,12 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<%= form.label :expires_at, "Expires At (Optional)", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.datetime_local_field :expires_at,
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Leave blank for permanent rule</p>
|
||||
<%= form.text_field :expires_at,
|
||||
placeholder: "YYYY-MM-DD HH:MM (24-hour format, optional)",
|
||||
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
data: { quick_create_rule_target: "expiresAtField" },
|
||||
autocomplete: "off" %>
|
||||
<p class="mt-1 text-xs text-gray-500">Leave blank for permanent rule. Format: YYYY-MM-DD HH:MM (e.g., 2024-12-31 23:59)</p>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 flex items-center pt-6">
|
||||
@@ -331,19 +403,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Pattern-based Rule Fields -->
|
||||
<div id="pattern_fields" class="hidden space-y-4">
|
||||
<div id="pattern_fields" data-quick-create-rule-target="patternFields" class="hidden space-y-4">
|
||||
<div>
|
||||
<%= form.label :conditions, "Pattern/Conditions", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :conditions, rows: 3,
|
||||
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: "Enter pattern or JSON conditions...",
|
||||
id: "quick_conditions_field" %>
|
||||
<p class="mt-1 text-xs text-gray-500" id="pattern_help_text">Pattern will be used for matching</p>
|
||||
id: "quick_conditions_field",
|
||||
data: { quick_create_rule_target: "conditionsField" } %>
|
||||
<p class="mt-1 text-xs text-gray-500" id="pattern_help_text" data-quick-create-rule-target="helpText">Pattern will be used for matching</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rate Limit Fields -->
|
||||
<div id="rate_limit_fields" class="hidden space-y-4">
|
||||
<div id="rate_limit_fields" data-quick-create-rule-target="rateLimitFields" class="hidden space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<%= label_tag :rate_limit, "Request Limit", class: "block text-sm font-medium text-gray-700" %>
|
||||
@@ -363,7 +436,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Redirect Fields -->
|
||||
<div id="redirect_fields" class="hidden space-y-4">
|
||||
<div id="redirect_fields" data-quick-create-rule-target="redirectFields" class="hidden space-y-4">
|
||||
<div>
|
||||
<%= label_tag :redirect_url, "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= text_field_tag :redirect_url,
|
||||
@@ -405,7 +478,7 @@
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="flex justify-end space-x-3 pt-4 border-t border-blue-200">
|
||||
<button type="button" onclick="toggleQuickCreateRule()" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
<button type="button" data-action="click->quick-create-rule#toggle" class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
<%= form.submit "Create Rule", class: "px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700" %>
|
||||
@@ -425,13 +498,13 @@
|
||||
<div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-medium text-gray-900">
|
||||
<%= rule.action.upcase %> <%= rule.cidr %>
|
||||
<%= rule.waf_action.upcase %> <%= rule.cidr %>
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
Priority: <%= rule.priority %>
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
<%= rule.rule_type.humanize %>
|
||||
<%= rule.waf_rule_type.humanize %>
|
||||
</span>
|
||||
<% if rule.source.include?('surgical') %>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||
@@ -442,9 +515,9 @@
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
Created <%= time_ago_in_words(rule.created_at) %> ago by <%= rule.user&.email_address || 'System' %>
|
||||
</div>
|
||||
<% if rule.metadata&.dig('reason').present? %>
|
||||
<% if rule.metadata_hash['reason'].present? %>
|
||||
<div class="mt-1 text-sm text-gray-600">
|
||||
Reason: <%= rule.metadata['reason'] %>
|
||||
Reason: <%= rule.metadata_hash['reason'] %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -480,23 +553,52 @@
|
||||
<!-- Network Relationships -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- Parent Ranges -->
|
||||
<% if @parent_ranges.any? %>
|
||||
<% if @parent_ranges.any? || @supernet_rules.any? %>
|
||||
<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">Parent Network Ranges</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Supernet Ranges
|
||||
<% if @supernet_rules.any? %>
|
||||
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= @supernet_rules.count %> <%= 'rule'.pluralize(@supernet_rules.count) %>
|
||||
</span>
|
||||
<% end %>
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">Broader networks that contain this range</p>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
<% @parent_ranges.each do |parent| %>
|
||||
<div class="px-6 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<%= link_to parent.cidr, network_range_path(parent), class: "text-sm font-medium text-gray-900 hover:text-blue-600" %>
|
||||
<div class="text-sm text-gray-500">
|
||||
Prefix: /<%= parent.prefix_length %> |
|
||||
<% if parent.company.present? %><%= parent.company %> | <% end %>
|
||||
<%= parent.source %>
|
||||
</div>
|
||||
<%# Show rules for this parent %>
|
||||
<% parent_rules = @supernet_rules.select { |r| r.network_range_id == parent.id } %>
|
||||
<% if parent_rules.any? %>
|
||||
<div class="mt-2 pl-3 border-l-2 border-blue-200 space-y-1">
|
||||
<% parent_rules.each do |rule| %>
|
||||
<%= render 'rules/compact_rule', rule: rule %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%# Show supernet rules that don't have a parent range loaded %>
|
||||
<% orphan_supernet_rules = @supernet_rules.reject { |r| @parent_ranges.map(&:id).include?(r.network_range_id) } %>
|
||||
<% if orphan_supernet_rules.any? %>
|
||||
<div class="px-6 py-3 bg-gray-50">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">Additional Supernet Rules</div>
|
||||
<div class="space-y-1">
|
||||
<% orphan_supernet_rules.each do |rule| %>
|
||||
<%= render 'rules/compact_rule', rule: rule %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -505,23 +607,52 @@
|
||||
<% end %>
|
||||
|
||||
<!-- Child Ranges -->
|
||||
<% if @child_ranges.any? %>
|
||||
<% if @child_ranges.any? || @subnet_rules.any? %>
|
||||
<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">Child Network Ranges</h3>
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Subnet Ranges
|
||||
<% if @subnet_rules.any? %>
|
||||
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<%= @subnet_rules.count %> <%= 'rule'.pluralize(@subnet_rules.count) %>
|
||||
</span>
|
||||
<% end %>
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">More specific networks within this range</p>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200">
|
||||
<% @child_ranges.each do |child| %>
|
||||
<div class="px-6 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<%= link_to child.cidr, network_range_path(child), class: "text-sm font-medium text-gray-900 hover:text-blue-600" %>
|
||||
<div class="text-sm text-gray-500">
|
||||
Prefix: /<%= child.prefix_length %> |
|
||||
<% if child.company.present? %><%= child.company %> | <% end %>
|
||||
<%= child.source %>
|
||||
</div>
|
||||
<%# Show rules for this child %>
|
||||
<% child_rules = @subnet_rules.select { |r| r.network_range_id == child.id } %>
|
||||
<% if child_rules.any? %>
|
||||
<div class="mt-2 pl-3 border-l-2 border-green-200 space-y-1">
|
||||
<% child_rules.each do |rule| %>
|
||||
<%= render 'rules/compact_rule', rule: rule %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%# Show subnet rules that don't have a child range loaded %>
|
||||
<% orphan_subnet_rules = @subnet_rules.reject { |r| @child_ranges.map(&:id).include?(r.network_range_id) } %>
|
||||
<% if orphan_subnet_rules.any? %>
|
||||
<div class="px-6 py-3 bg-gray-50">
|
||||
<div class="text-sm font-medium text-gray-700 mb-2">Additional Subnet Rules</div>
|
||||
<div class="space-y-1">
|
||||
<% orphan_subnet_rules.each do |rule| %>
|
||||
<%= render 'rules/compact_rule', rule: rule %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -532,9 +663,22 @@
|
||||
|
||||
<!-- Recent Events -->
|
||||
<% if @related_events.any? %>
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="bg-white shadow rounded-lg" data-controller="timeline" data-timeline-mode-value="events">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Recent Events (<%= @related_events.count %>)</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">Recent Events (<%= number_with_delimiter(@events_pagy.count) %>)</h3>
|
||||
<% if @events_pagy.pages > 1 %>
|
||||
<span class="text-sm text-gray-500">
|
||||
Page <%= @events_pagy.page %> of <%= @events_pagy.pages %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<!-- Top Pagination -->
|
||||
<% if @events_pagy.pages > 1 %>
|
||||
<div class="mt-4">
|
||||
<%= pagy_nav_tailwind(@events_pagy, pagy_id: 'network_events_top') %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
@@ -548,16 +692,23 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% @related_events.first(20).each do |event| %>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<% @related_events.each do |event| %>
|
||||
<tr class="hover:bg-gray-50 cursor-pointer" onclick="window.location='<%= event_path(event) %>'">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<div class="text-gray-900" data-timeline-target="timestamp" data-iso="<%= event.timestamp.iso8601 %>">
|
||||
<%= event.timestamp.strftime("%H:%M:%S") %>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500" data-timeline-target="date" data-iso="<%= event.timestamp.iso8601 %>">
|
||||
<%= 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>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
<div class="max-w-md break-all">
|
||||
<%= event.request_path || "-" %>
|
||||
</div>
|
||||
</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 <%= event.waf_action == 'deny' ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' %>">
|
||||
@@ -565,98 +716,46 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-900">
|
||||
<div class="truncate max-w-xs" title="<%= event.user_agent %>">
|
||||
<%= event.user_agent&.truncate(50) || "-" %>
|
||||
<% if event.user_agent.present? %>
|
||||
<% ua = parse_user_agent(event.user_agent) %>
|
||||
<div class="space-y-0.5" title="<%= ua[:raw] %>">
|
||||
<div class="font-medium text-gray-900">
|
||||
<%= ua[:name] if ua[:name].present? %>
|
||||
<% if ua[:version].present? && ua[:name].present? %>
|
||||
<span class="text-gray-500 font-normal"><%= ua[:version] %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if ua[:os_name].present? %>
|
||||
<div class="text-xs text-gray-500">
|
||||
<%= ua[:os_name] %>
|
||||
<% if ua[:os_version].present? %>
|
||||
<%= ua[:os_version] %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if ua[:bot] %>
|
||||
<div class="text-xs">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full bg-orange-100 text-orange-800">
|
||||
🤖 <%= ua[:bot_name] || 'Bot' %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-gray-400">-</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Pagination -->
|
||||
<% if @events_pagy.pages > 1 %>
|
||||
<%= pagy_nav_tailwind(@events_pagy, pagy_id: 'network_events_bottom') %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleQuickCreateRule() {
|
||||
const formDiv = document.getElementById('quick_create_rule');
|
||||
formDiv.classList.toggle('hidden');
|
||||
|
||||
// Reset form when hiding
|
||||
if (formDiv.classList.contains('hidden')) {
|
||||
resetQuickCreateForm();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRuleTypeFields() {
|
||||
const ruleType = document.getElementById('quick_rule_type_select').value;
|
||||
const action = document.querySelector('select[name="rule[action]"]').value;
|
||||
|
||||
// Hide all optional fields
|
||||
document.getElementById('pattern_fields').classList.add('hidden');
|
||||
document.getElementById('rate_limit_fields').classList.add('hidden');
|
||||
document.getElementById('redirect_fields').classList.add('hidden');
|
||||
|
||||
// Show relevant fields based on rule type
|
||||
if (['path_pattern', 'header_pattern', 'query_pattern', 'body_signature'].includes(ruleType)) {
|
||||
document.getElementById('pattern_fields').classList.remove('hidden');
|
||||
updatePatternHelpText(ruleType);
|
||||
} else if (ruleType === 'rate_limit') {
|
||||
document.getElementById('rate_limit_fields').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Show redirect fields if action is redirect
|
||||
if (action === 'redirect') {
|
||||
document.getElementById('redirect_fields').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePatternHelpText(ruleType) {
|
||||
const helpText = document.getElementById('pattern_help_text');
|
||||
const conditionsField = document.getElementById('quick_conditions_field');
|
||||
|
||||
switch(ruleType) {
|
||||
case 'path_pattern':
|
||||
helpText.textContent = 'Regex pattern to match URL paths (e.g.,\\.env$|wp-admin|phpmyadmin)';
|
||||
conditionsField.placeholder = 'Example: \\.env$|\\.git|config\\.php|wp-admin';
|
||||
break;
|
||||
case 'header_pattern':
|
||||
helpText.textContent = 'JSON with header_name and pattern (e.g., {"header_name": "User-Agent", "pattern": "bot.*"})';
|
||||
conditionsField.placeholder = 'Example: {"header_name": "User-Agent", "pattern": ".*[Bb]ot.*"}';
|
||||
break;
|
||||
case 'query_pattern':
|
||||
helpText.textContent = 'Regex pattern to match query parameters (e.g., union.*select|<script)';
|
||||
conditionsField.placeholder = 'Example: (?:union|select|insert|update|delete).*\\s+(?:union|select)';
|
||||
break;
|
||||
case 'body_signature':
|
||||
helpText.textContent = 'Regex pattern to match request body content (e.g., OR 1=1|<script)';
|
||||
conditionsField.placeholder = 'Example: (?:OR\\s+1\\s*=\\s*1|AND\\s+1\\s*=\\s*1|UNION\\s+SELECT)';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function resetQuickCreateForm() {
|
||||
const form = document.querySelector('#quick_create_rule form');
|
||||
if (form) {
|
||||
form.reset();
|
||||
// Reset rule type to default
|
||||
document.getElementById('quick_rule_type_select').value = 'network';
|
||||
toggleRuleTypeFields();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the form visibility state
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set up action change listener to show/hide redirect fields
|
||||
const actionSelect = document.querySelector('select[name="rule[action]"]');
|
||||
if (actionSelect) {
|
||||
actionSelect.addEventListener('change', function() {
|
||||
toggleRuleTypeFields();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize field visibility
|
||||
toggleRuleTypeFields();
|
||||
});
|
||||
</script>
|
||||
33
app/views/rules/_compact_rule.html.erb
Normal file
33
app/views/rules/_compact_rule.html.erb
Normal file
@@ -0,0 +1,33 @@
|
||||
<%# Compact rule display for showing rules on network range pages %>
|
||||
<div class="flex items-center justify-between text-sm py-1.5 hover:bg-gray-50 px-2 -mx-2 rounded">
|
||||
<div class="flex items-center space-x-2 min-w-0 flex-1">
|
||||
<%= link_to rule, class: "flex items-center space-x-2 min-w-0 hover:text-blue-600" do %>
|
||||
<%# Action badge %>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= rule.waf_action == 'deny' ? 'bg-red-100 text-red-800' : rule.waf_action == 'allow' ? 'bg-green-100 text-green-800' : 'bg-blue-100 text-blue-800' %>">
|
||||
<%= rule.waf_action.upcase %>
|
||||
</span>
|
||||
|
||||
<%# Network CIDR %>
|
||||
<span class="font-mono text-gray-900 truncate"><%= rule.network_range.cidr %></span>
|
||||
|
||||
<%# Priority %>
|
||||
<span class="text-xs text-gray-500">P:<%= rule.priority %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2 flex-shrink-0">
|
||||
<%# Disabled badge %>
|
||||
<% unless rule.enabled? %>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-200 text-gray-600" title="<%= rule.metadata_hash['disabled_reason'] %>">
|
||||
Disabled
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Policy badge if policy-generated %>
|
||||
<% if rule.waf_policy.present? %>
|
||||
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-purple-100 text-purple-800" title="<%= rule.waf_policy.name %>">
|
||||
Policy
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,9 +44,9 @@
|
||||
<div class="px-6 py-4 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :rule_type,
|
||||
options_for_select(@rule_types.map { |type| [type.humanize, type] }, @rule.rule_type),
|
||||
<%= form.label :waf_rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :waf_rule_type,
|
||||
options_for_select(@waf_rule_types.map { |type, _| [type.humanize, type] }, @rule.waf_rule_type),
|
||||
{ prompt: "Select rule 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",
|
||||
id: "rule_type_select",
|
||||
@@ -55,9 +55,9 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :action,
|
||||
options_for_select(@actions.map { |action| [action.humanize, action] }, @rule.action),
|
||||
<%= form.label :waf_action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :waf_action,
|
||||
options_for_select(@waf_actions.map { |action, _| [action.humanize, action] }, @rule.waf_action),
|
||||
{ },
|
||||
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||
<p class="mt-2 text-sm text-gray-500">What action to take when this rule matches</p>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Rules</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@rules.count) %></dd>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.count) %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,26 +42,8 @@
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Active Rules</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@rules.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-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Block Rules</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@rules.where(action: 'deny').count) %></dd>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Active Block Rules</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.deny.active.count) %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,13 +55,31 @@
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Disabled Rules</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.where(enabled: false).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-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Expired Rules</dt>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@rules.expired.count) %></dd>
|
||||
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(Rule.expired.count) %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rule</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">Action</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Target</th>
|
||||
<th class="px-6 py-3 text-center 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">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
@@ -123,54 +123,77 @@
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% @rules.each do |rule| %>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<td class="px-6 py-4">
|
||||
<div class="space-y-1">
|
||||
<!-- Rule name -->
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<%= link_to "Rule ##{rule.id}", rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<%= rule.source.humanize %>
|
||||
</div>
|
||||
|
||||
<!-- Policy (if created by a policy) -->
|
||||
<% if rule.waf_policy.present? %>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">Policy</div>
|
||||
<div class="text-sm">
|
||||
<%= link_to rule.waf_policy.name, waf_policy_path(rule.waf_policy), class: "text-blue-600 hover:text-blue-900" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- IP network -->
|
||||
<% if rule.network_range? && rule.network_range %>
|
||||
• <%= link_to rule.network_range.cidr, network_range_path(rule.network_range), class: "text-blue-600 hover:text-blue-900" %>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">IP network</div>
|
||||
<div class="text-sm text-gray-900">
|
||||
<%= link_to rule.network_range.cidr, network_range_path(rule.network_range), class: "text-blue-600 hover:text-blue-900" %>
|
||||
<% if rule.network_range.company.present? %>
|
||||
<div class="text-xs text-gray-500"><%= rule.network_range.company %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% elsif rule.conditions.present? && rule.conditions&.dig('cidr').present? %>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500">IP network</div>
|
||||
<div class="text-sm text-gray-900">
|
||||
<%= rule.conditions['cidr'] %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</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 <%=
|
||||
case rule.rule_type
|
||||
case rule.waf_rule_type
|
||||
when 'network' then 'bg-blue-100 text-blue-800'
|
||||
when 'rate_limit' then 'bg-yellow-100 text-yellow-800'
|
||||
when 'path_pattern' then 'bg-purple-100 text-purple-800'
|
||||
else 'bg-gray-100 text-gray-800'
|
||||
end %>">
|
||||
<%= rule.rule_type.humanize %>
|
||||
<%= rule.waf_rule_type.humanize %>
|
||||
</span>
|
||||
</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 <%=
|
||||
case rule.action
|
||||
case rule.waf_action
|
||||
when 'allow' then 'bg-green-100 text-green-800'
|
||||
when 'deny' then 'bg-red-100 text-red-800'
|
||||
when 'rate_limit' then 'bg-yellow-100 text-yellow-800'
|
||||
when 'redirect' then 'bg-indigo-100 text-indigo-800'
|
||||
when 'log' then 'bg-gray-100 text-gray-800'
|
||||
when 'challenge' then 'bg-orange-100 text-orange-800'
|
||||
else 'bg-gray-100 text-gray-800'
|
||||
end %>">
|
||||
<%= rule.action.upcase %>
|
||||
<%= rule.waf_action.upcase %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<% if rule.network_range? && rule.network_range %>
|
||||
<%= rule.network_range.cidr %>
|
||||
<% if rule.network_range.company.present? %>
|
||||
<div class="text-xs text-gray-500"><%= rule.network_range.company %></div>
|
||||
<% end %>
|
||||
<% elsif rule.conditions.present? %>
|
||||
<div class="max-w-xs truncate">
|
||||
<%= JSON.parse(rule.conditions || "{}").map { |k, v| "#{k}: #{v}" }.join(", ") rescue "Invalid JSON" %>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
<% event_count = rule.events.count %>
|
||||
<% if event_count > 0 %>
|
||||
<%= link_to number_with_delimiter(event_count), events_path(rule_id: rule.id), class: "text-blue-600 hover:text-blue-900 font-medium" %>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span class="text-red-600"><%= rule.events.where(waf_action: :deny).count %> blocked</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-gray-400">-</span>
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
<div class="px-6 py-4 space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :rule_type,
|
||||
options_for_select(@rule_types.map { |type| [type.humanize, type] }, @rule.rule_type),
|
||||
<%= form.label :waf_rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :waf_rule_type,
|
||||
options_for_select(@waf_rule_types.map { |type, _| [type.humanize, type] }, @rule.waf_rule_type),
|
||||
{ prompt: "Select rule 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",
|
||||
id: "rule_type_select" } %>
|
||||
@@ -50,9 +50,9 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :action,
|
||||
options_for_select(@actions.map { |action| [action.humanize, action] }, @rule.action),
|
||||
<%= form.label :waf_action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :waf_action,
|
||||
options_for_select(@waf_actions.map { |action, _| [action.humanize, action] }, @rule.waf_action),
|
||||
{ prompt: "Select action" },
|
||||
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||
<p class="mt-2 text-sm text-gray-500">What action to take when this rule matches</p>
|
||||
@@ -113,13 +113,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conditions (shown for non-network rules) -->
|
||||
<!-- Path Pattern (shown for path_pattern rules) -->
|
||||
<div id="path_pattern_section" class="hidden space-y-6">
|
||||
<div>
|
||||
<%= form.label :path_pattern, "Path Pattern", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= text_field_tag :path_pattern, "",
|
||||
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: "/admin, /wp-login.php, /.env, /phpmyadmin",
|
||||
id: "path_pattern_input" %>
|
||||
<p class="mt-2 text-sm text-gray-500">Enter the path to match (e.g., /admin, /wp-login.php)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :match_type, "Match Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= select_tag :match_type,
|
||||
options_for_select([
|
||||
["Exact - Matches path exactly", "exact"],
|
||||
["Prefix - Matches path and subpaths (e.g., /admin matches /admin/users)", "prefix"],
|
||||
["Suffix - Matches paths ending with pattern (e.g., /.env matches /backup/.env)", "suffix"],
|
||||
["Contains - Matches paths containing pattern anywhere", "contains"]
|
||||
]),
|
||||
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||
id: "match_type_select" } %>
|
||||
<p class="mt-2 text-sm text-gray-500">How the pattern should be matched against request paths</p>
|
||||
</div>
|
||||
|
||||
<!-- Example Matches (dynamically updated) -->
|
||||
<div id="match_examples" class="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 mb-2">Example Matches:</h4>
|
||||
<ul class="text-sm text-gray-600 space-y-1" id="example_list">
|
||||
<li>Enter a pattern to see examples</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conditions (shown for other non-network rules) -->
|
||||
<div id="conditions_section" class="hidden">
|
||||
<div>
|
||||
<%= form.label :conditions, "Conditions", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.text_area :conditions, rows: 4,
|
||||
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: '{"path_pattern": "/admin/*", "user_agent": "bot*"}' %>
|
||||
placeholder: '{"user_agent": "bot*"}' %>
|
||||
<p class="mt-2 text-sm text-gray-500">JSON format with matching conditions</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,19 +226,83 @@ let selectedNetworkData = null;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const ruleTypeSelect = document.getElementById('rule_type_select');
|
||||
const networkSection = document.getElementById('network_range_section');
|
||||
const pathPatternSection = document.getElementById('path_pattern_section');
|
||||
const conditionsSection = document.getElementById('conditions_section');
|
||||
const pathPatternInput = document.getElementById('path_pattern_input');
|
||||
const matchTypeSelect = document.getElementById('match_type_select');
|
||||
|
||||
function toggleSections() {
|
||||
if (ruleTypeSelect.value === 'network') {
|
||||
networkSection.classList.remove('hidden');
|
||||
conditionsSection.classList.add('hidden');
|
||||
} else {
|
||||
const ruleType = ruleTypeSelect.value;
|
||||
|
||||
// Hide all sections first
|
||||
networkSection.classList.add('hidden');
|
||||
pathPatternSection.classList.add('hidden');
|
||||
conditionsSection.classList.add('hidden');
|
||||
|
||||
// Show appropriate section
|
||||
if (ruleType === 'network') {
|
||||
networkSection.classList.remove('hidden');
|
||||
} else if (ruleType === 'path_pattern') {
|
||||
pathPatternSection.classList.remove('hidden');
|
||||
} else {
|
||||
conditionsSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePathExamples() {
|
||||
const pattern = pathPatternInput.value.trim();
|
||||
const matchType = matchTypeSelect.value;
|
||||
const exampleList = document.getElementById('example_list');
|
||||
|
||||
if (!pattern) {
|
||||
exampleList.innerHTML = '<li>Enter a pattern to see examples</li>';
|
||||
return;
|
||||
}
|
||||
|
||||
let examples = [];
|
||||
const cleanPattern = pattern.startsWith('/') ? pattern : '/' + pattern;
|
||||
|
||||
switch(matchType) {
|
||||
case 'exact':
|
||||
examples = [
|
||||
`✓ ${cleanPattern}`,
|
||||
`✗ ${cleanPattern}/users (extra segments)`,
|
||||
`✗ /api${cleanPattern} (not at root)`
|
||||
];
|
||||
break;
|
||||
case 'prefix':
|
||||
examples = [
|
||||
`✓ ${cleanPattern}`,
|
||||
`✓ ${cleanPattern}/users`,
|
||||
`✓ ${cleanPattern}/dashboard/settings`,
|
||||
`✗ /api${cleanPattern} (not at start)`
|
||||
];
|
||||
break;
|
||||
case 'suffix':
|
||||
examples = [
|
||||
`✓ ${cleanPattern}`,
|
||||
`✓ /backup${cleanPattern}`,
|
||||
`✓ /config/backup${cleanPattern}`,
|
||||
`✗ ${cleanPattern}/test (extra at end)`
|
||||
];
|
||||
break;
|
||||
case 'contains':
|
||||
examples = [
|
||||
`✓ ${cleanPattern}`,
|
||||
`✓ /api${cleanPattern}/users`,
|
||||
`✓ /super/secret${cleanPattern}/panel`,
|
||||
`✗ ${cleanPattern}tool (different segment)`
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
exampleList.innerHTML = examples.map(ex => `<li>${ex}</li>`).join('');
|
||||
}
|
||||
|
||||
ruleTypeSelect.addEventListener('change', toggleSections);
|
||||
pathPatternInput.addEventListener('input', updatePathExamples);
|
||||
matchTypeSelect.addEventListener('change', updatePathExamples);
|
||||
|
||||
toggleSections(); // Initial state
|
||||
|
||||
// Pre-select network range if provided
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<% content_for :title, "Rule ##{@rule.id} - #{@rule.action.upcase}" %>
|
||||
<% content_for :title, "Rule ##{@rule.id} - #{@rule.waf_action.upcase}" %>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
@@ -23,15 +23,16 @@
|
||||
<div class="mt-2 flex items-center space-x-3">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Rule #<%= @rule.id %></h1>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium <%=
|
||||
case @rule.action
|
||||
case @rule.waf_action
|
||||
when 'allow' then 'bg-green-100 text-green-800'
|
||||
when 'deny' then 'bg-red-100 text-red-800'
|
||||
when 'rate_limit' then 'bg-yellow-100 text-yellow-800'
|
||||
when 'redirect' then 'bg-indigo-100 text-indigo-800'
|
||||
when 'log' then 'bg-gray-100 text-gray-800'
|
||||
when 'challenge' then 'bg-orange-100 text-orange-800'
|
||||
else 'bg-gray-100 text-gray-800'
|
||||
end %>">
|
||||
<%= @rule.action.upcase %>
|
||||
<%= @rule.waf_action.upcase %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,12 +61,12 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Rule Type</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @rule.rule_type.humanize %></dd>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @rule.waf_rule_type.humanize %></dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500">Action</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @rule.action.upcase %></dd>
|
||||
<dd class="mt-1 text-sm text-gray-900"><%= @rule.waf_action.upcase %></dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -118,6 +119,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Statistics -->
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 class="text-lg font-medium text-gray-900">Event Statistics</h3>
|
||||
<%= link_to "View Events", events_path(rule_id: @rule.id), class: "text-sm text-blue-600 hover:text-blue-800" %>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-blue-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-500">Total Events</div>
|
||||
<div class="mt-2 text-3xl font-bold text-gray-900"><%= @rule.events.count %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-500">Blocked Events</div>
|
||||
<div class="mt-2 text-3xl font-bold text-red-900"><%= @rule.events.where(waf_action: :deny).count %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-500">Allowed Events</div>
|
||||
<div class="mt-2 text-3xl font-bold text-green-900"><%= @rule.events.where(waf_action: :allow).count %></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @rule.events.any? %>
|
||||
<div class="mt-4 text-sm text-gray-500">
|
||||
Last event: <%= time_ago_in_words(@rule.events.maximum(:timestamp)) %> ago
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Information -->
|
||||
<% if @rule.network_rule? && @rule.network_range.present? %>
|
||||
<div class="bg-white shadow rounded-lg mb-6">
|
||||
|
||||
60
app/views/settings/index.html.erb
Normal file
60
app/views/settings/index.html.erb
Normal file
@@ -0,0 +1,60 @@
|
||||
<% content_for :title, "Settings" %>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Settings</h1>
|
||||
<p class="mt-2 text-gray-600">Manage system configuration and API keys</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Form -->
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">API Configuration</h3>
|
||||
|
||||
<!-- ipapi.is API Key -->
|
||||
<div class="mb-6">
|
||||
<%= form_with url: settings_path, method: :patch, class: "space-y-4" do |f| %>
|
||||
<%= hidden_field_tag :key, 'ipapi_key' %>
|
||||
|
||||
<div>
|
||||
<label for="ipapi_key" class="block text-sm font-medium text-gray-700">
|
||||
ipapi.is API Key
|
||||
</label>
|
||||
<div class="mt-1 flex rounded-md shadow-sm">
|
||||
<%= text_field_tag :value,
|
||||
@settings['ipapi_key']&.value || ENV['IPAPI_KEY'],
|
||||
class: "flex-1 min-w-0 block w-full px-3 py-2 rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||
placeholder: "Enter your ipapi.is API key" %>
|
||||
<%= f.submit "Update", class: "ml-3 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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
<% if @settings['ipapi_key']&.value.present? %>
|
||||
<span class="text-green-600">✓ Configured in database</span>
|
||||
<% elsif ENV['IPAPI_KEY'].present? %>
|
||||
<span class="text-yellow-600">Using environment variable (IPAPI_KEY)</span>
|
||||
<% else %>
|
||||
<span class="text-red-600">ipapi.is not active</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
Get your API key from <a href="https://ipapi.is/" target="_blank" class="text-blue-600 hover:text-blue-800">ipapi.is</a>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Future Settings Section -->
|
||||
<div class="mt-6 bg-gray-50 shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-2">Additional Settings</h3>
|
||||
<p class="text-sm text-gray-500">More configuration options will be added here as needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with(model: @waf_policy, local: true, class: "space-y-6") do |form| %>
|
||||
<%= form_with(model: @waf_policy, local: true, class: "space-y-6", data: { controller: "waf-policy-form" }) do |form| %>
|
||||
<!-- Basic Information -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6 space-y-4">
|
||||
@@ -35,14 +35,14 @@
|
||||
placeholder: "Explain why this policy is needed..." %>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<!-- Policy Action -->
|
||||
<div>
|
||||
<%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :action,
|
||||
options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.action),
|
||||
<%= form.label :policy_action, "Policy Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :policy_action,
|
||||
options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.policy_action),
|
||||
{ prompt: "Select action" },
|
||||
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||
id: "action-select" } %>
|
||||
data: { "waf-policy-form-target": "policyActionSelect", "action": "change->waf-policy-form#updateActionConfig" } } %>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
@@ -164,7 +164,7 @@
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">⚙️ Additional Configuration</h3>
|
||||
|
||||
<!-- Redirect Settings (for redirect action) -->
|
||||
<div id="redirect-config" class="space-y-3 <%= 'hidden' unless @waf_policy.redirect_action? %>">
|
||||
<div id="redirect-config" class="space-y-3 <%= 'hidden' unless @waf_policy.redirect_action? %>" data-waf-policy-form-target="redirectConfig">
|
||||
<div>
|
||||
<%= label_tag "additional_data[redirect_url]", "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= text_field_tag "additional_data[redirect_url]", @waf_policy.additional_data&.dig('redirect_url'),
|
||||
@@ -180,7 +180,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Challenge Settings (for challenge action) -->
|
||||
<div id="challenge-config" class="space-y-3 <%= 'hidden' unless @waf_policy.challenge_action? %>">
|
||||
<div id="challenge-config" class="space-y-3 <%= 'hidden' unless @waf_policy.challenge_action? %>" data-waf-policy-form-target="challengeConfig">
|
||||
<div>
|
||||
<%= label_tag "additional_data[challenge_type]", "Challenge Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= select_tag "additional_data[challenge_type]",
|
||||
@@ -206,35 +206,3 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const actionSelect = document.getElementById('action-select');
|
||||
const redirectConfig = document.getElementById('redirect-config');
|
||||
const challengeConfig = document.getElementById('challenge-config');
|
||||
|
||||
function updateActionConfig() {
|
||||
const selectedAction = actionSelect.value;
|
||||
|
||||
// Hide all config sections
|
||||
redirectConfig.classList.add('hidden');
|
||||
challengeConfig.classList.add('hidden');
|
||||
|
||||
// Show relevant config section
|
||||
switch(selectedAction) {
|
||||
case 'redirect':
|
||||
redirectConfig.classList.remove('hidden');
|
||||
break;
|
||||
case 'challenge':
|
||||
challengeConfig.classList.remove('hidden');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
actionSelect.addEventListener('change', updateActionConfig);
|
||||
|
||||
// Initial update
|
||||
updateActionConfig();
|
||||
});
|
||||
</script>
|
||||
@@ -85,7 +85,7 @@
|
||||
<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) %>
|
||||
<%= number_with_delimiter(@waf_policies.where(policy_action: 'deny').count) %>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -137,15 +137,15 @@
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<!-- Action Badge -->
|
||||
<!-- Policy 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
|
||||
<%= case policy.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 %>
|
||||
<%= policy.policy_action.upcase %>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-gray-500">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with(model: @waf_policy, local: true, class: "space-y-6") do |form| %>
|
||||
<%= form_with(model: @waf_policy, local: true, class: "space-y-6", data: { controller: "waf-policy-form" }) do |form| %>
|
||||
<!-- Basic Information -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6 space-y-4">
|
||||
@@ -42,17 +42,17 @@
|
||||
options_for_select(@policy_types.map { |type| [type.humanize, type] }, @waf_policy.policy_type),
|
||||
{ prompt: "Select policy type" },
|
||||
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||
id: "policy-type-select" } %>
|
||||
data: { "waf-policy-form-target": "policyTypeSelect", "action": "change->waf-policy-form#updateTargetsVisibility" } } %>
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<!-- Policy Action -->
|
||||
<div>
|
||||
<%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :action,
|
||||
options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.action),
|
||||
<%= form.label :policy_action, "Policy Action", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.select :policy_action,
|
||||
options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.policy_action),
|
||||
{ prompt: "Select action" },
|
||||
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||
id: "action-select" } %>
|
||||
data: { "waf-policy-form-target": "policyActionSelect", "action": "change->waf-policy-form#updateActionConfig" } } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +63,7 @@
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">🎯 Targets Configuration</h3>
|
||||
|
||||
<!-- Country Policy Targets -->
|
||||
<div id="country-targets" class="policy-targets hidden">
|
||||
<div id="country-targets" class="policy-targets hidden" data-waf-policy-form-target="countryTargets">
|
||||
<%= form.label :targets, "Countries", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<div data-controller="country-selector"
|
||||
data-country-selector-options-value="<%= CountryHelper.all_for_select.to_json %>"
|
||||
@@ -79,7 +79,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ASN Policy Targets -->
|
||||
<div id="asn-targets" class="policy-targets hidden">
|
||||
<div id="asn-targets" class="policy-targets hidden" data-waf-policy-form-target="asnTargets">
|
||||
<%= form.label :targets, "ASN Numbers", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= text_field_tag "waf_policy[targets][]", nil,
|
||||
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||
@@ -88,7 +88,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Company Policy Targets -->
|
||||
<div id="company-targets" class="policy-targets hidden">
|
||||
<div id="company-targets" class="policy-targets hidden" data-waf-policy-form-target="companyTargets">
|
||||
<%= form.label :targets, "Companies", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= text_field_tag "waf_policy[targets][]", nil,
|
||||
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||
@@ -97,7 +97,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Network Type Targets -->
|
||||
<div id="network-type-targets" class="policy-targets hidden">
|
||||
<div id="network-type-targets" class="policy-targets hidden" data-waf-policy-form-target="networkTypeTargets">
|
||||
<%= form.label :targets, "Network Types", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
@@ -123,7 +123,7 @@
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">⚙️ Additional Configuration</h3>
|
||||
|
||||
<!-- Redirect Settings (for redirect action) -->
|
||||
<div id="redirect-config" class="hidden space-y-3">
|
||||
<div id="redirect-config" class="hidden space-y-3" data-waf-policy-form-target="redirectConfig">
|
||||
<div>
|
||||
<%= label_tag "additional_data[redirect_url]", "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= text_field_tag "additional_data[redirect_url]", nil,
|
||||
@@ -139,7 +139,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Challenge Settings (for challenge action) -->
|
||||
<div id="challenge-config" class="hidden space-y-3">
|
||||
<div id="challenge-config" class="hidden space-y-3" data-waf-policy-form-target="challengeConfig">
|
||||
<div>
|
||||
<%= label_tag "additional_data[challenge_type]", "Challenge Type", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= select_tag "additional_data[challenge_type]",
|
||||
@@ -179,62 +179,3 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const policyTypeSelect = document.getElementById('policy-type-select');
|
||||
const actionSelect = document.getElementById('action-select');
|
||||
const allTargets = document.querySelectorAll('.policy-targets');
|
||||
const redirectConfig = document.getElementById('redirect-config');
|
||||
const challengeConfig = document.getElementById('challenge-config');
|
||||
|
||||
function updateTargetsVisibility() {
|
||||
const selectedType = policyTypeSelect.value;
|
||||
|
||||
// Hide all target sections
|
||||
allTargets.forEach(target => target.classList.add('hidden'));
|
||||
|
||||
// Show relevant target section
|
||||
switch(selectedType) {
|
||||
case 'country':
|
||||
document.getElementById('country-targets').classList.remove('hidden');
|
||||
break;
|
||||
case 'asn':
|
||||
document.getElementById('asn-targets').classList.remove('hidden');
|
||||
break;
|
||||
case 'company':
|
||||
document.getElementById('company-targets').classList.remove('hidden');
|
||||
break;
|
||||
case 'network_type':
|
||||
document.getElementById('network-type-targets').classList.remove('hidden');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateActionConfig() {
|
||||
const selectedAction = actionSelect.value;
|
||||
|
||||
// Hide all config sections
|
||||
redirectConfig.classList.add('hidden');
|
||||
challengeConfig.classList.add('hidden');
|
||||
|
||||
// Show relevant config section
|
||||
switch(selectedAction) {
|
||||
case 'redirect':
|
||||
redirectConfig.classList.remove('hidden');
|
||||
break;
|
||||
case 'challenge':
|
||||
challengeConfig.classList.remove('hidden');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
policyTypeSelect.addEventListener('change', updateTargetsVisibility);
|
||||
actionSelect.addEventListener('change', updateActionConfig);
|
||||
|
||||
// Initial update
|
||||
updateTargetsVisibility();
|
||||
updateActionConfig();
|
||||
});
|
||||
</script>
|
||||
@@ -14,6 +14,31 @@
|
||||
</div>
|
||||
|
||||
<%= form_with(url: create_country_waf_policies_path, method: :post, local: true, class: "space-y-6") do |form| %>
|
||||
<!-- Display validation errors -->
|
||||
<% if defined?(@waf_policy) && @waf_policy&.errors&.any? %>
|
||||
<div class="rounded-md bg-red-50 p-4 border border-red-200">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">
|
||||
<%= pluralize(@waf_policy.errors.count, "error") %> prohibited this policy from being saved:
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<% @waf_policy.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Popular Countries Quick Selection -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
@@ -67,28 +92,28 @@
|
||||
|
||||
<!-- Action Selection -->
|
||||
<div>
|
||||
<%= form.label :action, "What should happen to traffic from selected countries?", class: "block text-sm font-medium text-gray-700" %>
|
||||
<%= form.label :policy_action, "What should happen to traffic from selected countries?", class: "block text-sm font-medium text-gray-700" %>
|
||||
<div class="mt-2 space-y-2">
|
||||
<label class="flex items-center">
|
||||
<%= radio_button_tag "action", "deny", true, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||
<%= radio_button_tag "policy_action", "deny", true, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||
<span class="ml-2 text-sm text-gray-700">
|
||||
<span class="font-medium">🚫 Block (Deny)</span> - Show 403 Forbidden error
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<%= radio_button_tag "action", "challenge", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||
<%= radio_button_tag "policy_action", "challenge", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||
<span class="ml-2 text-sm text-gray-700">
|
||||
<span class="font-medium">🛡️ Challenge</span> - Present CAPTCHA challenge
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<%= radio_button_tag "action", "redirect", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||
<%= radio_button_tag "policy_action", "redirect", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||
<span class="ml-2 text-sm text-gray-700">
|
||||
<span class="font-medium">🔄 Redirect</span> - Redirect to compliance page
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<%= radio_button_tag "action", "allow", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||
<%= radio_button_tag "policy_action", "allow", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
|
||||
<span class="ml-2 text-sm text-gray-700">
|
||||
<span class="font-medium">✅ Allow</span> - Explicitly allow traffic
|
||||
</span>
|
||||
@@ -138,14 +163,14 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show/hide redirect settings based on action selection
|
||||
// Show/hide redirect settings based on policy action selection
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const actionRadios = document.querySelectorAll('input[name="action"]');
|
||||
const actionRadios = document.querySelectorAll('input[name="policy_action"]');
|
||||
const redirectSettings = document.getElementById('redirect-settings');
|
||||
const previewText = document.getElementById('preview-text');
|
||||
|
||||
function updateVisibility() {
|
||||
const selectedAction = document.querySelector('input[name="action"]:checked')?.value;
|
||||
const selectedAction = document.querySelector('input[name="policy_action"]:checked')?.value;
|
||||
|
||||
if (selectedAction === 'redirect') {
|
||||
redirectSettings.classList.remove('hidden');
|
||||
@@ -158,7 +183,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
function updatePreview() {
|
||||
const selectedCountries = document.querySelectorAll('input[name="countries[]"]:checked');
|
||||
const selectedAction = document.querySelector('input[name="action"]:checked')?.value || 'deny';
|
||||
const selectedAction = document.querySelector('input[name="policy_action"]:checked')?.value || 'deny';
|
||||
const actionText = {
|
||||
'deny': '🚫 Block',
|
||||
'challenge': '🛡️ Challenge',
|
||||
|
||||
@@ -32,16 +32,16 @@
|
||||
</div>
|
||||
|
||||
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt class="text-sm font-medium text-gray-500">Action</dt>
|
||||
<dt class="text-sm font-medium text-gray-500">Policy Action</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
<%= case @waf_policy.action
|
||||
<%= case @waf_policy.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 %>">
|
||||
<%= @waf_policy.action.upcase %>
|
||||
<%= @waf_policy.policy_action.upcase %>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -239,7 +239,7 @@
|
||||
Rule #<%= rule.id %> - <%= rule.network_range&.cidr || "Unknown" %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<%= rule.action.upcase %> • Created <%= time_ago_in_words(rule.created_at) %> ago
|
||||
<%= rule.waf_action.upcase %> • Created <%= time_ago_in_words(rule.created_at) %> ago
|
||||
<% if rule.redirect_action? %>
|
||||
• Redirect to <%= rule.redirect_url %>
|
||||
<% elsif rule.challenge_action? %>
|
||||
|
||||
@@ -77,4 +77,6 @@ Rails.application.configure do
|
||||
# config.generators.apply_rubocop_autocorrect_after_generate!
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
# Don't store finished jobs - we don't need job history, saves DB space
|
||||
config.solid_queue.preserve_finished_jobs = false
|
||||
end
|
||||
|
||||
@@ -52,6 +52,8 @@ Rails.application.configure do
|
||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
# Don't store finished jobs - we don't need job history, saves DB space
|
||||
config.solid_queue.preserve_finished_jobs = false
|
||||
|
||||
# Ignore bad email addresses and do not raise email delivery errors.
|
||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||
|
||||
@@ -5,3 +5,6 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js"
|
||||
pin "@hotwired/stimulus", to: "stimulus.min.js"
|
||||
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
|
||||
pin_all_from "app/javascript/controllers", under: "controllers"
|
||||
|
||||
# Tom Select for enhanced multi-select
|
||||
pin "tom-select", to: "https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"
|
||||
|
||||
156
config/initializers/sentry.rb
Normal file
156
config/initializers/sentry.rb
Normal file
@@ -0,0 +1,156 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Sentry configuration for error tracking and performance monitoring
|
||||
# Only initializes if SENTRY_DSN is configured
|
||||
|
||||
return unless ENV['SENTRY_DSN'].present?
|
||||
|
||||
require 'sentry-rails'
|
||||
|
||||
Sentry.init do |config|
|
||||
config.dsn = ENV['SENTRY_DSN']
|
||||
|
||||
# Configure sampling for production (lower in production, higher in staging)
|
||||
config.traces_sample_rate = case Rails.env
|
||||
when 'production' then ENV.fetch('SENTRY_TRACES_SAMPLE_RATE', '0.05').to_f
|
||||
when 'staging' then ENV.fetch('SENTRY_TRACES_SAMPLE_RATE', '0.2').to_f
|
||||
else ENV.fetch('SENTRY_TRACES_SAMPLE_RATE', '0.1').to_f
|
||||
end
|
||||
|
||||
# Enable breadcrumbs for better debugging
|
||||
config.breadcrumbs_logger = [:active_support_logger, :http_logger]
|
||||
|
||||
# Send PII data (like IPs) to Sentry for debugging (disable in production if needed)
|
||||
config.send_default_pii = ENV.fetch('SENTRY_SEND_PII', 'false') == 'true'
|
||||
|
||||
# Set environment
|
||||
config.environment = Rails.env
|
||||
|
||||
# Configure release info from Docker tag or Git
|
||||
if ENV['GIT_COMMIT_SHA']
|
||||
config.release = ENV['GIT_COMMIT_SHA'][0..7]
|
||||
elsif ENV['APP_VERSION']
|
||||
config.release = ENV['APP_VERSION']
|
||||
end
|
||||
|
||||
# Set server name for multi-instance environments
|
||||
config.server_name = ENV.fetch('SERVER_NAME', 'baffle-agent')
|
||||
|
||||
# Filter out certain errors to reduce noise and add tags
|
||||
config.before_send = lambda do |event, hint|
|
||||
# Filter out 404 errors and other expected HTTP errors
|
||||
if event.contexts.dig(:response, :status_code) == 404
|
||||
nil
|
||||
# Filter out validation errors in development
|
||||
elsif Rails.env.development? && event.exception&.message&.include?("Validation failed")
|
||||
nil
|
||||
# Filter out specific noisy exceptions
|
||||
elsif %w[ActionController::RoutingError
|
||||
ActionController::InvalidAuthenticityToken
|
||||
ActionController::UnknownFormat
|
||||
ActionDispatch::Http::Parameters::ParseError].include?(event.exception&.class&.name)
|
||||
nil
|
||||
else
|
||||
# Add tags for better filtering in Sentry
|
||||
event.tags.merge!({
|
||||
ruby_version: RUBY_VERSION,
|
||||
rails_version: Rails.version,
|
||||
environment: Rails.env
|
||||
})
|
||||
event
|
||||
end
|
||||
end
|
||||
|
||||
# Configure exception class exclusion
|
||||
config.excluded_exceptions += [
|
||||
'ActionController::RoutingError',
|
||||
'ActionController::InvalidAuthenticityToken',
|
||||
'CGI::Session::CookieStore::TamperedWithCookie',
|
||||
'ActionController::UnknownFormat',
|
||||
'ActionDispatch::Http::Parameters::ParseError',
|
||||
'Mongoid::Errors::DocumentNotFound'
|
||||
]
|
||||
end
|
||||
|
||||
# SolidQueue monitoring will be automatically handled by sentry-solid_queue gem
|
||||
|
||||
# Add correlation ID to Sentry context
|
||||
ActiveSupport::Notifications.subscribe('action_controller.process_action') do |name, start, finish, id, payload|
|
||||
controller = payload[:controller]
|
||||
action = payload[:action]
|
||||
request_id = payload[:request]&.request_id
|
||||
|
||||
if controller && action && request_id
|
||||
Sentry.set_context(:request, {
|
||||
correlation_id: request_id,
|
||||
controller: controller.controller_name,
|
||||
action: action.action_name,
|
||||
ip: request&.remote_ip
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
# Add ActiveJob context to all job transactions
|
||||
ActiveSupport::Notifications.subscribe('perform.active_job') do |name, start, finish, id, payload|
|
||||
job = payload[:job]
|
||||
|
||||
Sentry.configure_scope do |scope|
|
||||
scope.set_tag(:job_class, job.class.name)
|
||||
scope.set_tag(:job_queue, job.queue_name)
|
||||
scope.set_tag(:job_id, job.job_id)
|
||||
|
||||
scope.set_context(:job, {
|
||||
job_id: job.job_id,
|
||||
job_class: job.class.name,
|
||||
queue_name: job.queue_name,
|
||||
arguments: job.arguments.to_s,
|
||||
enqueued_at: job.enqueued_at,
|
||||
executions: job.executions
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
# Monitor SolidQueue job failures
|
||||
ActiveSupport::Notifications.subscribe('solid_queue.error') do |name, start, finish, id, payload|
|
||||
job = payload[:job]
|
||||
error = payload[:error]
|
||||
|
||||
Sentry.with_scope do |scope|
|
||||
scope.set_tag(:job_class, job.class_name)
|
||||
scope.set_tag(:job_queue, job.queue_name)
|
||||
scope.set_context(:job, {
|
||||
job_id: job.active_job_id,
|
||||
arguments: job.arguments.to_s,
|
||||
queue_name: job.queue_name,
|
||||
created_at: job.created_at
|
||||
})
|
||||
scope.set_context(:error, {
|
||||
error_class: error.class.name,
|
||||
error_message: error.message
|
||||
})
|
||||
|
||||
Sentry.capture_exception(error)
|
||||
end
|
||||
end
|
||||
|
||||
# Set user context when available
|
||||
if defined?(Current) && Current.user
|
||||
Sentry.set_user(id: Current.user.id, email: Current.user.email)
|
||||
end
|
||||
|
||||
# Add application-specific context
|
||||
app_version = begin
|
||||
File.read(Rails.root.join('VERSION')).strip
|
||||
rescue
|
||||
ENV['APP_VERSION'] || ENV['GIT_COMMIT_SHA']&.[](0..7) || 'unknown'
|
||||
end
|
||||
|
||||
Sentry.set_context('application', {
|
||||
name: 'BaffleHub',
|
||||
version: app_version,
|
||||
environment: Rails.env,
|
||||
database: ActiveRecord::Base.connection.adapter_name,
|
||||
queue_adapter: Rails.application.config.active_job.queue_adapter
|
||||
})
|
||||
|
||||
Rails.logger.info "Sentry configured for environment: #{Rails.env}"
|
||||
@@ -9,7 +9,17 @@
|
||||
# priority: 2
|
||||
# schedule: at 5am every day
|
||||
|
||||
production:
|
||||
clear_solid_queue_finished_jobs:
|
||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||
schedule: every hour at minute 12
|
||||
# No recurring tasks configured yet
|
||||
# (previously had clear_solid_queue_finished_jobs, but now preserve_finished_jobs: false in queue.yml)
|
||||
|
||||
# Backfill network intelligence for recent events (catches events before network data imported)
|
||||
backfill_recent_network_intelligence:
|
||||
class: BackfillRecentNetworkIntelligenceJob
|
||||
queue: default
|
||||
schedule: every 5 minutes
|
||||
|
||||
# Clean up failed jobs older than 1 day
|
||||
cleanup_failed_jobs:
|
||||
command: "SolidQueue::FailedExecution.where('created_at < ?', 1.day.ago).delete_all"
|
||||
queue: background
|
||||
schedule: every 6 hours
|
||||
|
||||
@@ -11,6 +11,13 @@ Rails.application.routes.draw do
|
||||
# Admin user management (admin only)
|
||||
resources :users, only: [:index, :show, :edit, :update]
|
||||
|
||||
# Settings management (admin only)
|
||||
resources :settings, only: [:index] do
|
||||
collection do
|
||||
patch :update
|
||||
end
|
||||
end
|
||||
|
||||
# DSN management (admin only)
|
||||
resources :dsns do
|
||||
member do
|
||||
@@ -44,7 +51,7 @@ Rails.application.routes.draw do
|
||||
root "analytics#index"
|
||||
|
||||
# Event management
|
||||
resources :events, only: [:index]
|
||||
resources :events, only: [:index, :show]
|
||||
|
||||
# Network range management
|
||||
resources :network_ranges, only: [:index, :show, :new, :create, :edit, :update, :destroy] do
|
||||
@@ -79,4 +86,11 @@ Rails.application.routes.draw do
|
||||
post :create_country
|
||||
end
|
||||
end
|
||||
|
||||
# GeoLite2 data import management (admin only)
|
||||
resources :data_imports, only: [:index, :new, :create, :show, :destroy] do
|
||||
member do
|
||||
get :progress
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ test:
|
||||
|
||||
local:
|
||||
service: Disk
|
||||
root: <%= Rails.root.join("storage") %>
|
||||
root: <%= Rails.root.join("storage", "uploads") %>
|
||||
|
||||
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
||||
# amazon:
|
||||
|
||||
13
config/tailwind.config.js
Normal file
13
config/tailwind.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./app/helpers/**/*.rb',
|
||||
'./app/javascript/**/*.js',
|
||||
'./app/views/**/*.erb',
|
||||
'./app/views/**/*.html.erb'
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddPoliciesEvaluatedAtToNetworkRanges < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :network_ranges, :policies_evaluated_at, :datetime
|
||||
add_index :network_ranges, :policies_evaluated_at
|
||||
end
|
||||
end
|
||||
11
db/migrate/20251111053159_create_settings.rb
Normal file
11
db/migrate/20251111053159_create_settings.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class CreateSettings < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :settings do |t|
|
||||
t.string :key
|
||||
t.string :value
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
add_index :settings, :key, unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class ConvertAdditionalDataToNetworkData < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :network_ranges, :network_data, :jsonb, default: {}
|
||||
add_index :network_ranges, :network_data, using: :gin
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class RenameActionToPolicyActionOnWafPolicies < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
rename_column :waf_policies, :action, :policy_action
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
class AddNetworkIntelligenceToEvents < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Add network intelligence columns for denormalization
|
||||
add_column :events, :country, :string
|
||||
add_column :events, :company, :string
|
||||
add_column :events, :asn, :integer
|
||||
add_column :events, :asn_org, :string
|
||||
add_column :events, :is_datacenter, :boolean, default: false, null: false
|
||||
add_column :events, :is_vpn, :boolean, default: false, null: false
|
||||
add_column :events, :is_proxy, :boolean, default: false, null: false
|
||||
add_column :events, :network_range_id, :bigint
|
||||
|
||||
# Add indexes for commonly queried fields
|
||||
add_index :events, :country
|
||||
add_index :events, :company
|
||||
add_index :events, :asn
|
||||
add_index :events, :network_range_id
|
||||
add_index :events, [:is_datacenter, :is_vpn, :is_proxy], name: 'index_events_on_network_flags'
|
||||
|
||||
# Backfill skipped - run manually after migration
|
||||
# See script/backfill_network_intelligence.rb or lib/tasks/events.rake
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
class RenameRuleMatchedToRuleIdInEvents < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Add new rule_id column (instant - just metadata change)
|
||||
add_column :events, :rule_id, :bigint
|
||||
|
||||
# Drop old rule_matched string column (instant - no data to migrate)
|
||||
remove_column :events, :rule_matched, :string
|
||||
|
||||
# Add foreign key constraint (fast - all values are NULL)
|
||||
add_foreign_key :events, :rules
|
||||
|
||||
# Add index for analytics queries (fast - mostly NULL values)
|
||||
add_index :events, :rule_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
class RenameEventIdToRequestIdInEvents < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Only rename the column if it still exists as event_id
|
||||
if column_exists?(:events, :event_id)
|
||||
rename_column :events, :event_id, :request_id
|
||||
end
|
||||
|
||||
# Rename the unique index if it still exists with the old name
|
||||
if index_name_exists?(:events, :index_events_on_event_id)
|
||||
rename_index :events, :index_events_on_event_id, :index_events_on_request_id
|
||||
elsif !index_name_exists?(:events, :index_events_on_request_id)
|
||||
# Create the index with the new name if neither exists
|
||||
add_index :events, :request_id, unique: true, name: :index_events_on_request_id
|
||||
end
|
||||
end
|
||||
end
|
||||
37
db/migrate/20251113031234_add_enums_to_rules.rb
Normal file
37
db/migrate/20251113031234_add_enums_to_rules.rb
Normal file
@@ -0,0 +1,37 @@
|
||||
class AddEnumsToRules < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Add enum columns with default values
|
||||
add_column :rules, :waf_action, :integer, default: 0, null: false
|
||||
add_column :rules, :waf_rule_type, :integer, default: 0, null: false
|
||||
|
||||
# Add indexes for enum columns
|
||||
add_index :rules, :waf_action
|
||||
add_index :rules, :waf_rule_type
|
||||
|
||||
# Migrate existing data
|
||||
# Map action strings to integers (starting from 0 to match Rails enum convention)
|
||||
execute <<-SQL
|
||||
UPDATE rules
|
||||
SET waf_action = CASE action
|
||||
WHEN 'allow' THEN 0
|
||||
WHEN 'deny' THEN 1
|
||||
WHEN 'rate_limit' THEN 2
|
||||
WHEN 'redirect' THEN 3
|
||||
WHEN 'log' THEN 4
|
||||
WHEN 'challenge' THEN 5
|
||||
ELSE 0
|
||||
END;
|
||||
SQL
|
||||
|
||||
# Map rule_type strings to integers
|
||||
execute <<-SQL
|
||||
UPDATE rules
|
||||
SET waf_rule_type = CASE rule_type
|
||||
WHEN 'network' THEN 0
|
||||
WHEN 'rate_limit' THEN 1
|
||||
WHEN 'path_pattern' THEN 2
|
||||
ELSE 0
|
||||
END;
|
||||
SQL
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
class RemoveLegacyColumnsFromRules < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
# Remove indexes first
|
||||
remove_index :rules, name: "index_rules_on_action" if index_exists?(:rules, name: "index_rules_on_action")
|
||||
remove_index :rules, name: "index_rules_on_rule_type" if index_exists?(:rules, name: "index_rules_on_rule_type")
|
||||
remove_index :rules, name: "idx_rules_type_enabled" if index_exists?(:rules, name: "idx_rules_type_enabled")
|
||||
|
||||
# Remove the legacy columns
|
||||
remove_column :rules, :action, :string
|
||||
remove_column :rules, :rule_type, :string
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddTimeAwareUniqueIndexesToRules < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
# First, clean up existing duplicate records to allow unique constraints
|
||||
cleanup_duplicate_rules
|
||||
|
||||
# Add time-aware unique indexes for policy-generated rules
|
||||
# This prevents exact duplicate time windows while allowing temporal flexibility
|
||||
|
||||
# For temporary rules (with expiration dates)
|
||||
add_index :rules,
|
||||
[:network_range_id, :waf_action, :waf_policy_id, :expires_at],
|
||||
name: 'index_rules_on_network_policy_expires_unique',
|
||||
unique: true,
|
||||
where: "source = 'policy' AND expires_at IS NOT NULL"
|
||||
|
||||
# For permanent rules (no expiration date)
|
||||
add_index :rules,
|
||||
[:network_range_id, :waf_action, :waf_policy_id],
|
||||
name: 'index_rules_on_network_policy_unique',
|
||||
unique: true,
|
||||
where: "source = 'policy' AND expires_at IS NULL"
|
||||
|
||||
# Additional indexes for performance
|
||||
add_index :rules, [:source, :expires_at], name: 'index_rules_on_source_expires'
|
||||
add_index :rules, [:waf_policy_id, :expires_at], name: 'index_rules_on_policy_expires'
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :rules, name: 'index_rules_on_network_policy_expires_unique'
|
||||
remove_index :rules, name: 'index_rules_on_network_policy_unique'
|
||||
remove_index :rules, name: 'index_rules_on_source_expires'
|
||||
remove_index :rules, name: 'index_rules_on_policy_expires'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def cleanup_duplicate_rules
|
||||
# Clean up duplicates for policy rules with the same expiration time
|
||||
duplicate_sql = <<~SQL
|
||||
WITH ranked_rules AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY network_range_id, waf_action, waf_policy_id, expires_at
|
||||
ORDER BY created_at DESC
|
||||
) as rn
|
||||
FROM rules
|
||||
WHERE source = 'policy'
|
||||
)
|
||||
DELETE FROM rules
|
||||
WHERE id IN (SELECT id FROM ranked_rules WHERE rn > 1)
|
||||
SQL
|
||||
|
||||
execute duplicate_sql
|
||||
Rails.logger.info "Cleaned up duplicate policy rules with same expiration times"
|
||||
end
|
||||
end
|
||||
100
db/schema.rb
100
db/schema.rb
@@ -10,10 +10,56 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2025_11_13_052831) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
|
||||
create_table "active_storage_attachments", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.string "name", null: false
|
||||
t.bigint "record_id", null: false
|
||||
t.string "record_type", null: false
|
||||
t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id"
|
||||
t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_blobs", force: :cascade do |t|
|
||||
t.bigint "byte_size", null: false
|
||||
t.string "checksum"
|
||||
t.string "content_type"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "filename", null: false
|
||||
t.string "key", null: false
|
||||
t.text "metadata"
|
||||
t.string "service_name", null: false
|
||||
t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "active_storage_variant_records", force: :cascade do |t|
|
||||
t.bigint "blob_id", null: false
|
||||
t.string "variation_digest", null: false
|
||||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "data_imports", force: :cascade do |t|
|
||||
t.datetime "completed_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.text "error_message"
|
||||
t.integer "failed_records", default: 0
|
||||
t.string "filename", null: false
|
||||
t.json "import_stats", default: {}, comment: "Detailed import statistics"
|
||||
t.string "import_type", null: false, comment: "ASN or Country import"
|
||||
t.integer "processed_records", default: 0
|
||||
t.datetime "started_at"
|
||||
t.string "status", default: "pending", null: false, comment: "pending, processing, completed, failed"
|
||||
t.integer "total_records", default: 0
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["created_at"], name: "index_data_imports_on_created_at"
|
||||
t.index ["import_type"], name: "index_data_imports_on_import_type"
|
||||
t.index ["status"], name: "index_data_imports_on_status"
|
||||
end
|
||||
|
||||
create_table "dsns", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.boolean "enabled", default: true, null: false
|
||||
@@ -26,13 +72,21 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||
create_table "events", force: :cascade do |t|
|
||||
t.string "agent_name"
|
||||
t.string "agent_version"
|
||||
t.integer "asn"
|
||||
t.string "asn_org"
|
||||
t.text "blocked_reason"
|
||||
t.string "company"
|
||||
t.string "country"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "environment"
|
||||
t.string "event_id", null: false
|
||||
t.inet "ip_address"
|
||||
t.boolean "is_datacenter", default: false, null: false
|
||||
t.boolean "is_proxy", default: false, null: false
|
||||
t.boolean "is_vpn", default: false, null: false
|
||||
t.bigint "network_range_id"
|
||||
t.json "payload"
|
||||
t.bigint "request_host_id"
|
||||
t.string "request_id", null: false
|
||||
t.integer "request_method", default: 0
|
||||
t.string "request_path"
|
||||
t.string "request_protocol"
|
||||
@@ -40,17 +94,25 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||
t.string "request_url"
|
||||
t.integer "response_status"
|
||||
t.integer "response_time_ms"
|
||||
t.string "rule_matched"
|
||||
t.bigint "rule_id"
|
||||
t.string "server_name"
|
||||
t.jsonb "tags", default: [], null: false
|
||||
t.datetime "timestamp", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "user_agent"
|
||||
t.integer "waf_action", default: 0, null: false
|
||||
t.index ["event_id"], name: "index_events_on_event_id", unique: true
|
||||
t.index ["asn"], name: "index_events_on_asn"
|
||||
t.index ["company"], name: "index_events_on_company"
|
||||
t.index ["country"], name: "index_events_on_country"
|
||||
t.index ["ip_address"], name: "index_events_on_ip_address"
|
||||
t.index ["is_datacenter", "is_vpn", "is_proxy"], name: "index_events_on_network_flags"
|
||||
t.index ["network_range_id"], name: "index_events_on_network_range_id"
|
||||
t.index ["request_host_id", "request_method", "request_segment_ids"], name: "idx_events_host_method_path"
|
||||
t.index ["request_host_id"], name: "index_events_on_request_host_id"
|
||||
t.index ["request_id"], name: "index_events_on_request_id", unique: true
|
||||
t.index ["request_segment_ids"], name: "index_events_on_request_segment_ids"
|
||||
t.index ["rule_id"], name: "index_events_on_rule_id"
|
||||
t.index ["tags"], name: "index_events_on_tags", using: :gin
|
||||
t.index ["timestamp"], name: "index_events_on_timestamp"
|
||||
t.index ["waf_action"], name: "index_events_on_waf_action"
|
||||
end
|
||||
@@ -70,6 +132,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||
t.boolean "is_vpn", default: false
|
||||
t.datetime "last_api_fetch"
|
||||
t.inet "network", null: false
|
||||
t.jsonb "network_data", default: {}
|
||||
t.datetime "policies_evaluated_at"
|
||||
t.string "source", default: "api_imported", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id"
|
||||
@@ -82,6 +146,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||
t.index ["is_datacenter"], name: "index_network_ranges_on_is_datacenter"
|
||||
t.index ["network"], name: "index_network_ranges_on_network", opclass: :inet_ops, using: :gist
|
||||
t.index ["network"], name: "index_network_ranges_on_network_unique", unique: true
|
||||
t.index ["network_data"], name: "index_network_ranges_on_network_data", using: :gin
|
||||
t.index ["policies_evaluated_at"], name: "index_network_ranges_on_policies_evaluated_at"
|
||||
t.index ["source"], name: "index_network_ranges_on_source"
|
||||
t.index ["user_id"], name: "index_network_ranges_on_user_id"
|
||||
end
|
||||
@@ -126,7 +192,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||
end
|
||||
|
||||
create_table "rules", force: :cascade do |t|
|
||||
t.string "action", null: false
|
||||
t.json "conditions", default: {}
|
||||
t.datetime "created_at", null: false
|
||||
t.boolean "enabled", default: true, null: false
|
||||
@@ -134,24 +199,28 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||
t.json "metadata", default: {}
|
||||
t.bigint "network_range_id"
|
||||
t.integer "priority"
|
||||
t.string "rule_type", null: false
|
||||
t.string "source", limit: 100, default: "manual"
|
||||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id"
|
||||
t.integer "waf_action", default: 0, null: false
|
||||
t.bigint "waf_policy_id"
|
||||
t.index ["action"], name: "index_rules_on_action"
|
||||
t.integer "waf_rule_type", default: 0, null: false
|
||||
t.index ["enabled", "expires_at"], name: "idx_rules_active"
|
||||
t.index ["enabled"], name: "index_rules_on_enabled"
|
||||
t.index ["expires_at"], name: "index_rules_on_expires_at"
|
||||
t.index ["network_range_id", "waf_action", "waf_policy_id", "expires_at"], name: "index_rules_on_network_policy_expires_unique", unique: true, where: "(((source)::text = 'policy'::text) AND (expires_at IS NOT NULL))"
|
||||
t.index ["network_range_id", "waf_action", "waf_policy_id"], name: "index_rules_on_network_policy_unique", unique: true, where: "(((source)::text = 'policy'::text) AND (expires_at IS NULL))"
|
||||
t.index ["network_range_id"], name: "index_rules_on_network_range_id"
|
||||
t.index ["priority"], name: "index_rules_on_priority"
|
||||
t.index ["rule_type", "enabled"], name: "idx_rules_type_enabled"
|
||||
t.index ["rule_type"], name: "index_rules_on_rule_type"
|
||||
t.index ["source", "expires_at"], name: "index_rules_on_source_expires"
|
||||
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_action"], name: "index_rules_on_waf_action"
|
||||
t.index ["waf_policy_id", "expires_at"], name: "index_rules_on_policy_expires"
|
||||
t.index ["waf_policy_id"], name: "idx_rules_waf_policy"
|
||||
t.index ["waf_policy_id"], name: "index_rules_on_waf_policy_id"
|
||||
t.index ["waf_rule_type"], name: "index_rules_on_waf_rule_type"
|
||||
end
|
||||
|
||||
create_table "sessions", force: :cascade do |t|
|
||||
@@ -163,6 +232,14 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||
t.index ["user_id"], name: "index_sessions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "settings", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "key"
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "value"
|
||||
t.index ["key"], name: "index_settings_on_key", unique: true
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "email_address", null: false
|
||||
@@ -173,13 +250,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||
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_action", default: "deny", null: false
|
||||
t.string "policy_type", default: "country", null: false
|
||||
t.json "targets", default: []
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -191,7 +268,10 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_10_023232) do
|
||||
t.index ["user_id"], name: "index_waf_policies_on_user_id"
|
||||
end
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "events", "request_hosts"
|
||||
add_foreign_key "events", "rules"
|
||||
add_foreign_key "network_ranges", "users"
|
||||
add_foreign_key "rules", "network_ranges"
|
||||
add_foreign_key "rules", "users"
|
||||
|
||||
57
lib/tasks/events.rake
Normal file
57
lib/tasks/events.rake
Normal file
@@ -0,0 +1,57 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
namespace :events do
|
||||
desc "Backfill network intelligence data for events"
|
||||
task backfill_network_intelligence: :environment do
|
||||
batch_size = ENV['BATCH_SIZE']&.to_i || 10_000
|
||||
Event.backfill_network_intelligence!(batch_size: batch_size)
|
||||
end
|
||||
|
||||
desc "Backfill events with missing network intelligence (newly imported network data)"
|
||||
task backfill_missing: :environment do
|
||||
count = Event.where(country: nil).count
|
||||
|
||||
if count.zero?
|
||||
puts "✓ No events missing network intelligence"
|
||||
else
|
||||
puts "Found #{count} events without network intelligence"
|
||||
puts "Backfilling..."
|
||||
|
||||
processed = 0
|
||||
Event.where(country: nil).find_in_batches(batch_size: 1000) do |batch|
|
||||
batch.each(&:save)
|
||||
processed += batch.size
|
||||
puts " Processed #{processed}/#{count}"
|
||||
end
|
||||
|
||||
puts "✓ Complete"
|
||||
end
|
||||
end
|
||||
|
||||
desc "Show backfill progress"
|
||||
task backfill_progress: :environment do
|
||||
total = Event.count
|
||||
with_country = Event.where.not(country: nil).count
|
||||
without_country = Event.where(country: nil).count
|
||||
percent = (with_country.to_f / total * 100).round(1)
|
||||
|
||||
puts "=" * 60
|
||||
puts "Network Intelligence Backfill Progress"
|
||||
puts "=" * 60
|
||||
puts "Total events: #{total}"
|
||||
puts "With network data: #{with_country} (#{percent}%)"
|
||||
puts "Missing network data: #{without_country}"
|
||||
puts "=" * 60
|
||||
|
||||
if without_country > 0
|
||||
puts
|
||||
puts "To continue backfill:"
|
||||
puts " rails events:backfill_network_intelligence"
|
||||
puts
|
||||
puts "Or with custom batch size:"
|
||||
puts " BATCH_SIZE=5000 rails events:backfill_network_intelligence"
|
||||
else
|
||||
puts "✓ Backfill complete!"
|
||||
end
|
||||
end
|
||||
end
|
||||
229
test/controllers/api/events_controller_test.rb
Normal file
229
test/controllers/api/events_controller_test.rb
Normal file
@@ -0,0 +1,229 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class Api::EventsControllerTest < ActionDispatch::IntegrationTest
|
||||
def setup
|
||||
@dsn = Dsn.create!(name: "Test DSN", key: "test-api-key-1234567890abcdef")
|
||||
@disabled_dsn = Dsn.create!(name: "Disabled DSN", key: "disabled-key-1234567890abcdef", enabled: false)
|
||||
|
||||
@sample_event_data = {
|
||||
"timestamp" => Time.current.iso8601,
|
||||
"method" => "GET",
|
||||
"path" => "/api/test",
|
||||
"status" => 200,
|
||||
"ip" => "192.168.1.100",
|
||||
"user_agent" => "TestAgent/1.0"
|
||||
}
|
||||
end
|
||||
|
||||
test "should create event with valid DSN via query parameter" do
|
||||
post api_events_path,
|
||||
params: @sample_event_data.merge(baffle_key: @dsn.key),
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert json_response["success"]
|
||||
assert_not_nil json_response["rule_version"]
|
||||
assert_not_nil response.headers['X-Rule-Version']
|
||||
end
|
||||
|
||||
test "should create event with valid DSN via Authorization header" do
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: @sample_event_data,
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert json_response["success"]
|
||||
end
|
||||
|
||||
test "should create event with valid DSN via X-Baffle-Auth header" do
|
||||
post api_events_path,
|
||||
headers: { "X-Baffle-Auth" => "Baffle baffle_key=#{@dsn.key}, baffle_version=1" },
|
||||
params: @sample_event_data,
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert json_response["success"]
|
||||
end
|
||||
|
||||
test "should create event with valid DSN via Basic auth" do
|
||||
credentials = Base64.strict_encode64("#{@dsn.key}:password")
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Basic #{credentials}" },
|
||||
params: @sample_event_data,
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert json_response["success"]
|
||||
end
|
||||
|
||||
test "should create event with form encoded data" do
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: @sample_event_data,
|
||||
as: :url_encoded
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should include rules in response when agent has no version" do
|
||||
# Create some test rules
|
||||
Rule.create!(action: "block", pattern_type: "ip", pattern: "192.168.1.0/24", reason: "Test rule")
|
||||
Rule.create!(action: "allow", pattern_type: "ip", pattern: "10.0.0.0/8", reason: "Allow internal")
|
||||
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: @sample_event_data,
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert json_response["success"]
|
||||
assert json_response["rules_changed"]
|
||||
assert_not_nil json_response["rules"]
|
||||
assert_equal 2, json_response["rules"].length
|
||||
end
|
||||
|
||||
test "should include only new rules when agent has old version" do
|
||||
# Create rules with different versions
|
||||
old_rule = Rule.create!(action: "block", pattern_type: "ip", pattern: "192.168.1.0/24", reason: "Old rule", version: 1)
|
||||
new_rule = Rule.create!(action: "block", pattern_type: "ip", pattern: "203.0.113.0/24", reason: "New rule", version: 2)
|
||||
|
||||
event_data_with_version = @sample_event_data.merge("last_rule_sync" => 1)
|
||||
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: event_data_with_version,
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert json_response["success"]
|
||||
assert json_response["rules_changed"]
|
||||
assert_equal 1, json_response["rules"].length
|
||||
assert_equal "203.0.113.0/24", json_response["rules"].first["pattern"]
|
||||
end
|
||||
|
||||
test "should not include rules when agent has latest version" do
|
||||
# Create a rule and get its version
|
||||
rule = Rule.create!(action: "block", pattern_type: "ip", pattern: "192.168.1.0/24", reason: "Test rule")
|
||||
latest_version = Rule.latest_version
|
||||
|
||||
event_data_with_latest_version = @sample_event_data.merge("last_rule_sync" => latest_version)
|
||||
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: event_data_with_latest_version,
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert json_response["success"]
|
||||
assert_not json_response["rules_changed"]
|
||||
assert_nil json_response["rules"]
|
||||
end
|
||||
|
||||
test "should return unauthorized with invalid DSN key" do
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer invalid-key-1234567890abcdef" },
|
||||
params: @sample_event_data,
|
||||
as: :json
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "should return unauthorized with disabled DSN" do
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@disabled_dsn.key}" },
|
||||
params: @sample_event_data,
|
||||
as: :json
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "should return unauthorized with no authentication" do
|
||||
post api_events_path,
|
||||
params: @sample_event_data,
|
||||
as: :json
|
||||
|
||||
assert_response :unauthorized
|
||||
end
|
||||
|
||||
test "should return bad request with invalid JSON" do
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: "invalid json {",
|
||||
as: :json
|
||||
|
||||
assert_response :bad_request
|
||||
end
|
||||
|
||||
test "should handle empty request body gracefully" do
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: {},
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
json_response = JSON.parse(response.body)
|
||||
assert json_response["success"]
|
||||
end
|
||||
|
||||
test "should set sampling headers in response" do
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: @sample_event_data,
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
assert_not_nil response.headers['X-Sample-Rate']
|
||||
assert_not_nil response.headers['X-Sample-Until']
|
||||
end
|
||||
|
||||
test "should set rule version header in response" do
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: @sample_event_data,
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
assert_not_nil response.headers['X-Rule-Version']
|
||||
assert_match /^\d+$/, response.headers['X-Rule-Version']
|
||||
end
|
||||
|
||||
test "should handle large event payloads" do
|
||||
large_payload = @sample_event_data.merge(
|
||||
"large_field" => "x" * 10000, # 10KB of data
|
||||
"headers" => { "user-agent" => "TestAgent", "accept" => "*/*" },
|
||||
"custom_data" => Hash[*(1..100).map { |i| ["key#{i}", "value#{i}"] }.flatten]
|
||||
)
|
||||
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: large_payload,
|
||||
as: :json
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should process event asynchronously" do
|
||||
# Clear any existing jobs
|
||||
ActiveJob::Base.queue_adapter.perform_enqueued_at_jobs = true
|
||||
ActiveJob::Base.queue_adapter.perform_enqueued_jobs = false
|
||||
|
||||
assert_difference 'ProcessWafEventJob.jobs.count', 1 do
|
||||
post api_events_path,
|
||||
headers: { "Authorization" => "Bearer #{@dsn.key}" },
|
||||
params: @sample_event_data,
|
||||
as: :json
|
||||
end
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
@@ -10,18 +10,21 @@ module Api
|
||||
key: "test-key-#{SecureRandom.hex(8)}"
|
||||
)
|
||||
|
||||
@rule1_network_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
@rule1 = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
source: "manual"
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: @rule1_network_range,
|
||||
source: "manual",
|
||||
user: users(:one)
|
||||
)
|
||||
|
||||
@rule2 = Rule.create!(
|
||||
rule_type: "rate_limit",
|
||||
action: "rate_limit",
|
||||
waf_rule_type: "rate_limit",
|
||||
waf_action: "rate_limit",
|
||||
conditions: { cidr: "0.0.0.0/0", scope: "global" },
|
||||
metadata: { limit: 100, window: 60 }
|
||||
metadata: { limit: 100, window: 60 },
|
||||
user: users(:one)
|
||||
)
|
||||
end
|
||||
|
||||
@@ -68,8 +71,8 @@ module Api
|
||||
assert_equal 2, json["rules"].length
|
||||
|
||||
rule = json["rules"].find { |r| r["id"] == @rule1.id }
|
||||
assert_equal "network_v4", rule["rule_type"]
|
||||
assert_equal "deny", rule["action"]
|
||||
assert_equal "network", rule["waf_rule_type"]
|
||||
assert_equal "deny", rule["waf_action"]
|
||||
assert_equal({ "cidr" => "10.0.0.0/8" }, rule["conditions"])
|
||||
assert_equal 8, rule["priority"]
|
||||
end
|
||||
@@ -159,24 +162,27 @@ module Api
|
||||
|
||||
test "rules are ordered by updated_at for sync" do
|
||||
# Create rules with different timestamps
|
||||
oldest_range = NetworkRange.create!(cidr: "192.168.1.0/24")
|
||||
oldest = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.1.0/24" }
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: oldest_range
|
||||
)
|
||||
oldest.update_column(:updated_at, 3.hours.ago)
|
||||
|
||||
middle_range = NetworkRange.create!(cidr: "192.168.2.0/24")
|
||||
middle = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.2.0/24" }
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: middle_range
|
||||
)
|
||||
middle.update_column(:updated_at, 2.hours.ago)
|
||||
|
||||
newest_range = NetworkRange.create!(cidr: "192.168.3.0/24")
|
||||
newest = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.3.0/24" }
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: newest_range
|
||||
)
|
||||
|
||||
get "/api/rules?since=#{4.hours.ago.iso8601}"
|
||||
|
||||
2
test/fixtures/ipv4_ranges.yml.bak
vendored
Normal file
2
test/fixtures/ipv4_ranges.yml.bak
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
# Empty fixtures - tests create their own data
|
||||
2
test/fixtures/ipv6_ranges.yml.bak
vendored
Normal file
2
test/fixtures/ipv6_ranges.yml.bak
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
# Empty fixtures - tests create their own data
|
||||
1
test/fixtures/rule_sets.yml.bak
vendored
Normal file
1
test/fixtures/rule_sets.yml.bak
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# Empty fixtures
|
||||
9
test/fixtures/settings.yml
vendored
Normal file
9
test/fixtures/settings.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
key: MyString1
|
||||
value: MyString1
|
||||
|
||||
two:
|
||||
key: MyString2
|
||||
value: MyString2
|
||||
23
test/fixtures/waf_policies.yml
vendored
Normal file
23
test/fixtures/waf_policies.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||
|
||||
one:
|
||||
name: Policy One
|
||||
description: MyText
|
||||
policy_type: MyString
|
||||
policy_action: MyString
|
||||
targets:
|
||||
enabled: false
|
||||
expires_at: 2025-11-10 13:30:53
|
||||
user: one
|
||||
additional_data:
|
||||
|
||||
two:
|
||||
name: Policy Two
|
||||
description: MyText
|
||||
policy_type: MyString
|
||||
policy_action: MyString
|
||||
targets:
|
||||
enabled: false
|
||||
expires_at: 2025-11-10 13:30:53
|
||||
user: two
|
||||
additional_data:
|
||||
292
test/integration/waf_policy_brazil_test.rb
Normal file
292
test/integration/waf_policy_brazil_test.rb
Normal file
@@ -0,0 +1,292 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
# Custom test class that avoids fixture loading issues
|
||||
class WafPolicyBrazilTest < Minitest::Test
|
||||
def setup
|
||||
# Clean up any existing data
|
||||
Event.delete_all
|
||||
Rule.delete_all
|
||||
NetworkRange.delete_all
|
||||
WafPolicy.delete_all
|
||||
User.delete_all
|
||||
|
||||
@user = User.create!(email_address: "test@example.com", password: "password")
|
||||
|
||||
# Create a WAF policy to block Brazil
|
||||
@brazil_policy = WafPolicy.create_country_policy(
|
||||
['BR'],
|
||||
policy_action: 'deny',
|
||||
user: @user,
|
||||
name: "Block Brazil"
|
||||
)
|
||||
|
||||
# Sample event data for a Brazilian IP
|
||||
@brazil_ip = "177.104.144.0" # Known Brazilian IP
|
||||
@brazil_event_data = {
|
||||
"request_id" => "brazil-test-123",
|
||||
"timestamp" => Time.now.iso8601,
|
||||
"request" => {
|
||||
"ip" => @brazil_ip,
|
||||
"method" => "GET",
|
||||
"path" => "/api/test",
|
||||
"headers" => {
|
||||
"host" => "example.com",
|
||||
"user-agent" => "TestAgent/1.0"
|
||||
}
|
||||
},
|
||||
"response" => {
|
||||
"status_code" => 200,
|
||||
"duration_ms" => 150
|
||||
},
|
||||
"waf_action" => "allow",
|
||||
"server_name" => "test-server",
|
||||
"environment" => "test",
|
||||
"geo" => {
|
||||
"country_code" => "BR",
|
||||
"city" => "São Paulo"
|
||||
},
|
||||
"agent" => {
|
||||
"name" => "baffle-agent",
|
||||
"version" => "1.0.0"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def teardown
|
||||
Event.delete_all
|
||||
Rule.delete_all
|
||||
NetworkRange.delete_all
|
||||
WafPolicy.delete_all
|
||||
User.delete_all
|
||||
end
|
||||
|
||||
def test_brazil_waf_policy_generates_block_rule_when_brazilian_event_is_processed
|
||||
# Process the Brazilian event
|
||||
event = Event.create_from_waf_payload!("brazil-test", @brazil_event_data)
|
||||
assert event.persisted?
|
||||
|
||||
# Extract country code from payload geo data
|
||||
country_code = event.payload.dig("geo", "country_code")
|
||||
assert_equal "BR", country_code
|
||||
assert_equal @brazil_ip, event.ip_address.to_s
|
||||
|
||||
# Ensure network range exists for the Brazilian IP
|
||||
network_range = NetworkRangeGenerator.find_or_create_for_ip(@brazil_ip)
|
||||
assert network_range.persisted?
|
||||
assert network_range.contains_ip?(@brazil_ip)
|
||||
|
||||
# Set the country on the network range to simulate geo-lookup
|
||||
network_range.update!(country: 'BR')
|
||||
|
||||
# Process WAF policies for this network range
|
||||
ProcessWafPoliciesJob.perform_now(network_range: network_range, event: event)
|
||||
|
||||
# Verify that a blocking rule was generated
|
||||
generated_rules = Rule.where(
|
||||
network_range: network_range,
|
||||
policy_action: 'deny',
|
||||
waf_policy: @brazil_policy
|
||||
)
|
||||
|
||||
assert_equal 1, generated_rules.count, "Should have generated exactly one blocking rule"
|
||||
|
||||
rule = generated_rules.first
|
||||
assert_equal 'deny', rule.waf_action
|
||||
assert_equal network_range, rule.network_range
|
||||
assert_equal @brazil_policy, rule.waf_policy
|
||||
assert_equal "policy", rule.source
|
||||
assert rule.enabled?, "Generated rule should be enabled"
|
||||
|
||||
# Verify rule metadata contains policy information
|
||||
metadata = rule.metadata
|
||||
assert_equal @brazil_policy.id, metadata['generated_by_policy']
|
||||
assert_equal "Block Brazil", metadata['policy_name']
|
||||
assert_equal "country", metadata['policy_type']
|
||||
assert_equal "country", metadata['matched_field']
|
||||
assert_equal "BR", metadata['matched_value']
|
||||
end
|
||||
|
||||
def test_non_brazilian_event_does_not_generate_block_rule_from_brazil_policy
|
||||
# Create event data for a US IP
|
||||
us_ip = "8.8.8.8" # Known US IP
|
||||
us_event_data = @brazil_event_data.dup
|
||||
us_event_data["event_id"] = "us-test-123"
|
||||
us_event_data["request"]["ip"] = us_ip
|
||||
us_event_data["geo"]["country_code"] = "US"
|
||||
us_event_data["geo"]["city"] = "Mountain View"
|
||||
|
||||
# Process the US event
|
||||
event = Event.create_from_waf_payload!("us-test", us_event_data)
|
||||
assert event.persisted?
|
||||
|
||||
# Extract country code from payload geo data
|
||||
country_code = event.payload.dig("geo", "country_code")
|
||||
assert_equal "US", country_code
|
||||
assert_equal us_ip, event.ip_address.to_s
|
||||
|
||||
# Ensure network range exists for the US IP
|
||||
network_range = NetworkRangeGenerator.find_or_create_for_ip(us_ip)
|
||||
assert network_range.persisted?
|
||||
network_range.update!(country: 'US')
|
||||
|
||||
# Process WAF policies for this network range
|
||||
ProcessWafPoliciesJob.perform_now(network_range: network_range, event: event)
|
||||
|
||||
# Verify that no blocking rule was generated
|
||||
generated_rules = Rule.where(
|
||||
network_range: network_range,
|
||||
policy_action: 'deny',
|
||||
waf_policy: @brazil_policy
|
||||
)
|
||||
|
||||
assert_equal 0, generated_rules.count, "Should not have generated any blocking rules for US IP"
|
||||
end
|
||||
|
||||
def test_multiple_country_policies_generate_rules_for_matching_countries_only
|
||||
# Create additional policy to block China
|
||||
china_policy = WafPolicy.create_country_policy(
|
||||
['CN'],
|
||||
policy_action: 'deny',
|
||||
user: @user,
|
||||
name: "Block China"
|
||||
)
|
||||
|
||||
# Create Chinese IP event
|
||||
china_ip = "220.181.38.148" # Known Chinese IP
|
||||
china_event_data = @brazil_event_data.dup
|
||||
china_event_data["event_id"] = "china-test-123"
|
||||
china_event_data["request"]["ip"] = china_ip
|
||||
china_event_data["geo"]["country_code"] = "CN"
|
||||
china_event_data["geo"]["city"] = "Beijing"
|
||||
|
||||
# Process Chinese event
|
||||
china_event = Event.create_from_waf_payload!("china-test", china_event_data)
|
||||
china_network_range = NetworkRangeGenerator.find_or_create_for_ip(china_ip)
|
||||
china_network_range.update!(country: 'CN')
|
||||
|
||||
# Process Brazilian event (from setup)
|
||||
brazil_event = Event.create_from_waf_payload!("brazil-test", @brazil_event_data)
|
||||
brazil_network_range = NetworkRangeGenerator.find_or_create_for_ip(@brazil_ip)
|
||||
brazil_network_range.update!(country: 'BR')
|
||||
|
||||
# Process WAF policies for both network ranges
|
||||
ProcessWafPoliciesJob.perform_now(network_range: brazil_network_range, event: brazil_event)
|
||||
ProcessWafPoliciesJob.perform_now(network_range: china_network_range, event: china_event)
|
||||
|
||||
# Verify Brazil IP matched Brazil policy only
|
||||
brazil_rules = Rule.where(network_range: brazil_network_range)
|
||||
assert_equal 1, brazil_rules.count
|
||||
brazil_rule = brazil_rules.first
|
||||
assert_equal @brazil_policy, brazil_rule.waf_policy
|
||||
assert_equal "BR", brazil_rule.metadata['matched_value']
|
||||
|
||||
# Verify China IP matched China policy only
|
||||
china_rules = Rule.where(network_range: china_network_range)
|
||||
assert_equal 1, china_rules.count
|
||||
china_rule = china_rules.first
|
||||
assert_equal china_policy, china_rule.waf_policy
|
||||
assert_equal "CN", china_rule.metadata['matched_value']
|
||||
end
|
||||
|
||||
def test_policy_expiration_prevents_rule_generation
|
||||
# Create an expired Brazil policy
|
||||
expired_policy = WafPolicy.create_country_policy(
|
||||
['BR'],
|
||||
policy_action: 'deny',
|
||||
user: @user,
|
||||
name: "Expired Brazil Block",
|
||||
expires_at: 1.day.ago
|
||||
)
|
||||
|
||||
# Process Brazilian event
|
||||
event = Event.create_from_waf_payload!("expired-test", @brazil_event_data)
|
||||
network_range = NetworkRangeGenerator.find_or_create_for_ip(@brazil_ip)
|
||||
network_range.update!(country: 'BR')
|
||||
|
||||
# Process WAF policies
|
||||
ProcessWafPoliciesJob.perform_now(network_range: network_range, event: event)
|
||||
|
||||
# Verify that no rule was generated from expired policy
|
||||
generated_rules = Rule.where(
|
||||
network_range: network_range,
|
||||
policy_action: 'deny',
|
||||
waf_policy: expired_policy
|
||||
)
|
||||
|
||||
assert_equal 0, generated_rules.count, "Expired policy should not generate rules"
|
||||
end
|
||||
|
||||
def test_disabled_policy_prevents_rule_generation
|
||||
# Create a disabled Brazil policy
|
||||
disabled_policy = WafPolicy.create_country_policy(
|
||||
['BR'],
|
||||
policy_action: 'deny',
|
||||
user: @user,
|
||||
name: "Disabled Brazil Block"
|
||||
)
|
||||
disabled_policy.update!(enabled: false)
|
||||
|
||||
# Process Brazilian event
|
||||
event = Event.create_from_waf_payload!("disabled-test", @brazil_event_data)
|
||||
network_range = NetworkRangeGenerator.find_or_create_for_ip(@brazil_ip)
|
||||
network_range.update!(country: 'BR')
|
||||
|
||||
# Process WAF policies
|
||||
ProcessWafPoliciesJob.perform_now(network_range: network_range, event: event)
|
||||
|
||||
# Verify that no rule was generated from disabled policy
|
||||
generated_rules = Rule.where(
|
||||
network_range: network_range,
|
||||
policy_action: 'deny',
|
||||
waf_policy: disabled_policy
|
||||
)
|
||||
|
||||
assert_equal 0, generated_rules.count, "Disabled policy should not generate rules"
|
||||
end
|
||||
|
||||
def test_policy_action_types_are_correctly_applied_to_generated_rules
|
||||
# Test different policy actions
|
||||
redirect_policy = WafPolicy.create_country_policy(
|
||||
['BR'],
|
||||
policy_action: 'redirect',
|
||||
user: @user,
|
||||
name: "Redirect Brazil",
|
||||
additional_data: {
|
||||
'redirect_url' => 'https://example.com/blocked',
|
||||
'redirect_status' => 302
|
||||
}
|
||||
)
|
||||
|
||||
challenge_policy = WafPolicy.create_country_policy(
|
||||
['BR'],
|
||||
policy_action: 'challenge',
|
||||
user: @user,
|
||||
name: "Challenge Brazil",
|
||||
additional_data: {
|
||||
'challenge_type' => 'captcha',
|
||||
'challenge_message' => 'Please verify you are human'
|
||||
}
|
||||
)
|
||||
|
||||
# Process Brazilian event for redirect policy
|
||||
event = Event.create_from_waf_payload!("redirect-test", @brazil_event_data)
|
||||
network_range = NetworkRangeGenerator.find_or_create_for_ip(@brazil_ip)
|
||||
network_range.update!(country: 'BR')
|
||||
|
||||
# Manually create rule for redirect policy to test metadata handling
|
||||
redirect_rule = redirect_policy.create_rule_for_network_range(network_range)
|
||||
assert redirect_rule.persisted?
|
||||
assert_equal 'redirect', redirect_rule.action
|
||||
assert_equal 'https://example.com/blocked', redirect_rule.redirect_url
|
||||
assert_equal 302, redirect_rule.redirect_status
|
||||
|
||||
# Manually create rule for challenge policy to test metadata handling
|
||||
challenge_rule = challenge_policy.create_rule_for_network_range(network_range)
|
||||
assert challenge_rule.persisted?
|
||||
assert_equal 'challenge', challenge_rule.action
|
||||
assert_equal 'captcha', challenge_rule.challenge_type
|
||||
assert_equal 'Please verify you are human', challenge_rule.challenge_message
|
||||
end
|
||||
end
|
||||
290
test/integration/waf_policy_integration_test.rb
Normal file
290
test/integration/waf_policy_integration_test.rb
Normal file
@@ -0,0 +1,290 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class WafPolicyIntegrationTest < ActiveSupport::TestCase
|
||||
# Don't load any fixtures
|
||||
self.use_transactional_tests = true
|
||||
|
||||
def setup
|
||||
# Clean up any existing data
|
||||
Event.delete_all
|
||||
Rule.delete_all
|
||||
NetworkRange.delete_all
|
||||
WafPolicy.delete_all
|
||||
User.delete_all
|
||||
Project.delete_all
|
||||
|
||||
@user = User.create!(email_address: "test@example.com", password: "password")
|
||||
@project = Project.create!(name: "Test Project", slug: "test-project", public_key: "test-key-123456")
|
||||
|
||||
# Create a WAF policy to block Brazil
|
||||
@brazil_policy = WafPolicy.create_country_policy(
|
||||
['BR'],
|
||||
policy_policy_action: 'deny',
|
||||
user: @user,
|
||||
name: "Block Brazil"
|
||||
)
|
||||
|
||||
# Sample event data for a Brazilian IP
|
||||
@brazil_ip = "177.104.144.10" # Known Brazilian IP
|
||||
@brazil_event_data = {
|
||||
"request_id" => "brazil-test-123",
|
||||
"timestamp" => Time.now.iso8601,
|
||||
"request" => {
|
||||
"ip" => @brazil_ip,
|
||||
"method" => "GET",
|
||||
"path" => "/api/test",
|
||||
"headers" => {
|
||||
"host" => "example.com",
|
||||
"user-agent" => "TestAgent/1.0"
|
||||
}
|
||||
},
|
||||
"response" => {
|
||||
"status_code" => 200,
|
||||
"duration_ms" => 150
|
||||
},
|
||||
"waf_action" => "allow",
|
||||
"server_name" => "test-server",
|
||||
"environment" => "test",
|
||||
"geo" => {
|
||||
"country_code" => "BR",
|
||||
"city" => "São Paulo"
|
||||
},
|
||||
"agent" => {
|
||||
"name" => "baffle-agent",
|
||||
"version" => "1.0.0"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def teardown
|
||||
Event.delete_all
|
||||
Rule.delete_all
|
||||
NetworkRange.delete_all
|
||||
WafPolicy.delete_all
|
||||
User.delete_all
|
||||
end
|
||||
|
||||
test "Brazil WAF policy generates block rule when Brazilian event is processed" do
|
||||
# Process the Brazilian event
|
||||
event = Event.create_from_waf_payload!("brazil-test", @brazil_event_data)
|
||||
assert event.persisted?
|
||||
assert_equal "BR", event.country_code
|
||||
assert_equal @brazil_ip, event.ip_address
|
||||
|
||||
# Ensure network range exists for the Brazilian IP
|
||||
network_range = NetworkRangeGenerator.find_or_create_for_ip(@brazil_ip)
|
||||
assert network_range.persisted?
|
||||
assert network_range.contains_ip?(@brazil_ip)
|
||||
|
||||
# Set the country on the network range to simulate geo-lookup
|
||||
network_range.update!(country: 'BR')
|
||||
|
||||
# Process WAF policies for this network range
|
||||
ProcessWafPoliciesJob.perform_now(network_range: network_range, event: event)
|
||||
|
||||
# Verify that a blocking rule was generated
|
||||
generated_rules = Rule.where(
|
||||
network_range: network_range,
|
||||
policy_action: 'deny',
|
||||
waf_policy: @brazil_policy
|
||||
)
|
||||
|
||||
assert_equal 1, generated_rules.count, "Should have generated exactly one blocking rule"
|
||||
|
||||
rule = generated_rules.first
|
||||
assert_equal 'deny', rule.waf_action
|
||||
assert_equal network_range, rule.network_range
|
||||
assert_equal @brazil_policy, rule.waf_policy
|
||||
assert_equal "policy:Block Brazil", rule.source
|
||||
assert rule.enabled?, "Generated rule should be enabled"
|
||||
|
||||
# Verify rule metadata contains policy information
|
||||
metadata = rule.metadata
|
||||
assert_equal @brazil_policy.id, metadata['generated_by_policy']
|
||||
assert_equal "Block Brazil", metadata['policy_name']
|
||||
assert_equal "country", metadata['policy_type']
|
||||
assert_equal "country", metadata['matched_field']
|
||||
assert_equal "BR", metadata['matched_value']
|
||||
end
|
||||
|
||||
test "Non-Brazilian event does not generate block rule from Brazil policy" do
|
||||
# Create event data for a US IP
|
||||
us_ip = "8.8.8.8" # Known US IP
|
||||
us_event_data = @brazil_event_data.dup
|
||||
us_event_data["event_id"] = "us-test-123"
|
||||
us_event_data["request"]["ip"] = us_ip
|
||||
us_event_data["geo"]["country_code"] = "US"
|
||||
us_event_data["geo"]["city"] = "Mountain View"
|
||||
|
||||
# Process the US event
|
||||
event = Event.create_from_waf_payload!("us-test", us_event_data)
|
||||
assert event.persisted?
|
||||
assert_equal "US", event.country_code
|
||||
assert_equal us_ip, event.ip_address
|
||||
|
||||
# Ensure network range exists for the US IP
|
||||
network_range = NetworkRangeGenerator.find_or_create_for_ip(us_ip)
|
||||
assert network_range.persisted?
|
||||
network_range.update!(country: 'US')
|
||||
|
||||
# Process WAF policies for this network range
|
||||
ProcessWafPoliciesJob.perform_now(network_range: network_range, event: event)
|
||||
|
||||
# Verify that no blocking rule was generated
|
||||
generated_rules = Rule.where(
|
||||
network_range: network_range,
|
||||
policy_action: 'deny',
|
||||
waf_policy: @brazil_policy
|
||||
)
|
||||
|
||||
assert_equal 0, generated_rules.count, "Should not have generated any blocking rules for US IP"
|
||||
end
|
||||
|
||||
test "Multiple country policies generate rules for matching countries only" do
|
||||
# Create additional policy to block China
|
||||
china_policy = WafPolicy.create_country_policy(
|
||||
['CN'],
|
||||
policy_action: 'deny',
|
||||
user: @user,
|
||||
name: "Block China"
|
||||
)
|
||||
|
||||
# Create Chinese IP event
|
||||
china_ip = "220.181.38.148" # Known Chinese IP
|
||||
china_event_data = @brazil_event_data.dup
|
||||
china_event_data["event_id"] = "china-test-123"
|
||||
china_event_data["request"]["ip"] = china_ip
|
||||
china_event_data["geo"]["country_code"] = "CN"
|
||||
china_event_data["geo"]["city"] = "Beijing"
|
||||
|
||||
# Process Chinese event
|
||||
china_event = Event.create_from_waf_payload!("china-test", china_event_data)
|
||||
china_network_range = NetworkRangeGenerator.find_or_create_for_ip(china_ip)
|
||||
china_network_range.update!(country: 'CN')
|
||||
|
||||
# Process Brazilian event (from setup)
|
||||
brazil_event = Event.create_from_waf_payload!("brazil-test", @brazil_event_data)
|
||||
brazil_network_range = NetworkRangeGenerator.find_or_create_for_ip(@brazil_ip)
|
||||
brazil_network_range.update!(country: 'BR')
|
||||
|
||||
# Process WAF policies for both network ranges
|
||||
ProcessWafPoliciesJob.perform_now(network_range: brazil_network_range, event: brazil_event)
|
||||
ProcessWafPoliciesJob.perform_now(network_range: china_network_range, event: china_event)
|
||||
|
||||
# Verify Brazil IP matched Brazil policy only
|
||||
brazil_rules = Rule.where(network_range: brazil_network_range)
|
||||
assert_equal 1, brazil_rules.count
|
||||
brazil_rule = brazil_rules.first
|
||||
assert_equal @brazil_policy, brazil_rule.waf_policy
|
||||
assert_equal "BR", brazil_rule.metadata['matched_value']
|
||||
|
||||
# Verify China IP matched China policy only
|
||||
china_rules = Rule.where(network_range: china_network_range)
|
||||
assert_equal 1, china_rules.count
|
||||
china_rule = china_rules.first
|
||||
assert_equal china_policy, china_rule.waf_policy
|
||||
assert_equal "CN", china_rule.metadata['matched_value']
|
||||
end
|
||||
|
||||
test "Policy expiration prevents rule generation" do
|
||||
# Create an expired Brazil policy
|
||||
expired_policy = WafPolicy.create_country_policy(
|
||||
['BR'],
|
||||
policy_action: 'deny',
|
||||
user: @user,
|
||||
name: "Expired Brazil Block",
|
||||
expires_at: 1.day.ago
|
||||
)
|
||||
|
||||
# Process Brazilian event
|
||||
event = Event.create_from_waf_payload!("expired-test", @brazil_event_data)
|
||||
network_range = NetworkRangeGenerator.find_or_create_for_ip(@brazil_ip)
|
||||
network_range.update!(country: 'BR')
|
||||
|
||||
# Process WAF policies
|
||||
ProcessWafPoliciesJob.perform_now(network_range: network_range, event: event)
|
||||
|
||||
# Verify that no rule was generated from expired policy
|
||||
generated_rules = Rule.where(
|
||||
network_range: network_range,
|
||||
policy_action: 'deny',
|
||||
waf_policy: expired_policy
|
||||
)
|
||||
|
||||
assert_equal 0, generated_rules.count, "Expired policy should not generate rules"
|
||||
end
|
||||
|
||||
test "Disabled policy prevents rule generation" do
|
||||
# Create a disabled Brazil policy
|
||||
disabled_policy = WafPolicy.create_country_policy(
|
||||
['BR'],
|
||||
policy_action: 'deny',
|
||||
user: @user,
|
||||
name: "Disabled Brazil Block"
|
||||
)
|
||||
disabled_policy.update!(enabled: false)
|
||||
|
||||
# Process Brazilian event
|
||||
event = Event.create_from_waf_payload!("disabled-test", @brazil_event_data)
|
||||
network_range = NetworkRangeGenerator.find_or_create_for_ip(@brazil_ip)
|
||||
network_range.update!(country: 'BR')
|
||||
|
||||
# Process WAF policies
|
||||
ProcessWafPoliciesJob.perform_now(network_range: network_range, event: event)
|
||||
|
||||
# Verify that no rule was generated from disabled policy
|
||||
generated_rules = Rule.where(
|
||||
network_range: network_range,
|
||||
policy_action: 'deny',
|
||||
waf_policy: disabled_policy
|
||||
)
|
||||
|
||||
assert_equal 0, generated_rules.count, "Disabled policy should not generate rules"
|
||||
end
|
||||
|
||||
test "Policy action types are correctly applied to generated rules" do
|
||||
# Test different policy actions
|
||||
redirect_policy = WafPolicy.create_country_policy(
|
||||
['BR'],
|
||||
policy_action: 'redirect',
|
||||
user: @user,
|
||||
name: "Redirect Brazil",
|
||||
additional_data: {
|
||||
'redirect_url' => 'https://example.com/blocked',
|
||||
'redirect_status' => 302
|
||||
}
|
||||
)
|
||||
|
||||
challenge_policy = WafPolicy.create_country_policy(
|
||||
['BR'],
|
||||
policy_action: 'challenge',
|
||||
user: @user,
|
||||
name: "Challenge Brazil",
|
||||
additional_data: {
|
||||
'challenge_type' => 'captcha',
|
||||
'challenge_message' => 'Please verify you are human'
|
||||
}
|
||||
)
|
||||
|
||||
# Process Brazilian event for redirect policy
|
||||
event = Event.create_from_waf_payload!("redirect-test", @brazil_event_data)
|
||||
network_range = NetworkRangeGenerator.find_or_create_for_ip(@brazil_ip)
|
||||
network_range.update!(country: 'BR')
|
||||
|
||||
# Manually create rule for redirect policy to test metadata handling
|
||||
redirect_rule = redirect_policy.create_rule_for_network_range(network_range)
|
||||
assert redirect_rule.persisted?
|
||||
assert_equal 'redirect', redirect_rule.action
|
||||
assert_equal 'https://example.com/blocked', redirect_rule.redirect_url
|
||||
assert_equal 302, redirect_rule.redirect_status
|
||||
|
||||
# Manually create rule for challenge policy to test metadata handling
|
||||
challenge_rule = challenge_policy.create_rule_for_network_range(network_range)
|
||||
assert challenge_rule.persisted?
|
||||
assert_equal 'challenge', challenge_rule.action
|
||||
assert_equal 'captcha', challenge_rule.challenge_type
|
||||
assert_equal 'Please verify you are human', challenge_rule.challenge_message
|
||||
end
|
||||
end
|
||||
@@ -4,18 +4,20 @@ require "test_helper"
|
||||
|
||||
class ExpiredRulesCleanupJobTest < ActiveJob::TestCase
|
||||
test "disables expired rules" do
|
||||
expired_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
expired_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: expired_range,
|
||||
expires_at: 1.hour.ago,
|
||||
enabled: true
|
||||
)
|
||||
|
||||
active_range = NetworkRange.create!(cidr: "192.168.0.0/16")
|
||||
active_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.0.0/16" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: active_range,
|
||||
expires_at: 1.hour.from_now,
|
||||
enabled: true
|
||||
)
|
||||
@@ -28,10 +30,11 @@ class ExpiredRulesCleanupJobTest < ActiveJob::TestCase
|
||||
end
|
||||
|
||||
test "does not affect rules without expiration" do
|
||||
permanent_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
permanent_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: permanent_range,
|
||||
expires_at: nil,
|
||||
enabled: true
|
||||
)
|
||||
@@ -42,10 +45,11 @@ class ExpiredRulesCleanupJobTest < ActiveJob::TestCase
|
||||
end
|
||||
|
||||
test "does not affect already disabled rules" do
|
||||
disabled_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
disabled_expired_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: disabled_range,
|
||||
expires_at: 1.hour.ago,
|
||||
enabled: false
|
||||
)
|
||||
@@ -57,10 +61,11 @@ class ExpiredRulesCleanupJobTest < ActiveJob::TestCase
|
||||
end
|
||||
|
||||
test "updates updated_at timestamp when disabling" do
|
||||
expired_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
expired_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: expired_range,
|
||||
expires_at: 1.hour.ago,
|
||||
enabled: true
|
||||
)
|
||||
@@ -75,18 +80,20 @@ class ExpiredRulesCleanupJobTest < ActiveJob::TestCase
|
||||
end
|
||||
|
||||
test "deletes old disabled rules when running at 1am" do
|
||||
old_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
old_disabled_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: old_range,
|
||||
enabled: false
|
||||
)
|
||||
old_disabled_rule.update_column(:updated_at, 31.days.ago)
|
||||
|
||||
recent_range = NetworkRange.create!(cidr: "192.168.0.0/16")
|
||||
recent_disabled_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.0.0/16" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: recent_range,
|
||||
enabled: false
|
||||
)
|
||||
|
||||
@@ -99,10 +106,11 @@ class ExpiredRulesCleanupJobTest < ActiveJob::TestCase
|
||||
end
|
||||
|
||||
test "does not delete old rules when not running at 1am" do
|
||||
old_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
old_disabled_rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: old_range,
|
||||
enabled: false
|
||||
)
|
||||
old_disabled_rule.update_column(:updated_at, 31.days.ago)
|
||||
@@ -116,10 +124,11 @@ class ExpiredRulesCleanupJobTest < ActiveJob::TestCase
|
||||
|
||||
test "returns count of disabled rules" do
|
||||
3.times do |i|
|
||||
range = NetworkRange.create!(cidr: "10.#{i}.0.0/16")
|
||||
Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.#{i}.0.0/16" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: range,
|
||||
expires_at: 1.hour.ago,
|
||||
enabled: true
|
||||
)
|
||||
|
||||
387
test/jobs/fetch_ipapi_data_job_test.rb
Normal file
387
test/jobs/fetch_ipapi_data_job_test.rb
Normal file
@@ -0,0 +1,387 @@
|
||||
require "test_helper"
|
||||
|
||||
class FetchIpapiDataJobTest < ActiveJob::TestCase
|
||||
setup do
|
||||
@tracking_network = NetworkRange.create!(
|
||||
network: "192.168.1.0/24",
|
||||
source: "auto_generated",
|
||||
creation_reason: "IPAPI tracking network"
|
||||
)
|
||||
@sample_ipapi_data = {
|
||||
"ip" => "192.168.1.100",
|
||||
"type" => "ipv4",
|
||||
"continent_code" => "NA",
|
||||
"continent_name" => "North America",
|
||||
"country_code" => "US",
|
||||
"country_name" => "United States",
|
||||
"region_code" => "CA",
|
||||
"region_name" => "California",
|
||||
"city" => "San Francisco",
|
||||
"zip" => "94102",
|
||||
"latitude" => 37.7749,
|
||||
"longitude" => -122.4194,
|
||||
"location" => {
|
||||
"geoname_id" => 5391959,
|
||||
"capital" => "Washington D.C.",
|
||||
"languages" => [
|
||||
{
|
||||
"code" => "en",
|
||||
"name" => "English",
|
||||
"native" => "English"
|
||||
}
|
||||
],
|
||||
"country_flag" => "https://cdn.ipapi.com/flags/us.svg",
|
||||
"country_flag_emoji" => "🇺🇸",
|
||||
"country_flag_emoji_unicode" => "U+1F1FA U+1F1F8",
|
||||
"calling_code" => "1",
|
||||
"is_eu" => false
|
||||
},
|
||||
"time_zone" => {
|
||||
"id" => "America/Los_Angeles",
|
||||
"current_time" => "2023-12-07T12:00:00+00:00",
|
||||
"gmt_offset" => -28800,
|
||||
"code" => "PST",
|
||||
"is_dst" => false
|
||||
},
|
||||
"currency" => {
|
||||
"code" => "USD",
|
||||
"name" => "US Dollar",
|
||||
"plural" => "US dollars",
|
||||
"symbol" => "$",
|
||||
"symbol_native" => "$"
|
||||
},
|
||||
"connection" => {
|
||||
"asn" => 12345,
|
||||
"isp" => "Test ISP",
|
||||
"domain" => "test.com",
|
||||
"type" => "isp"
|
||||
},
|
||||
"security" => {
|
||||
"is_proxy" => false,
|
||||
"is_crawler" => false,
|
||||
"is_tor" => false,
|
||||
"threat_level" => "low",
|
||||
"threat_types" => []
|
||||
},
|
||||
"asn" => {
|
||||
"asn" => "AS12345 Test ISP",
|
||||
"domain" => "test.com",
|
||||
"route" => "192.168.1.0/24",
|
||||
"type" => "isp"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
teardown do
|
||||
# Clean up any test networks
|
||||
NetworkRange.where(network: "192.168.1.0/24").delete_all
|
||||
NetworkRange.where(network: "203.0.113.0/24").delete_all
|
||||
end
|
||||
|
||||
# Successful Data Fetching
|
||||
test "fetches and stores IPAPI data successfully" do
|
||||
# Mock Ipapi.lookup
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(@sample_ipapi_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
@tracking_network.reload
|
||||
assert_equal @sample_ipapi_data, @tracking_network.network_data_for(:ipapi)
|
||||
assert_not_nil @tracking_network.last_api_fetch
|
||||
assert @tracking_network.network_data['ipapi_queried_at'] > 5.seconds.ago.to_i
|
||||
assert_equal "192.168.1.0/24", @tracking_network.network_data['ipapi_returned_cidr']
|
||||
end
|
||||
|
||||
test "handles IPAPI returning different route than tracking network" do
|
||||
# IPAPI returns a more specific network
|
||||
different_route_data = @sample_ipapi_data.dup
|
||||
different_route_data["asn"]["route"] = "203.0.113.0/25"
|
||||
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(different_route_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
# Should create new network range for the correct route
|
||||
target_network = NetworkRange.find_by(network: "203.0.113.0/25")
|
||||
assert_not_nil target_network
|
||||
assert_equal different_route_data, target_network.network_data_for(:ipapi)
|
||||
assert_equal "api_imported", target_network.source
|
||||
assert_match /Created from IPAPI lookup/, target_network.creation_reason
|
||||
|
||||
# Tracking network should be marked as queried with the returned CIDR
|
||||
@tracking_network.reload
|
||||
assert_equal "203.0.113.0/25", @tracking_network.network_data['ipapi_returned_cidr']
|
||||
end
|
||||
|
||||
test "uses existing network when IPAPI returns different route" do
|
||||
# Create the target network first
|
||||
existing_network = NetworkRange.create!(
|
||||
network: "203.0.113.0/25",
|
||||
source: "manual",
|
||||
creation_reason: "Pre-existing"
|
||||
)
|
||||
|
||||
different_route_data = @sample_ipapi_data.dup
|
||||
different_route_data["asn"]["route"] = "203.0.113.0/25"
|
||||
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(different_route_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
# Should use existing network, not create new one
|
||||
existing_network.reload
|
||||
assert_equal different_route_data, existing_network.network_data_for(:ipapi)
|
||||
assert_equal 1, NetworkRange.where(network: "203.0.113.0/25").count
|
||||
end
|
||||
|
||||
# Error Handling
|
||||
test "handles IPAPI returning error gracefully" do
|
||||
error_data = {
|
||||
"error" => true,
|
||||
"reason" => "Invalid IP address",
|
||||
"ip" => "192.168.1.0"
|
||||
}
|
||||
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(error_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
# Should mark as queried to avoid immediate retry
|
||||
@tracking_network.reload
|
||||
assert @tracking_network.network_data['ipapi_queried_at'] > 5.seconds.ago.to_i
|
||||
assert_equal "192.168.1.0/24", @tracking_network.network_data['ipapi_returned_cidr']
|
||||
|
||||
# Should not store the error data
|
||||
assert_empty @tracking_network.network_data_for(:ipapi)
|
||||
end
|
||||
|
||||
test "handles IPAPI returning nil gracefully" do
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(nil)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
# Should mark as queried to avoid immediate retry
|
||||
@tracking_network.reload
|
||||
assert @tracking_network.network_data['ipapi_queried_at'] > 5.seconds.ago.to_i
|
||||
assert_equal "192.168.1.0/24", @tracking_network.network_data['ipapi_returned_cidr']
|
||||
end
|
||||
|
||||
test "handles missing network range gracefully" do
|
||||
# Use non-existent network range ID
|
||||
assert_nothing_raised do
|
||||
FetchIpapiDataJob.perform_now(network_range_id: 99999)
|
||||
end
|
||||
end
|
||||
|
||||
test "handles IPAPI service errors gracefully" do
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").raises(StandardError.new("Service unavailable"))
|
||||
|
||||
# Should not raise error but should clear fetching status
|
||||
assert_nothing_raised do
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
end
|
||||
|
||||
# Fetching status should be cleared
|
||||
assert_not @tracking_network.is_fetching_api_data?(:ipapi)
|
||||
end
|
||||
|
||||
# Fetching Status Management
|
||||
test "clears fetching status when done" do
|
||||
@tracking_network.mark_as_fetching_api_data!(:ipapi)
|
||||
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(@sample_ipapi_data)
|
||||
|
||||
assert @tracking_network.is_fetching_api_data?(:ipapi)
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
assert_not @tracking_network.is_fetching_api_data?(:ipapi)
|
||||
end
|
||||
|
||||
test "clears fetching status even on error" do
|
||||
@tracking_network.mark_as_fetching_api_data!(:ipapi)
|
||||
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").raises(StandardError.new("Service error"))
|
||||
|
||||
assert @tracking_network.is_fetching_api_data?(:ipapi)
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
assert_not @tracking_network.is_fetching_api_data?(:ipapi)
|
||||
end
|
||||
|
||||
test "clears fetching status when network range not found" do
|
||||
# Create network range and mark as fetching
|
||||
temp_network = NetworkRange.create!(
|
||||
network: "10.0.0.0/24",
|
||||
source: "auto_generated"
|
||||
)
|
||||
temp_network.mark_as_fetching_api_data!(:ipapi)
|
||||
|
||||
# Try to fetch with non-existent ID
|
||||
FetchIpapiDataJob.perform_now(network_range_id: 99999)
|
||||
|
||||
# Original network should still have fetching status cleared (ensure block runs)
|
||||
temp_network.reload
|
||||
assert_not temp_network.is_fetching_api_data?(:ipapi)
|
||||
end
|
||||
|
||||
# Turbo Broadcast
|
||||
test "broadcasts IPAPI update on success" do
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(@sample_ipapi_data)
|
||||
|
||||
# Expect Turbo broadcast
|
||||
Turbo::StreamsChannel.expects(:broadcast_replace_to)
|
||||
.with("network_range_#{@tracking_network.id}", {
|
||||
target: "ipapi_data_section",
|
||||
partial: "network_ranges/ipapi_data",
|
||||
locals: {
|
||||
ipapi_data: @sample_ipapi_data,
|
||||
network_range: @tracking_network,
|
||||
parent_with_ipapi: nil,
|
||||
ipapi_loading: false
|
||||
}
|
||||
})
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
end
|
||||
|
||||
test "does not broadcast on error" do
|
||||
error_data = { "error" => true, "reason" => "Invalid IP" }
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(error_data)
|
||||
|
||||
# Should not broadcast
|
||||
Turbo::StreamsChannel.expects(:broadcast_replace_to).never
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
end
|
||||
|
||||
# Network Address Extraction
|
||||
test "extracts correct sample IP from network" do
|
||||
# Test with different network formats
|
||||
ipv4_network = NetworkRange.create!(network: "203.0.113.0/24")
|
||||
Ipapi.expects(:lookup).with("203.0.113.0").returns(@sample_ipapi_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: ipv4_network.id)
|
||||
|
||||
ipv6_network = NetworkRange.create!(network: "2001:db8::/64")
|
||||
Ipapi.expects(:lookup).with("2001:db8::").returns(@sample_ipapi_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: ipv6_network.id)
|
||||
end
|
||||
|
||||
# Data Storage
|
||||
test "stores complete IPAPI data in network_data" do
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(@sample_ipapi_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
stored_data = @tracking_network.reload.network_data_for(:ipapi)
|
||||
assert_equal @sample_ipapi_data["country_code"], stored_data["country_code"]
|
||||
assert_equal @sample_ipapi_data["city"], stored_data["city"]
|
||||
assert_equal @sample_ipapi_data["asn"]["asn"], stored_data["asn"]["asn"]
|
||||
assert_equal @sample_ipapi_data["security"]["is_proxy"], stored_data["security"]["is_proxy"]
|
||||
end
|
||||
|
||||
test "updates last_api_fetch timestamp" do
|
||||
original_time = 1.hour.ago
|
||||
@tracking_network.update!(last_api_fetch: original_time)
|
||||
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(@sample_ipapi_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
@tracking_network.reload
|
||||
assert @tracking_network.last_api_fetch > original_time
|
||||
end
|
||||
|
||||
# IPv6 Support
|
||||
test "handles IPv6 networks correctly" do
|
||||
ipv6_network = NetworkRange.create!(
|
||||
network: "2001:db8::/64",
|
||||
source: "auto_generated",
|
||||
creation_reason: "IPAPI tracking network"
|
||||
)
|
||||
|
||||
ipv6_data = @sample_ipapi_data.dup
|
||||
ipv6_data["ip"] = "2001:db8::1"
|
||||
ipv6_data["type"] = "ipv6"
|
||||
ipv6_data["asn"]["route"] = "2001:db8::/32"
|
||||
|
||||
Ipapi.expects(:lookup).with("2001:db8::").returns(ipv6_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: ipv6_network.id)
|
||||
|
||||
ipv6_network.reload
|
||||
assert_equal ipv6_data, ipv6_network.network_data_for(:ipapi)
|
||||
assert_equal "2001:db8::/32", ipv6_network.network_data['ipapi_returned_cidr']
|
||||
end
|
||||
|
||||
# Logging
|
||||
test "logs successful fetch" do
|
||||
log_output = StringIO.new
|
||||
logger = Logger.new(log_output)
|
||||
original_logger = Rails.logger
|
||||
Rails.logger = logger
|
||||
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(@sample_ipapi_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
log_content = log_output.string
|
||||
assert_match /Fetching IPAPI data for 192\.168\.1\.0\/24 using IP 192\.168\.1\.0/, log_content
|
||||
assert_match /Successfully fetched IPAPI data/, log_content
|
||||
|
||||
Rails.logger = original_logger
|
||||
end
|
||||
|
||||
test "logs errors and warnings" do
|
||||
log_output = StringIO.new
|
||||
logger = Logger.new(log_output)
|
||||
original_logger = Rails.logger
|
||||
Rails.logger = logger
|
||||
|
||||
error_data = { "error" => true, "reason" => "Rate limited" }
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(error_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
log_content = log_output.string
|
||||
assert_match /IPAPI returned error for 192\.168\.1\.0\/24/, log_content
|
||||
|
||||
Rails.logger = original_logger
|
||||
end
|
||||
|
||||
test "logs different route handling" do
|
||||
log_output = StringIO.new
|
||||
logger = Logger.new(log_output)
|
||||
original_logger = Rails.logger
|
||||
Rails.logger = logger
|
||||
|
||||
different_route_data = @sample_ipapi_data.dup
|
||||
different_route_data["asn"]["route"] = "203.0.113.0/25"
|
||||
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").returns(different_route_data)
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
log_content = log_output.string
|
||||
assert_match /IPAPI returned different route: 203\.0\.113\.0\/25/, log_content
|
||||
assert_match /Storing IPAPI data on correct network: 203\.0\.113\.0\/25/, log_content
|
||||
|
||||
Rails.logger = original_logger
|
||||
end
|
||||
|
||||
test "logs service errors with backtrace" do
|
||||
log_output = StringIO.new
|
||||
logger = Logger.new(log_output)
|
||||
original_logger = Rails.logger
|
||||
Rails.logger = logger
|
||||
|
||||
Ipapi.expects(:lookup).with("192.168.1.0").raises(StandardError.new("Connection failed"))
|
||||
|
||||
FetchIpapiDataJob.perform_now(network_range_id: @tracking_network.id)
|
||||
|
||||
log_content = log_output.string
|
||||
assert_match /Failed to fetch IPAPI data for network_range #{@tracking_network.id}/, log_content
|
||||
assert_match /Connection failed/, log_content
|
||||
|
||||
Rails.logger = original_logger
|
||||
end
|
||||
end
|
||||
@@ -18,7 +18,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
["/.env", "/.git", "/wp-admin"].each do |path|
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: path,
|
||||
@@ -32,8 +32,8 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
|
||||
rule = Rule.where(source: "auto:scanner_detected").last
|
||||
assert_not_nil rule
|
||||
assert_equal "network_v4", rule.rule_type
|
||||
assert_equal "deny", rule.action
|
||||
assert_equal "network", rule.waf_rule_type
|
||||
assert_equal "deny", rule.waf_action
|
||||
assert_equal "#{ip}/32", rule.cidr
|
||||
assert_equal 32, rule.priority
|
||||
assert rule.enabled?
|
||||
@@ -45,7 +45,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
3.times do |i|
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
@@ -71,7 +71,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
paths.each do |path|
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: path,
|
||||
@@ -95,7 +95,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
2.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
@@ -114,7 +114,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
# Old event (outside lookback window)
|
||||
old_event = Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: 10.minutes.ago,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
@@ -125,7 +125,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
2.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.git",
|
||||
@@ -154,7 +154,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
3.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
@@ -173,7 +173,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
3.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
@@ -186,7 +186,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
assert_equal 1, count
|
||||
|
||||
rule = Rule.where(source: "auto:scanner_detected").last
|
||||
assert_equal "network_v6", rule.rule_type
|
||||
assert_equal "network", rule.waf_rule_type
|
||||
assert_equal "#{ip}/32", rule.cidr
|
||||
end
|
||||
|
||||
@@ -198,7 +198,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
3.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
@@ -216,7 +216,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
# Create event with invalid IP
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: "invalid-ip",
|
||||
request_path: "/.env",
|
||||
@@ -235,7 +235,7 @@ class PathScannerDetectorJobTest < ActiveJob::TestCase
|
||||
3.times do
|
||||
Event.create!(
|
||||
project: @project,
|
||||
event_id: SecureRandom.uuid,
|
||||
request_id: SecureRandom.uuid,
|
||||
timestamp: Time.current,
|
||||
ip_address: ip,
|
||||
request_path: "/.env",
|
||||
|
||||
363
test/jobs/process_waf_event_job_test.rb
Normal file
363
test/jobs/process_waf_event_job_test.rb
Normal file
@@ -0,0 +1,363 @@
|
||||
require "test_helper"
|
||||
|
||||
class ProcessWafEventJobTest < ActiveJob::TestCase
|
||||
setup do
|
||||
@sample_event_data = {
|
||||
"request_id" => "test-event-123",
|
||||
"timestamp" => Time.current.iso8601,
|
||||
"request" => {
|
||||
"ip" => "192.168.1.100",
|
||||
"method" => "GET",
|
||||
"path" => "/api/test",
|
||||
"headers" => {
|
||||
"host" => "example.com",
|
||||
"user-agent" => "TestAgent/1.0"
|
||||
}
|
||||
},
|
||||
"response" => {
|
||||
"status_code" => 200,
|
||||
"duration_ms" => 150
|
||||
},
|
||||
"waf_action" => "allow",
|
||||
"server_name" => "test-server",
|
||||
"environment" => "test"
|
||||
}
|
||||
|
||||
@headers = { "Content-Type" => "application/json" }
|
||||
end
|
||||
|
||||
# Single Event Processing
|
||||
test "processes single event with request_id" do
|
||||
assert_difference 'Event.count', 1 do
|
||||
ProcessWafEventJob.perform_now(event_data: @sample_event_data, headers: @headers)
|
||||
end
|
||||
|
||||
event = Event.last
|
||||
assert_equal "test-event-123", event.request_id
|
||||
assert_equal "192.168.1.100", event.ip_address
|
||||
assert_equal "/api/test", event.request_path
|
||||
assert_equal "get", event.request_method
|
||||
assert_equal "allow", event.waf_action
|
||||
end
|
||||
|
||||
test "processes single event with legacy event_id" do
|
||||
event_data = @sample_event_data.dup
|
||||
event_data.delete("request_id")
|
||||
event_data["event_id"] = "legacy-event-456"
|
||||
|
||||
assert_difference 'Event.count', 1 do
|
||||
ProcessWafEventJob.perform_now(event_data: event_data, headers: @headers)
|
||||
end
|
||||
|
||||
event = Event.last
|
||||
assert_equal "legacy-event-456", event.request_id
|
||||
end
|
||||
|
||||
test "processes single event with correlation_id" do
|
||||
event_data = @sample_event_data.dup
|
||||
event_data.delete("request_id")
|
||||
event_data["correlation_id"] = "correlation-789"
|
||||
|
||||
assert_difference 'Event.count', 1 do
|
||||
ProcessWafEventJob.perform_now(event_data: event_data, headers: @headers)
|
||||
end
|
||||
|
||||
event = Event.last
|
||||
assert_equal "correlation-789", event.request_id
|
||||
end
|
||||
|
||||
test "generates UUID for events without ID" do
|
||||
event_data = @sample_event_data.dup
|
||||
event_data.delete("request_id")
|
||||
|
||||
assert_difference 'Event.count', 1 do
|
||||
ProcessWafEventJob.perform_now(event_data: event_data, headers: @headers)
|
||||
end
|
||||
|
||||
event = Event.last
|
||||
assert_not_nil event.request_id
|
||||
assert_match /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/, event.request_id
|
||||
end
|
||||
|
||||
# Multiple Events Processing
|
||||
test "processes multiple events in events array" do
|
||||
event1 = @sample_event_data.dup
|
||||
event1["request_id"] = "event-1"
|
||||
event1["request"]["ip"] = "192.168.1.1"
|
||||
|
||||
event2 = @sample_event_data.dup
|
||||
event2["request_id"] = "event-2"
|
||||
event2["request"]["ip"] = "192.168.1.2"
|
||||
|
||||
batch_data = {
|
||||
"events" => [event1, event2]
|
||||
}
|
||||
|
||||
assert_difference 'Event.count', 2 do
|
||||
ProcessWafEventJob.perform_now(event_data: batch_data, headers: @headers)
|
||||
end
|
||||
|
||||
request_ids = Event.last(2).pluck(:request_id)
|
||||
assert_includes request_ids, "event-1"
|
||||
assert_includes request_ids, "event-2"
|
||||
end
|
||||
|
||||
# Duplicate Handling
|
||||
test "skips duplicate events" do
|
||||
# Create event first
|
||||
Event.create_from_waf_payload!("test-event-123", @sample_event_data)
|
||||
|
||||
assert_no_difference 'Event.count' do
|
||||
ProcessWafEventJob.perform_now(event_data: @sample_event_data, headers: @headers)
|
||||
end
|
||||
end
|
||||
|
||||
test "handles duplicates within batch" do
|
||||
event1 = @sample_event_data.dup
|
||||
event1["request_id"] = "duplicate-test"
|
||||
|
||||
event2 = @sample_event_data.dup
|
||||
event2["request_id"] = "duplicate-test"
|
||||
|
||||
batch_data = {
|
||||
"events" => [event1, event2]
|
||||
}
|
||||
|
||||
assert_difference 'Event.count', 1 do
|
||||
ProcessWafEventJob.perform_now(event_data: batch_data, headers: @headers)
|
||||
end
|
||||
end
|
||||
|
||||
# Network Range Processing
|
||||
test "creates tracking network for event IP" do
|
||||
ProcessWafEventJob.perform_now(event_data: @sample_event_data, headers: @headers)
|
||||
|
||||
event = Event.last
|
||||
assert_not_nil event.network_range_id
|
||||
|
||||
# Should create /24 tracking network for IPv4
|
||||
tracking_network = event.network_range
|
||||
assert_equal "192.168.1.0/24", tracking_network.network.to_s
|
||||
assert_equal "auto_generated", tracking_network.source
|
||||
assert_equal "IPAPI tracking network", tracking_network.creation_reason
|
||||
end
|
||||
|
||||
test "queues IPAPI enrichment when needed" do
|
||||
event_data = @sample_event_data.dup
|
||||
event_data["request"]["ip"] = "8.8.8.8" # Public IP that needs enrichment
|
||||
|
||||
assert_enqueued_jobs 1, only: [FetchIpapiDataJob] do
|
||||
ProcessWafEventJob.perform_now(event_data: event_data, headers: @headers)
|
||||
end
|
||||
end
|
||||
|
||||
test "skips IPAPI enrichment when recently queried" do
|
||||
# Create tracking network with recent query
|
||||
tracking_network = NetworkRange.create!(
|
||||
network: "192.168.1.0/24",
|
||||
source: "auto_generated",
|
||||
creation_reason: "IPAPI tracking network"
|
||||
)
|
||||
tracking_network.mark_ipapi_queried!("192.168.1.0/24")
|
||||
|
||||
assert_no_enqueued_jobs only: [FetchIpapiDataJob] do
|
||||
ProcessWafEventJob.perform_now(event_data: @sample_event_data, headers: @headers)
|
||||
end
|
||||
end
|
||||
|
||||
test "skips IPAPI enrichment when already fetching" do
|
||||
tracking_network = NetworkRange.create!(
|
||||
network: "192.168.1.0/24",
|
||||
source: "auto_generated",
|
||||
creation_reason: "IPAPI tracking network"
|
||||
)
|
||||
tracking_network.mark_as_fetching_api_data!(:ipapi)
|
||||
|
||||
assert_no_enqueued_jobs only: [FetchIpapiDataJob] do
|
||||
ProcessWafEventJob.perform_now(event_data: @sample_event_data, headers: @headers)
|
||||
end
|
||||
end
|
||||
|
||||
# WAF Policy Evaluation
|
||||
test "evaluates WAF policies when needed" do
|
||||
tracking_network = NetworkRange.create!(
|
||||
network: "192.168.1.0/24",
|
||||
source: "auto_generated",
|
||||
creation_reason: "IPAPI tracking network"
|
||||
)
|
||||
|
||||
# Mock WafPolicyMatcher
|
||||
WafPolicyMatcher.expects(:evaluate_and_mark!).with(tracking_network).returns({
|
||||
generated_rules: [],
|
||||
evaluated_policies: []
|
||||
})
|
||||
|
||||
ProcessWafEventJob.perform_now(event_data: @sample_event_data, headers: @headers)
|
||||
end
|
||||
|
||||
test "skips policy evaluation when not needed" do
|
||||
tracking_network = NetworkRange.create!(
|
||||
network: "192.168.1.0/24",
|
||||
source: "auto_generated",
|
||||
creation_reason: "IPAPI tracking network",
|
||||
policies_evaluated_at: 5.minutes.ago
|
||||
)
|
||||
|
||||
# Should not call WafPolicyMatcher
|
||||
WafPolicyMatcher.expects(:evaluate_and_mark!).never
|
||||
|
||||
ProcessWafEventJob.perform_now(event_data: @sample_event_data, headers: @headers)
|
||||
end
|
||||
|
||||
# Error Handling
|
||||
test "handles invalid event data format gracefully" do
|
||||
invalid_data = {
|
||||
"invalid" => "data"
|
||||
}
|
||||
|
||||
assert_no_difference 'Event.count' do
|
||||
assert_nothing_raised do
|
||||
ProcessWafEventJob.perform_now(event_data: invalid_data, headers: @headers)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "handles event creation errors gracefully" do
|
||||
invalid_event_data = @sample_event_data.dup
|
||||
invalid_event_data.delete("request") # Missing required request data
|
||||
|
||||
assert_no_difference 'Event.count' do
|
||||
assert_nothing_raised do
|
||||
ProcessWafEventJob.perform_now(event_data: invalid_event_data, headers: @headers)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "handles network processing errors gracefully" do
|
||||
# Create a tracking network that will cause an error
|
||||
tracking_network = NetworkRange.create!(
|
||||
network: "192.168.1.0/24",
|
||||
source: "auto_generated",
|
||||
creation_reason: "IPAPI tracking network"
|
||||
)
|
||||
|
||||
# Mock WafPolicyMatcher to raise an error
|
||||
WafPolicyMatcher.expects(:evaluate_and_mark!).with(tracking_network).raises(StandardError.new("Policy evaluation failed"))
|
||||
|
||||
# Event should still be created despite policy evaluation error
|
||||
assert_difference 'Event.count', 1 do
|
||||
assert_nothing_raised do
|
||||
ProcessWafEventJob.perform_now(event_data: @sample_event_data, headers: @headers)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "handles events without network ranges" do
|
||||
event_data = @sample_event_data.dup
|
||||
event_data["request"]["ip"] = "127.0.0.1" # Private/local IP
|
||||
|
||||
assert_difference 'Event.count', 1 do
|
||||
assert_nothing_raised do
|
||||
ProcessWafEventJob.perform_now(event_data: event_data, headers: @headers)
|
||||
end
|
||||
end
|
||||
|
||||
event = Event.last
|
||||
assert_nil event.network_range_id
|
||||
end
|
||||
|
||||
# Performance Logging
|
||||
test "logs processing metrics" do
|
||||
log_output = StringIO.new
|
||||
logger = Logger.new(log_output)
|
||||
original_logger = Rails.logger
|
||||
Rails.logger = logger
|
||||
|
||||
ProcessWafEventJob.perform_now(event_data: @sample_event_data, headers: @headers)
|
||||
|
||||
log_content = log_output.string
|
||||
assert_match /Processed WAF event test-event-123 in \d+\.\d+ms/, log_content
|
||||
assert_match /Processed 1 WAF events/, log_content
|
||||
|
||||
Rails.logger = original_logger
|
||||
end
|
||||
|
||||
test "logs IPAPI fetch decisions" do
|
||||
log_output = StringIO.new
|
||||
logger = Logger.new(log_output)
|
||||
original_logger = Rails.logger
|
||||
Rails.logger = logger
|
||||
|
||||
# Use a public IP to trigger IPAPI fetch
|
||||
event_data = @sample_event_data.dup
|
||||
event_data["request"]["ip"] = "8.8.8.8"
|
||||
|
||||
ProcessWafEventJob.perform_now(event_data: event_data, headers: @headers)
|
||||
|
||||
log_content = log_output.string
|
||||
assert_match /Queueing IPAPI fetch for IP 8\.8\.8\.8/, log_content
|
||||
|
||||
Rails.logger = original_logger
|
||||
end
|
||||
|
||||
# IPv6 Support
|
||||
test "creates /64 tracking network for IPv6 addresses" do
|
||||
event_data = @sample_event_data.dup
|
||||
event_data["request"]["ip"] = "2001:db8::1"
|
||||
|
||||
ProcessWafEventJob.perform_now(event_data: event_data, headers: @headers)
|
||||
|
||||
event = Event.last
|
||||
tracking_network = event.network_range
|
||||
assert_equal "2001:db8::/64", tracking_network.network.to_s
|
||||
end
|
||||
|
||||
# Mixed Batch Processing
|
||||
test "processes mixed valid and invalid events in batch" do
|
||||
valid_event = @sample_event_data.dup
|
||||
valid_event["request_id"] = "valid-event"
|
||||
|
||||
invalid_event = {
|
||||
"invalid" => "data",
|
||||
"request_id" => "invalid-event"
|
||||
}
|
||||
|
||||
batch_data = {
|
||||
"events" => [valid_event, invalid_event]
|
||||
}
|
||||
|
||||
# Should only create the valid event
|
||||
assert_difference 'Event.count', 1 do
|
||||
ProcessWafEventJob.perform_now(event_data: batch_data, headers: @headers)
|
||||
end
|
||||
|
||||
assert_equal "valid-event", Event.last.request_id
|
||||
end
|
||||
|
||||
test "handles very large batches efficiently" do
|
||||
events = []
|
||||
100.times do |i|
|
||||
event = @sample_event_data.dup
|
||||
event["request_id"] = "batch-event-#{i}"
|
||||
event["request"]["ip"] = "192.168.#{i / 256}.#{i % 256}"
|
||||
events << event
|
||||
end
|
||||
|
||||
batch_data = {
|
||||
"events" => events
|
||||
}
|
||||
|
||||
start_time = Time.current
|
||||
ProcessWafEventJob.perform_now(event_data: batch_data, headers: @headers)
|
||||
processing_time = Time.current - start_time
|
||||
|
||||
assert_equal 100, Event.count
|
||||
assert processing_time < 5.seconds, "Processing 100 events should take less than 5 seconds"
|
||||
end
|
||||
|
||||
# Integration with Other Jobs
|
||||
test "coordinates with BackfillRecentNetworkIntelligenceJob" do
|
||||
# This would be tested based on how the job enqueues other jobs
|
||||
# Implementation depends on your specific job coordination logic
|
||||
end
|
||||
end
|
||||
68
test/models/dsn_auth_service_test.rb
Normal file
68
test/models/dsn_auth_service_test.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class DsnAuthServiceTest < ActiveSupport::TestCase
|
||||
self.use_transactional_tests = true
|
||||
|
||||
def setup
|
||||
@dsn = Dsn.create!(name: "Test DSN", key: "test-auth-key-1234567890abcdef")
|
||||
end
|
||||
|
||||
def teardown
|
||||
Dsn.delete_all
|
||||
end
|
||||
|
||||
test "should authenticate via query parameter baffle_key" do
|
||||
request = ActionDispatch::TestRequest.create
|
||||
request.query_parameters = { "baffle_key" => @dsn.key }
|
||||
|
||||
authenticated_dsn = DsnAuthenticationService.authenticate(request)
|
||||
assert_equal @dsn, authenticated_dsn
|
||||
end
|
||||
|
||||
test "should authenticate via Authorization Bearer header" do
|
||||
request = ActionDispatch::TestRequest.create
|
||||
request.headers["Authorization"] = "Bearer #{@dsn.key}"
|
||||
|
||||
authenticated_dsn = DsnAuthenticationService.authenticate(request)
|
||||
assert_equal @dsn, authenticated_dsn
|
||||
end
|
||||
|
||||
test "should authenticate via Basic auth with username as key" do
|
||||
request = ActionDispatch::TestRequest.create
|
||||
credentials = Base64.strict_encode64("#{@dsn.key}:ignored-password")
|
||||
request.headers["Authorization"] = "Basic #{credentials}"
|
||||
|
||||
authenticated_dsn = DsnAuthenticationService.authenticate(request)
|
||||
assert_equal @dsn, authenticated_dsn
|
||||
end
|
||||
|
||||
test "should fail authentication with disabled DSN" do
|
||||
@dsn.update!(enabled: false)
|
||||
|
||||
request = ActionDispatch::TestRequest.create
|
||||
request.query_parameters = { "baffle_key" => @dsn.key }
|
||||
|
||||
assert_raises(DsnAuthenticationService::AuthenticationError) do
|
||||
DsnAuthenticationService.authenticate(request)
|
||||
end
|
||||
end
|
||||
|
||||
test "should fail authentication with non-existent key" do
|
||||
request = ActionDispatch::TestRequest.create
|
||||
request.query_parameters = { "baffle_key" => "non-existent-key" }
|
||||
|
||||
assert_raises(DsnAuthenticationService::AuthenticationError) do
|
||||
DsnAuthenticationService.authenticate(request)
|
||||
end
|
||||
end
|
||||
|
||||
test "should fail authentication with no authentication method" do
|
||||
request = ActionDispatch::TestRequest.create
|
||||
|
||||
assert_raises(DsnAuthenticationService::AuthenticationError) do
|
||||
DsnAuthenticationService.authenticate(request)
|
||||
end
|
||||
end
|
||||
end
|
||||
140
test/models/dsn_simple_test.rb
Normal file
140
test/models/dsn_simple_test.rb
Normal file
@@ -0,0 +1,140 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class DsnSimpleTest < ActiveSupport::TestCase
|
||||
# Don't use any fixtures
|
||||
self.use_transactional_tests = true
|
||||
|
||||
def setup
|
||||
@dsn = Dsn.new(name: "Test DSN")
|
||||
end
|
||||
|
||||
def teardown
|
||||
Dsn.delete_all
|
||||
end
|
||||
|
||||
test "should be valid with valid attributes" do
|
||||
assert @dsn.valid?
|
||||
end
|
||||
|
||||
test "should not be valid without name" do
|
||||
@dsn.name = nil
|
||||
assert_not @dsn.valid?
|
||||
assert_includes @dsn.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "should automatically generate key on create" do
|
||||
@dsn.save!
|
||||
assert_not_nil @dsn.key
|
||||
assert_equal 64, @dsn.key.length # hex(32) = 64 characters
|
||||
assert_match /\A[a-f0-9]{64}\z/, @dsn.key
|
||||
end
|
||||
|
||||
test "should not override existing key when saved" do
|
||||
@dsn.key = "existing-key-123"
|
||||
@dsn.save!
|
||||
assert_equal "existing-key-123", @dsn.key
|
||||
end
|
||||
|
||||
test "should enforce unique keys" do
|
||||
@dsn.save!
|
||||
dsn2 = Dsn.new(name: "Another DSN", key: @dsn.key)
|
||||
assert_not dsn2.valid?
|
||||
assert_includes dsn2.errors[:key], "has already been taken"
|
||||
end
|
||||
|
||||
test "should default to enabled" do
|
||||
@dsn.save!
|
||||
assert @dsn.enabled?
|
||||
end
|
||||
|
||||
test "should authenticate with valid key" do
|
||||
@dsn.save!
|
||||
authenticated_dsn = Dsn.authenticate(@dsn.key)
|
||||
assert_equal @dsn, authenticated_dsn
|
||||
end
|
||||
|
||||
test "should not authenticate with invalid key" do
|
||||
@dsn.save!
|
||||
assert_nil Dsn.authenticate("invalid-key")
|
||||
end
|
||||
|
||||
test "should not authenticate disabled DSNs" do
|
||||
@dsn.save!
|
||||
@dsn.update!(enabled: false)
|
||||
assert_nil Dsn.authenticate(@dsn.key)
|
||||
end
|
||||
|
||||
# URL Generation Tests
|
||||
test "should generate full DSN URL in development" do
|
||||
@dsn.key = "test-key-1234567890abcdef"
|
||||
@dsn.save!
|
||||
|
||||
expected = "http://test-key-1234567890abcdef@localhost"
|
||||
assert_equal expected, @dsn.full_dsn_url
|
||||
end
|
||||
|
||||
test "should generate API endpoint URL in development" do
|
||||
@dsn.save!
|
||||
|
||||
expected = "http://localhost"
|
||||
assert_equal expected, @dsn.api_endpoint_url
|
||||
end
|
||||
|
||||
test "should use custom host from environment variable" do
|
||||
ENV['RAILS_HOST'] = 'baffle.example.com'
|
||||
|
||||
@dsn.key = "custom-key-1234567890abcdef"
|
||||
@dsn.save!
|
||||
|
||||
assert_equal "http://custom-key-1234567890abcdef@baffle.example.com", @dsn.full_dsn_url
|
||||
assert_equal "http://baffle.example.com", @dsn.api_endpoint_url
|
||||
|
||||
ENV.delete('RAILS_HOST')
|
||||
end
|
||||
|
||||
test "should handle long hex keys in URLs" do
|
||||
long_key = "c92b7f8ad94ea3400299d8a6ff19e409c2df8c4540022c3167b8ac1002931624"
|
||||
@dsn.key = long_key
|
||||
@dsn.save!
|
||||
|
||||
expected = "http://#{long_key}@localhost"
|
||||
assert_equal expected, @dsn.full_dsn_url
|
||||
end
|
||||
|
||||
# Scope Tests
|
||||
test "enabled scope should return only enabled DSNs" do
|
||||
enabled_dsn = Dsn.create!(name: "Enabled DSN", enabled: true)
|
||||
disabled_dsn = Dsn.create!(name: "Disabled DSN", enabled: false)
|
||||
|
||||
enabled_dsns = Dsn.enabled
|
||||
|
||||
assert_includes enabled_dsns, enabled_dsn
|
||||
assert_not_includes enabled_dsns, disabled_dsn
|
||||
end
|
||||
|
||||
# Security Tests
|
||||
test "should generate cryptographically secure keys" do
|
||||
keys = []
|
||||
5.times do
|
||||
dsn = Dsn.create!(name: "Test DSN #{Time.current.to_f}")
|
||||
keys << dsn.key
|
||||
end
|
||||
|
||||
# All keys should be unique
|
||||
assert_equal keys.length, keys.uniq.length
|
||||
|
||||
# All keys should be valid hex
|
||||
keys.each do |key|
|
||||
assert_equal 64, key.length
|
||||
assert_match /\A[a-f0-9]{64}\z/, key
|
||||
end
|
||||
end
|
||||
|
||||
test "should not allow nil keys" do
|
||||
@dsn.key = nil
|
||||
assert_not @dsn.valid?
|
||||
assert_includes @dsn.errors[:key], "can't be blank"
|
||||
end
|
||||
end
|
||||
162
test/models/dsn_test.rb
Normal file
162
test/models/dsn_test.rb
Normal file
@@ -0,0 +1,162 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "test_helper"
|
||||
|
||||
class DsnTest < ActiveSupport::TestCase
|
||||
# Disable fixtures since we're creating test data manually
|
||||
self.use_instantiated_fixtures = false
|
||||
def setup
|
||||
@dsn = Dsn.new(name: "Test DSN")
|
||||
end
|
||||
|
||||
test "should be valid with valid attributes" do
|
||||
assert @dsn.valid?
|
||||
end
|
||||
|
||||
test "should not be valid without name" do
|
||||
@dsn.name = nil
|
||||
assert_not @dsn.valid?
|
||||
assert_includes @dsn.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "should automatically generate key on create" do
|
||||
@dsn.save!
|
||||
assert_not_nil @dsn.key
|
||||
assert_equal 64, @dsn.key.length # hex(32) = 64 characters
|
||||
assert_match /\A[a-f0-9]{64}\z/, @dsn.key
|
||||
end
|
||||
|
||||
test "should not override existing key when saved" do
|
||||
@dsn.key = "existing-key-123"
|
||||
@dsn.save!
|
||||
assert_equal "existing-key-123", @dsn.key
|
||||
end
|
||||
|
||||
test "should enforce unique keys" do
|
||||
@dsn.save!
|
||||
dsn2 = Dsn.new(name: "Another DSN", key: @dsn.key)
|
||||
assert_not dsn2.valid?
|
||||
assert_includes dsn2.errors[:key], "has already been taken"
|
||||
end
|
||||
|
||||
test "should default to enabled" do
|
||||
@dsn.save!
|
||||
assert @dsn.enabled?
|
||||
end
|
||||
|
||||
test "should authenticate with valid key" do
|
||||
@dsn.save!
|
||||
authenticated_dsn = Dsn.authenticate(@dsn.key)
|
||||
assert_equal @dsn, authenticated_dsn
|
||||
end
|
||||
|
||||
test "should not authenticate with invalid key" do
|
||||
@dsn.save!
|
||||
assert_nil Dsn.authenticate("invalid-key")
|
||||
end
|
||||
|
||||
test "should not authenticate disabled DSNs" do
|
||||
@dsn.save!
|
||||
@dsn.update!(enabled: false)
|
||||
assert_nil Dsn.authenticate(@dsn.key)
|
||||
end
|
||||
|
||||
# URL Generation Tests
|
||||
test "should generate full DSN URL in development" do
|
||||
@dsn.key = "test-key-1234567890abcdef"
|
||||
@dsn.save!
|
||||
|
||||
expected = "http://test-key-1234567890abcdef@localhost"
|
||||
assert_equal expected, @dsn.full_dsn_url
|
||||
end
|
||||
|
||||
test "should generate API endpoint URL in development" do
|
||||
@dsn.save!
|
||||
|
||||
expected = "http://localhost"
|
||||
assert_equal expected, @dsn.api_endpoint_url
|
||||
end
|
||||
|
||||
test "should use HTTPS in production environment" do
|
||||
# Temporarily switch to production environment
|
||||
original_env = Rails.env
|
||||
Rails.env = "production"
|
||||
|
||||
@dsn.key = "prod-key-1234567890abcdef"
|
||||
@dsn.save!
|
||||
|
||||
assert_equal "https://prod-key-1234567890abcdef@localhost", @dsn.full_dsn_url
|
||||
assert_equal "https://localhost", @dsn.api_endpoint_url
|
||||
|
||||
# Restore original environment
|
||||
Rails.env = original_env
|
||||
end
|
||||
|
||||
test "should use custom host from environment variable" do
|
||||
ENV['RAILS_HOST'] = 'baffle.example.com'
|
||||
|
||||
@dsn.key = "custom-key-1234567890abcdef"
|
||||
@dsn.save!
|
||||
|
||||
assert_equal "http://custom-key-1234567890abcdef@baffle.example.com", @dsn.full_dsn_url
|
||||
assert_equal "http://baffle.example.com", @dsn.api_endpoint_url
|
||||
|
||||
ENV.delete('RAILS_HOST')
|
||||
end
|
||||
|
||||
test "should use action mailer default host if configured" do
|
||||
Rails.application.config.action_mailer.default_url_options = { host: 'mail.baffle.com' }
|
||||
|
||||
@dsn.key = "mail-key-1234567890abcdef"
|
||||
@dsn.save!
|
||||
|
||||
assert_equal "http://mail-key-1234567890abcdef@mail.baffle.com", @dsn.full_dsn_url
|
||||
assert_equal "http://mail.baffle.com", @dsn.api_endpoint_url
|
||||
|
||||
Rails.application.config.action_mailer.default_url_options = {}
|
||||
end
|
||||
|
||||
test "should handle long hex keys in URLs" do
|
||||
long_key = "c92b7f8ad94ea3400299d8a6ff19e409c2df8c4540022c3167b8ac1002931624"
|
||||
@dsn.key = long_key
|
||||
@dsn.save!
|
||||
|
||||
expected = "http://#{long_key}@localhost"
|
||||
assert_equal expected, @dsn.full_dsn_url
|
||||
end
|
||||
|
||||
# Scope Tests
|
||||
test "enabled scope should return only enabled DSNs" do
|
||||
enabled_dsn = Dsn.create!(name: "Enabled DSN", enabled: true)
|
||||
disabled_dsn = Dsn.create!(name: "Disabled DSN", enabled: false)
|
||||
|
||||
enabled_dsns = Dsn.enabled
|
||||
|
||||
assert_includes enabled_dsns, enabled_dsn
|
||||
assert_not_includes enabled_dsns, disabled_dsn
|
||||
end
|
||||
|
||||
# Security Tests
|
||||
test "should generate cryptographically secure keys" do
|
||||
keys = []
|
||||
10.times do
|
||||
dsn = Dsn.create!(name: "Test DSN #{Time.current.to_f}")
|
||||
keys << dsn.key
|
||||
end
|
||||
|
||||
# All keys should be unique
|
||||
assert_equal keys.length, keys.uniq.length
|
||||
|
||||
# All keys should be valid hex
|
||||
keys.each do |key|
|
||||
assert_equal 64, key.length
|
||||
assert_match /\A[a-f0-9]{64}\z/, key
|
||||
end
|
||||
end
|
||||
|
||||
test "should not allow nil keys" do
|
||||
@dsn.key = nil
|
||||
assert_not @dsn.valid?
|
||||
assert_includes @dsn.errors[:key], "can't be blank"
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,7 @@ require "test_helper"
|
||||
class EventTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@sample_payload = {
|
||||
"event_id" => "test-event-123",
|
||||
"request_id" => "test-event-123",
|
||||
"timestamp" => Time.now.iso8601,
|
||||
"request" => {
|
||||
"ip" => "192.168.1.1",
|
||||
@@ -46,7 +46,7 @@ class EventTest < ActiveSupport::TestCase
|
||||
event = Event.create_from_waf_payload!("test-123", @sample_payload)
|
||||
|
||||
assert event.persisted?
|
||||
assert_equal "test-123", event.event_id
|
||||
assert_equal "test-123", event.request_id
|
||||
assert_equal "192.168.1.1", event.ip_address
|
||||
assert_equal "/api/test", event.request_path
|
||||
assert_equal 200, event.response_status
|
||||
@@ -66,7 +66,7 @@ class EventTest < ActiveSupport::TestCase
|
||||
test_methods.each_with_index do |method, index|
|
||||
payload = @sample_payload.dup
|
||||
payload["request"]["method"] = method
|
||||
payload["event_id"] = "test-method-#{method.downcase}"
|
||||
payload["request_id"] = "test-method-#{method.downcase}"
|
||||
|
||||
event = Event.create_from_waf_payload!("test-method-#{method.downcase}", payload)
|
||||
|
||||
@@ -91,7 +91,7 @@ class EventTest < ActiveSupport::TestCase
|
||||
test_actions.each do |action, expected_enum, expected_int|
|
||||
payload = @sample_payload.dup
|
||||
payload["waf_action"] = action
|
||||
payload["event_id"] = "test-action-#{action}"
|
||||
payload["request_id"] = "test-action-#{action}"
|
||||
|
||||
event = Event.create_from_waf_payload!("test-action-#{action}", payload)
|
||||
|
||||
@@ -143,7 +143,7 @@ class EventTest < ActiveSupport::TestCase
|
||||
|
||||
# Event 1: GET + allow
|
||||
Event.create_from_waf_payload!("get-allow", {
|
||||
"event_id" => "get-allow",
|
||||
"request_id" => "get-allow",
|
||||
"timestamp" => Time.now.iso8601,
|
||||
"request" => {
|
||||
"ip" => "192.168.1.1",
|
||||
@@ -157,7 +157,7 @@ class EventTest < ActiveSupport::TestCase
|
||||
|
||||
# Event 2: POST + allow
|
||||
Event.create_from_waf_payload!("post-allow", {
|
||||
"event_id" => "post-allow",
|
||||
"request_id" => "post-allow",
|
||||
"timestamp" => Time.now.iso8601,
|
||||
"request" => {
|
||||
"ip" => "192.168.1.1",
|
||||
@@ -171,7 +171,7 @@ class EventTest < ActiveSupport::TestCase
|
||||
|
||||
# Event 3: GET + deny
|
||||
Event.create_from_waf_payload!("get-deny", {
|
||||
"event_id" => "get-deny",
|
||||
"request_id" => "get-deny",
|
||||
"timestamp" => Time.now.iso8601,
|
||||
"request" => {
|
||||
"ip" => "192.168.1.1",
|
||||
@@ -202,7 +202,7 @@ class EventTest < ActiveSupport::TestCase
|
||||
# Create event without enum values (simulating old data)
|
||||
event = Event.create!(
|
||||
project: @project,
|
||||
event_id: "normalization-test",
|
||||
request_id: "normalization-test",
|
||||
timestamp: Time.current,
|
||||
payload: @sample_payload,
|
||||
ip_address: "192.168.1.1",
|
||||
@@ -279,7 +279,7 @@ class EventTest < ActiveSupport::TestCase
|
||||
timestamps.each_with_index do |timestamp, index|
|
||||
payload = @sample_payload.dup
|
||||
payload["timestamp"] = timestamp
|
||||
payload["event_id"] = "timestamp-test-#{index}"
|
||||
payload["request_id"] = "timestamp-test-#{index}"
|
||||
|
||||
event = Event.create_from_waf_payload!("timestamp-test-#{index}", payload)
|
||||
assert event.timestamp.is_a?(Time), "Timestamp #{index} should be parsed as Time"
|
||||
@@ -289,7 +289,7 @@ class EventTest < ActiveSupport::TestCase
|
||||
|
||||
test "handles missing optional fields gracefully" do
|
||||
minimal_payload = {
|
||||
"event_id" => "minimal-test",
|
||||
"request_id" => "minimal-test",
|
||||
"timestamp" => Time.now.iso8601,
|
||||
"request" => {
|
||||
"ip" => "10.0.0.1",
|
||||
|
||||
675
test/models/network_range_test.rb
Normal file
675
test/models/network_range_test.rb
Normal file
@@ -0,0 +1,675 @@
|
||||
require "test_helper"
|
||||
|
||||
class NetworkRangeTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@ipv4_range = NetworkRange.new(network: "192.168.1.0/24")
|
||||
@ipv6_range = NetworkRange.new(network: "2001:db8::/32")
|
||||
@user = users(:jason)
|
||||
end
|
||||
|
||||
# Validations
|
||||
test "should be valid with network address" do
|
||||
assert @ipv4_range.valid?
|
||||
assert @ipv6_range.valid?
|
||||
end
|
||||
|
||||
test "should not be valid without network" do
|
||||
@ipv4_range.network = nil
|
||||
assert_not @ipv4_range.valid?
|
||||
assert_includes @ipv4_range.errors[:network], "can't be blank"
|
||||
end
|
||||
|
||||
test "should validate network uniqueness" do
|
||||
@ipv4_range.save!
|
||||
duplicate = NetworkRange.new(network: "192.168.1.0/24")
|
||||
assert_not duplicate.valid?
|
||||
assert_includes duplicate.errors[:network], "has already been taken"
|
||||
end
|
||||
|
||||
test "should validate source inclusion" do
|
||||
valid_sources = %w[api_imported user_created manual auto_generated inherited geolite_asn geolite_country]
|
||||
valid_sources.each do |source|
|
||||
@ipv4_range.source = source
|
||||
assert @ipv4_range.valid?, "Source #{source} should be valid"
|
||||
end
|
||||
|
||||
@ipv4_range.source = "invalid_source"
|
||||
assert_not @ipv4_range.valid?
|
||||
assert_includes @ipv4_range.errors[:source], "is not included in the list"
|
||||
end
|
||||
|
||||
test "should validate ASN numericality" do
|
||||
@ipv4_range.asn = 12345
|
||||
assert @ipv4_range.valid?
|
||||
|
||||
@ipv4_range.asn = 0
|
||||
assert_not @ipv4_range.valid?
|
||||
assert_includes @ipv4_range.errors[:asn], "must be greater than 0"
|
||||
|
||||
@ipv4_range.asn = -1
|
||||
assert_not @ipv4_range.valid?
|
||||
assert_includes @ipv4_range.errors[:asn], "must be greater than 0"
|
||||
|
||||
@ipv4_range.asn = "not_a_number"
|
||||
assert_not @ipv4_range.valid?
|
||||
|
||||
@ipv4_range.asn = nil
|
||||
assert @ipv4_range.valid?
|
||||
end
|
||||
|
||||
# Callbacks
|
||||
test "should set default source before validation" do
|
||||
range = NetworkRange.new(network: "10.0.0.0/8")
|
||||
range.valid?
|
||||
assert_equal "api_imported", range.source
|
||||
end
|
||||
|
||||
test "should not override existing source" do
|
||||
range = NetworkRange.new(network: "10.0.0.0/8", source: "user_created")
|
||||
range.valid?
|
||||
assert_equal "user_created", range.source
|
||||
end
|
||||
|
||||
# Virtual Attributes (CIDR)
|
||||
test "cidr getter returns network as string" do
|
||||
@ipv4_range.save!
|
||||
assert_equal "192.168.1.0/24", @ipv4_range.cidr
|
||||
assert_equal "192.168.1.0/24", @ipv4_range.network.to_s
|
||||
end
|
||||
|
||||
test "cidr setter sets network from string" do
|
||||
range = NetworkRange.new
|
||||
range.cidr = "10.0.0.0/16"
|
||||
assert_equal "10.0.0.0/16", range.network.to_s
|
||||
end
|
||||
|
||||
# Network Properties
|
||||
test "prefix_length returns correct network prefix" do
|
||||
@ipv4_range.save!
|
||||
@ipv6_range.save!
|
||||
|
||||
assert_equal 24, @ipv4_range.prefix_length
|
||||
assert_equal 32, @ipv6_range.prefix_length
|
||||
end
|
||||
|
||||
test "network_address returns network address" do
|
||||
@ipv4_range.save!
|
||||
assert_equal "192.168.1.0", @ipv4_range.network_address
|
||||
end
|
||||
|
||||
test "broadcast_address returns correct broadcast for IPv4" do
|
||||
@ipv4_range.save!
|
||||
assert_equal "192.168.1.255", @ipv4_range.broadcast_address
|
||||
end
|
||||
|
||||
test "broadcast_address returns nil for IPv6" do
|
||||
@ipv6_range.save!
|
||||
assert_nil @ipv6_range.broadcast_address
|
||||
end
|
||||
|
||||
test "family detection works correctly" do
|
||||
@ipv4_range.save!
|
||||
@ipv6_range.save!
|
||||
|
||||
assert_equal 4, @ipv4_range.family
|
||||
assert_equal 6, @ipv6_range.family
|
||||
end
|
||||
|
||||
test "ipv4? and ipv6? predicate methods work" do
|
||||
@ipv4_range.save!
|
||||
@ipv6_range.save!
|
||||
|
||||
assert @ipv4_range.ipv4?
|
||||
assert_not @ipv4_range.ipv6?
|
||||
|
||||
assert @ipv6_range.ipv6?
|
||||
assert_not @ipv6_range.ipv4?
|
||||
end
|
||||
|
||||
test "virtual? works correctly" do
|
||||
range = NetworkRange.new(network: "10.0.0.0/8")
|
||||
assert range.virtual?
|
||||
|
||||
range.save!
|
||||
assert_not range.virtual?
|
||||
end
|
||||
|
||||
# Network Containment
|
||||
test "contains_ip? works correctly" do
|
||||
@ipv4_range.save!
|
||||
|
||||
assert @ipv4_range.contains_ip?("192.168.1.1")
|
||||
assert @ipv4_range.contains_ip?("192.168.1.254")
|
||||
assert_not @ipv4_range.contains_ip?("192.168.2.1")
|
||||
assert_not @ipv4_range.contains_ip?("2001:db8::1")
|
||||
|
||||
# Test IPv6
|
||||
@ipv6_range.save!
|
||||
assert @ipv6_range.contains_ip?("2001:db8::1")
|
||||
assert @ipv6_range.contains_ip?("2001:db8:ffff::ffff")
|
||||
assert_not @ipv6_range.contains_ip?("2001:db9::1")
|
||||
end
|
||||
|
||||
test "contains_network? works correctly" do
|
||||
@ipv4_range.save!
|
||||
|
||||
# More specific network
|
||||
assert @ipv4_range.contains_network?("192.168.1.0/25")
|
||||
assert @ipv4_range.contains_network?("192.168.1.128/25")
|
||||
|
||||
# Same network
|
||||
assert @ipv4_range.contains_network?("192.168.1.0/24")
|
||||
|
||||
# Less specific network
|
||||
assert_not @ipv4_range.contains_network?("192.168.0.0/16")
|
||||
|
||||
# Different network
|
||||
assert_not @ipv4_range.contains_network?("10.0.0.0/8")
|
||||
end
|
||||
|
||||
test "overlaps? works correctly" do
|
||||
@ipv4_range.save!
|
||||
|
||||
# Overlapping networks
|
||||
assert @ipv4_range.overlaps?("192.168.1.0/25") # More specific
|
||||
assert @ipv4_range.overlaps?("192.168.0.0/23") # Less specific
|
||||
assert @ipv4_range.overlaps?("192.168.1.128/25") # Partial overlap
|
||||
|
||||
# Non-overlapping
|
||||
assert_not @ipv4_range.overlaps?("10.0.0.0/8")
|
||||
assert_not @ipv4_range.overlaps?("172.16.0.0/12")
|
||||
end
|
||||
|
||||
# Parent/Child Relationships
|
||||
test "parent_ranges finds containing networks" do
|
||||
# Create parent and child networks
|
||||
parent = NetworkRange.create!(network: "192.168.0.0/16")
|
||||
@ipv4_range.save! # 192.168.1.0/24
|
||||
child = NetworkRange.create!(network: "192.168.1.0/25")
|
||||
|
||||
parents = @ipv4_range.parent_ranges
|
||||
assert_includes parents, parent
|
||||
assert_not_includes parents, child
|
||||
assert_not_includes parents, @ipv4_range
|
||||
|
||||
# Should be ordered by specificity (more specific first)
|
||||
assert_equal parent, parents.first
|
||||
end
|
||||
|
||||
test "child_ranges finds contained networks" do
|
||||
# Create parent and child networks
|
||||
parent = NetworkRange.create!(network: "192.168.0.0/16")
|
||||
@ipv4_range.save! # 192.168.1.0/24
|
||||
child = NetworkRange.create!(network: "192.168.1.0/25")
|
||||
|
||||
children = parent.child_ranges
|
||||
assert_includes children, @ipv4_range
|
||||
assert_includes children, child
|
||||
assert_not_includes children, parent
|
||||
|
||||
# Should be ordered by specificity (less specific first)
|
||||
assert_equal @ipv4_range, children.first
|
||||
end
|
||||
|
||||
test "sibling_ranges finds same-level networks" do
|
||||
# Create sibling networks
|
||||
sibling1 = NetworkRange.create!(network: "192.168.0.0/24")
|
||||
@ipv4_range.save! # 192.168.1.0/24
|
||||
sibling2 = NetworkRange.create!(network: "192.168.2.0/24")
|
||||
|
||||
siblings = @ipv4_range.sibling_ranges
|
||||
assert_includes siblings, sibling1
|
||||
assert_includes siblings, sibling2
|
||||
assert_not_includes siblings, @ipv4_range
|
||||
end
|
||||
|
||||
# Intelligence and Inheritance
|
||||
test "has_intelligence? detects available intelligence data" do
|
||||
range = NetworkRange.new(network: "10.0.0.0/8")
|
||||
assert_not range.has_intelligence?
|
||||
|
||||
range.asn = 12345
|
||||
assert range.has_intelligence?
|
||||
|
||||
range.asn = nil
|
||||
range.company = "Test Company"
|
||||
assert range.has_intelligence?
|
||||
|
||||
range.company = nil
|
||||
range.country = "US"
|
||||
assert range.has_intelligence?
|
||||
|
||||
range.country = nil
|
||||
range.is_datacenter = true
|
||||
assert range.has_intelligence?
|
||||
end
|
||||
|
||||
test "own_intelligence returns correct data structure" do
|
||||
range = NetworkRange.create!(
|
||||
network: "10.0.0.0/8",
|
||||
asn: 12345,
|
||||
asn_org: "Test ASN",
|
||||
company: "Test Company",
|
||||
country: "US",
|
||||
is_datacenter: true,
|
||||
is_proxy: false,
|
||||
is_vpn: false,
|
||||
source: "manual"
|
||||
)
|
||||
|
||||
intelligence = range.own_intelligence
|
||||
assert_equal 12345, intelligence[:asn]
|
||||
assert_equal "Test ASN", intelligence[:asn_org]
|
||||
assert_equal "Test Company", intelligence[:company]
|
||||
assert_equal "US", intelligence[:country]
|
||||
assert_equal true, intelligence[:is_datacenter]
|
||||
assert_equal false, intelligence[:is_proxy]
|
||||
assert_equal false, intelligence[:is_vpn]
|
||||
assert_equal false, intelligence[:inherited]
|
||||
assert_equal "manual", intelligence[:source]
|
||||
end
|
||||
|
||||
test "inherited_intelligence returns own data when available" do
|
||||
child = NetworkRange.create!(
|
||||
network: "192.168.1.0/24",
|
||||
country: "US",
|
||||
company: "Test Company"
|
||||
)
|
||||
|
||||
intelligence = child.inherited_intelligence
|
||||
assert_equal "US", intelligence[:country]
|
||||
assert_equal "Test Company", intelligence[:company]
|
||||
assert_equal false, intelligence[:inherited]
|
||||
end
|
||||
|
||||
test "inherited_intelligence inherits from parent when needed" do
|
||||
parent = NetworkRange.create!(
|
||||
network: "192.168.0.0/16",
|
||||
country: "US",
|
||||
company: "Test Company"
|
||||
)
|
||||
child = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
|
||||
intelligence = child.inherited_intelligence
|
||||
assert_equal "US", intelligence[:country]
|
||||
assert_equal "Test Company", intelligence[:company]
|
||||
assert_equal true, intelligence[:inherited]
|
||||
assert_equal parent.cidr, intelligence[:parent_cidr]
|
||||
end
|
||||
|
||||
test "parent_with_intelligence finds nearest parent with data" do
|
||||
grandparent = NetworkRange.create!(network: "10.0.0.0/8", country: "US")
|
||||
parent = NetworkRange.create!(network: "10.1.0.0/16")
|
||||
child = NetworkRange.create!(network: "10.1.1.0/24")
|
||||
|
||||
found_parent = child.parent_with_intelligence
|
||||
assert_equal grandparent, found_parent
|
||||
assert_not_equal parent, found_parent
|
||||
end
|
||||
|
||||
# API Data Management
|
||||
test "is_fetching_api_data? tracks active fetches" do
|
||||
range = NetworkRange.create!(network: "10.0.0.0/8")
|
||||
|
||||
assert_not range.is_fetching_api_data?(:ipapi)
|
||||
|
||||
range.mark_as_fetching_api_data!(:ipapi)
|
||||
assert range.is_fetching_api_data?(:ipapi)
|
||||
|
||||
range.clear_fetching_status!(:ipapi)
|
||||
assert_not range.is_fetching_api_data?(:ipapi)
|
||||
end
|
||||
|
||||
test "should_fetch_api_data? prevents duplicate fetches" do
|
||||
range = NetworkRange.create!(network: "10.0.0.0/8")
|
||||
|
||||
# Should fetch initially
|
||||
assert range.should_fetch_api_data?(:ipapi)
|
||||
|
||||
# Should not fetch while fetching
|
||||
range.mark_as_fetching_api_data!(:ipapi)
|
||||
assert_not range.should_fetch_api_data?(:ipapi)
|
||||
|
||||
# Should fetch again after clearing
|
||||
range.clear_fetching_status!(:ipapi)
|
||||
assert range.should_fetch_api_data?(:ipapi)
|
||||
end
|
||||
|
||||
test "has_ipapi_data_available? checks inheritance" do
|
||||
parent = NetworkRange.create!(network: "10.0.0.0/8")
|
||||
parent.set_network_data(:ipapi, { country: "US" })
|
||||
parent.save!
|
||||
|
||||
child = NetworkRange.create!(network: "10.0.1.0/24")
|
||||
|
||||
assert child.has_ipapi_data_available?
|
||||
end
|
||||
|
||||
test "should_fetch_ipapi_data? respects parent fetching status" do
|
||||
parent = NetworkRange.create!(network: "10.0.0.0/8")
|
||||
child = NetworkRange.create!(network: "10.0.1.0/24")
|
||||
|
||||
parent.mark_as_fetching_api_data!(:ipapi)
|
||||
assert_not child.should_fetch_ipapi_data?
|
||||
|
||||
parent.clear_fetching_status!(:ipapi)
|
||||
assert child.should_fetch_ipapi_data?
|
||||
end
|
||||
|
||||
# Network Data Management
|
||||
test "network_data_for and set_network_data work correctly" do
|
||||
range = NetworkRange.create!(network: "10.0.0.0/8")
|
||||
|
||||
assert_equal({}, range.network_data_for(:ipapi))
|
||||
|
||||
data = { country: "US", city: "New York" }
|
||||
range.set_network_data(:ipapi, data)
|
||||
range.save!
|
||||
|
||||
assert_equal data, range.network_data_for(:ipapi)
|
||||
end
|
||||
|
||||
test "has_network_data_from? checks data presence" do
|
||||
range = NetworkRange.create!(network: "10.0.0.0/8")
|
||||
|
||||
assert_not range.has_network_data_from?(:ipapi)
|
||||
|
||||
range.set_network_data(:ipapi, { country: "US" })
|
||||
range.save!
|
||||
|
||||
assert range.has_network_data_from?(:ipapi)
|
||||
end
|
||||
|
||||
# IPAPI Tracking Methods
|
||||
test "find_or_create_tracking_network_for_ip works correctly" do
|
||||
# IPv4 - should create /24
|
||||
tracking_range = NetworkRange.find_or_create_tracking_network_for_ip("192.168.1.100")
|
||||
assert_equal "192.168.1.0/24", tracking_range.network.to_s
|
||||
assert_equal "auto_generated", tracking_range.source
|
||||
assert_equal "IPAPI tracking network", tracking_range.creation_reason
|
||||
|
||||
# IPv6 - should create /64
|
||||
ipv6_tracking = NetworkRange.find_or_create_tracking_network_for_ip("2001:db8::1")
|
||||
assert_equal "2001:db8::/64", ipv6_tracking.network.to_s
|
||||
end
|
||||
|
||||
test "should_fetch_ipapi_for_ip? works correctly" do
|
||||
tracking_range = NetworkRange.create!(network: "192.168.1.0/8")
|
||||
|
||||
# Should fetch initially
|
||||
assert NetworkRange.should_fetch_ipapi_for_ip?("192.168.1.100")
|
||||
|
||||
# Mark as queried recently
|
||||
tracking_range.mark_ipapi_queried!("192.168.1.0/24")
|
||||
assert_not NetworkRange.should_fetch_ipapi_for_ip?("192.168.1.100")
|
||||
|
||||
# Should fetch for old queries
|
||||
tracking_range.network_data['ipapi_queried_at'] = 2.years.ago.to_i
|
||||
tracking_range.save!
|
||||
assert NetworkRange.should_fetch_ipapi_for_ip?("192.168.1.100")
|
||||
end
|
||||
|
||||
test "mark_ipapi_queried! stores query metadata" do
|
||||
range = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
|
||||
range.mark_ipapi_queried!("192.168.1.128/25")
|
||||
|
||||
assert range.network_data['ipapi_queried_at'] > 5.seconds.ago.to_i
|
||||
assert_equal "192.168.1.128/25", range.network_data['ipapi_returned_cidr']
|
||||
end
|
||||
|
||||
# JSON Helper Methods
|
||||
test "abuser_scores_hash handles JSON correctly" do
|
||||
range = NetworkRange.create!(network: "10.0.0.0/8")
|
||||
|
||||
assert_equal({}, range.abuser_scores_hash)
|
||||
|
||||
range.abuser_scores_hash = { ipquality: 85, abuseipdb: 92 }
|
||||
range.save!
|
||||
|
||||
assert_equal({ "ipquality" => 85, "abuseipdb" => 92 }, JSON.parse(range.abuser_scores))
|
||||
assert_equal({ ipquality: 85, abuseipdb: 92 }, range.abuser_scores_hash)
|
||||
end
|
||||
|
||||
test "additional_data_hash handles JSON correctly" do
|
||||
range = NetworkRange.create!(network: "10.0.0.0/8")
|
||||
|
||||
assert_equal({}, range.additional_data_hash)
|
||||
|
||||
range.additional_data_hash = { tags: ["malicious", "botnet"], notes: "Test data" }
|
||||
range.save!
|
||||
|
||||
parsed_data = JSON.parse(range.additional_data)
|
||||
assert_equal ["malicious", "botnet"], parsed_data["tags"]
|
||||
assert_equal "Test data", parsed_data["notes"]
|
||||
end
|
||||
|
||||
# Scopes
|
||||
test "ipv4 and ipv6 scopes work correctly" do
|
||||
ipv4_range = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
ipv6_range = NetworkRange.create!(network: "2001:db8::/32")
|
||||
|
||||
assert_includes NetworkRange.ipv4, ipv4_range
|
||||
assert_not_includes NetworkRange.ipv4, ipv6_range
|
||||
|
||||
assert_includes NetworkRange.ipv6, ipv6_range
|
||||
assert_not_includes NetworkRange.ipv6, ipv4_range
|
||||
end
|
||||
|
||||
test "filtering scopes work correctly" do
|
||||
range1 = NetworkRange.create!(network: "192.168.1.0/24", country: "US", company: "Google", asn: 15169, is_datacenter: true)
|
||||
range2 = NetworkRange.create!(network: "10.0.0.0/8", country: "BR", company: "Amazon", asn: 16509, is_proxy: true)
|
||||
|
||||
assert_includes NetworkRange.by_country("US"), range1
|
||||
assert_not_includes NetworkRange.by_country("US"), range2
|
||||
|
||||
assert_includes NetworkRange.by_company("Google"), range1
|
||||
assert_not_includes NetworkRange.by_company("Google"), range2
|
||||
|
||||
assert_includes NetworkRange.by_asn(15169), range1
|
||||
assert_not_includes NetworkRange.by_asn(15169), range2
|
||||
|
||||
assert_includes NetworkRange.datacenter, range1
|
||||
assert_not_includes NetworkRange.datacenter, range2
|
||||
|
||||
assert_includes NetworkRange.proxy, range2
|
||||
assert_not_includes NetworkRange.proxy, range1
|
||||
end
|
||||
|
||||
# Class Methods
|
||||
test "contains_ip class method finds most specific network" do
|
||||
parent = NetworkRange.create!(network: "192.168.0.0/16")
|
||||
child = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
|
||||
found = NetworkRange.contains_ip("192.168.1.100")
|
||||
assert_equal child, found.first # More specific should come first
|
||||
end
|
||||
|
||||
test "overlapping class method finds overlapping networks" do
|
||||
range1 = NetworkRange.create!(network: "192.168.0.0/16")
|
||||
range2 = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
range3 = NetworkRange.create!(network: "10.0.0.0/8")
|
||||
|
||||
overlapping = NetworkRange.overlapping("192.168.1.0/24")
|
||||
assert_includes overlapping, range1
|
||||
assert_includes overlapping, range2
|
||||
assert_not_includes overlapping, range3
|
||||
end
|
||||
|
||||
test "find_or_create_by_cidr works correctly" do
|
||||
# Creates new record
|
||||
range = NetworkRange.find_or_create_by_cidr("10.0.0.0/8", user: @user, source: "manual")
|
||||
assert range.persisted?
|
||||
assert_equal "10.0.0.0/8", range.network.to_s
|
||||
assert_equal @user, range.user
|
||||
assert_equal "manual", range.source
|
||||
|
||||
# Returns existing record
|
||||
existing = NetworkRange.find_or_create_by_cidr("10.0.0.0/8")
|
||||
assert_equal range.id, existing.id
|
||||
end
|
||||
|
||||
test "find_by_ip_or_network handles both IP and network inputs" do
|
||||
range = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
|
||||
# Find by IP within range
|
||||
found_by_ip = NetworkRange.find_by_ip_or_network("192.168.1.100")
|
||||
assert_includes found_by_ip, range
|
||||
|
||||
# Find by exact network
|
||||
found_by_network = NetworkRange.find_by_ip_or_network("192.168.1.0/24")
|
||||
assert_includes found_by_network, range
|
||||
|
||||
# Return none for invalid input
|
||||
assert_equal NetworkRange.none, NetworkRange.find_by_ip_or_network("")
|
||||
assert_equal NetworkRange.none, NetworkRange.find_by_ip_or_network("invalid")
|
||||
end
|
||||
|
||||
# Analytics Methods
|
||||
test "events_count returns counter cache value" do
|
||||
range = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
|
||||
assert_equal 0, range.events_count
|
||||
|
||||
# Update counter cache manually for testing
|
||||
range.update_column(:events_count, 5)
|
||||
assert_equal 5, range.events_count
|
||||
end
|
||||
|
||||
test "events method finds events within range" do
|
||||
range = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
|
||||
# Create test events
|
||||
matching_event = Event.create!(
|
||||
request_id: "test-1",
|
||||
timestamp: Time.current,
|
||||
payload: {},
|
||||
ip_address: "192.168.1.100"
|
||||
)
|
||||
non_matching_event = Event.create!(
|
||||
request_id: "test-2",
|
||||
timestamp: Time.current,
|
||||
payload: {},
|
||||
ip_address: "10.0.0.1"
|
||||
)
|
||||
|
||||
found_events = range.events
|
||||
assert_includes found_events, matching_event
|
||||
assert_not_includes found_events, non_matching_event
|
||||
end
|
||||
|
||||
test "blocking_rules and active_rules work correctly" do
|
||||
range = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
|
||||
blocking_rule = Rule.create!(
|
||||
rule_type: "network",
|
||||
action: "deny",
|
||||
network_range: range,
|
||||
user: @user,
|
||||
enabled: true
|
||||
)
|
||||
allow_rule = Rule.create!(
|
||||
rule_type: "network",
|
||||
action: "allow",
|
||||
network_range: range,
|
||||
user: @user,
|
||||
enabled: true
|
||||
)
|
||||
disabled_rule = Rule.create!(
|
||||
rule_type: "network",
|
||||
action: "deny",
|
||||
network_range: range,
|
||||
user: @user,
|
||||
enabled: false
|
||||
)
|
||||
|
||||
assert_includes range.blocking_rules, blocking_rule
|
||||
assert_not_includes range.blocking_rules, allow_rule
|
||||
assert_not_includes range.blocking_rules, disabled_rule
|
||||
|
||||
assert_includes range.active_rules, blocking_rule
|
||||
assert_includes range.active_rules, allow_rule
|
||||
assert_not_includes range.active_rules, disabled_rule
|
||||
end
|
||||
|
||||
# Policy Evaluation
|
||||
test "needs_policy_evaluation? works correctly" do
|
||||
range = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
|
||||
# Should evaluate if never evaluated
|
||||
assert range.needs_policy_evaluation?
|
||||
|
||||
# Should evaluate if policies updated since last evaluation
|
||||
range.update!(policies_evaluated_at: 1.hour.ago)
|
||||
WafPolicy.create!(name: "Test Policy", policy_type: "country", targets: ["US"], policy_action: "deny", user: @user)
|
||||
assert range.needs_policy_evaluation?
|
||||
|
||||
# Should not evaluate if up to date
|
||||
range.update!(policies_evaluated_at: 5.minutes.ago)
|
||||
assert_not range.needs_policy_evaluation?
|
||||
end
|
||||
|
||||
# String Representations
|
||||
test "to_s returns cidr" do
|
||||
@ipv4_range.save!
|
||||
assert_equal @ipv4_range.cidr, @ipv4_range.to_s
|
||||
end
|
||||
|
||||
test "to_param parameterizes cidr" do
|
||||
@ipv4_range.save!
|
||||
assert_equal "192.168.1.0_24", @ipv4_range.to_param
|
||||
end
|
||||
|
||||
# Geographic Lookup
|
||||
test "geo_lookup_country! updates country when available" do
|
||||
range = NetworkRange.create!(network: "8.8.8.0/24") # Google's network
|
||||
|
||||
# Mock GeoIpService
|
||||
GeoIpService.expects(:lookup_country).with("8.8.8.0").returns("US")
|
||||
|
||||
range.geo_lookup_country!
|
||||
assert_equal "US", range.reload.country
|
||||
end
|
||||
|
||||
test "geo_lookup_country! handles errors gracefully" do
|
||||
range = NetworkRange.create!(network: "192.168.1.0/24")
|
||||
|
||||
# Mock GeoIpService to raise error
|
||||
GeoIpService.expects(:lookup_country).with("192.168.1.0").raises(StandardError.new("Service error"))
|
||||
|
||||
# Should not raise error but log it
|
||||
assert_nothing_raised do
|
||||
range.geo_lookup_country!
|
||||
end
|
||||
|
||||
assert_nil range.reload.country
|
||||
end
|
||||
|
||||
# Stats Methods
|
||||
test "import_stats_by_source returns statistics" do
|
||||
NetworkRange.create!(network: "10.0.0.0/8", source: "manual")
|
||||
NetworkRange.create!(network: "192.168.1.0/24", source: "api_imported")
|
||||
NetworkRange.create!(network: "172.16.0.0/12", source: "api_imported")
|
||||
|
||||
stats = NetworkRange.import_stats_by_source
|
||||
assert_equal 2, stats.count
|
||||
|
||||
api_stats = stats.find { |s| s.source == "api_imported" }
|
||||
assert_equal 2, api_stats.count
|
||||
end
|
||||
|
||||
test "geolite_coverage_stats returns detailed coverage information" do
|
||||
NetworkRange.create!(network: "10.0.0.0/8", source: "geolite_asn", asn: 12345)
|
||||
NetworkRange.create!(network: "192.168.1.0/24", source: "geolite_country", country: "US")
|
||||
NetworkRange.create!(network: "172.16.0.0/12", source: "geolite_asn", country: "BR")
|
||||
|
||||
stats = NetworkRange.geolite_coverage_stats
|
||||
assert_equal 3, stats[:total_networks]
|
||||
assert_equal 2, stats[:asn_networks]
|
||||
assert_equal 1, stats[:country_networks]
|
||||
assert_equal 2, stats[:with_asn_data]
|
||||
assert_equal 1, stats[:with_country_data]
|
||||
assert_equal 2, stats[:unique_countries]
|
||||
assert_equal 2, stats[:unique_asns]
|
||||
end
|
||||
end
|
||||
@@ -3,25 +3,30 @@
|
||||
require "test_helper"
|
||||
|
||||
class RuleTest < ActiveSupport::TestCase
|
||||
|
||||
# Validation tests
|
||||
test "should create valid network_v4 rule" do
|
||||
test "should create valid network rule" do
|
||||
network_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
rule = Rule.new(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
source: "manual"
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: network_range,
|
||||
source: "manual",
|
||||
user: users(:one)
|
||||
)
|
||||
assert rule.valid?
|
||||
rule.save!
|
||||
assert_equal 8, rule.priority # Auto-calculated from CIDR prefix
|
||||
end
|
||||
|
||||
test "should create valid network_v6 rule" do
|
||||
test "should create valid network rule with IPv6" do
|
||||
network_range = NetworkRange.create!(cidr: "2001:db8::/32")
|
||||
rule = Rule.new(
|
||||
rule_type: "network_v6",
|
||||
action: "deny",
|
||||
conditions: { cidr: "2001:db8::/32" },
|
||||
source: "manual"
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: network_range,
|
||||
source: "manual",
|
||||
user: users(:one)
|
||||
)
|
||||
assert rule.valid?
|
||||
rule.save!
|
||||
@@ -30,53 +35,58 @@ class RuleTest < ActiveSupport::TestCase
|
||||
|
||||
test "should create valid rate_limit rule" do
|
||||
rule = Rule.new(
|
||||
rule_type: "rate_limit",
|
||||
action: "rate_limit",
|
||||
waf_rule_type: "rate_limit",
|
||||
waf_action: "rate_limit",
|
||||
conditions: { cidr: "0.0.0.0/0", scope: "global" },
|
||||
metadata: { limit: 100, window: 60 },
|
||||
source: "manual"
|
||||
source: "manual",
|
||||
user: users(:one)
|
||||
)
|
||||
assert rule.valid?
|
||||
end
|
||||
|
||||
test "should create valid path_pattern rule" do
|
||||
rule = Rule.new(
|
||||
rule_type: "path_pattern",
|
||||
action: "log",
|
||||
waf_rule_type: "path_pattern",
|
||||
waf_action: "log",
|
||||
conditions: { patterns: ["/.env", "/.git"] },
|
||||
source: "default"
|
||||
source: "default",
|
||||
user: users(:one)
|
||||
)
|
||||
assert rule.valid?
|
||||
end
|
||||
|
||||
test "should require rule_type" do
|
||||
rule = Rule.new(action: "deny", conditions: { cidr: "10.0.0.0/8" })
|
||||
test "should require waf_rule_type" do
|
||||
rule = Rule.new(waf_action: "deny", waf_rule_type: nil, conditions: { patterns: ["/test"] }, user: users(:one))
|
||||
assert_not rule.valid?
|
||||
assert_includes rule.errors[:rule_type], "can't be blank"
|
||||
assert_includes rule.errors[:waf_rule_type], "can't be blank"
|
||||
end
|
||||
|
||||
test "should require action" do
|
||||
rule = Rule.new(rule_type: "network_v4", conditions: { cidr: "10.0.0.0/8" })
|
||||
test "should require waf_action" do
|
||||
rule = Rule.new(waf_rule_type: "path_pattern", waf_action: nil, conditions: { patterns: ["/test"] }, user: users(:one))
|
||||
assert_not rule.valid?
|
||||
assert_includes rule.errors[:action], "can't be blank"
|
||||
assert_includes rule.errors[:waf_action], "can't be blank"
|
||||
end
|
||||
|
||||
test "should validate network_v4 has valid IPv4 CIDR" do
|
||||
test "should validate network has valid CIDR" do
|
||||
rule = Rule.new(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "2001:db8::/32" } # IPv6 in IPv4 rule
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
conditions: { cidr: "invalid-cidr" }, # Invalid CIDR
|
||||
user: users(:one)
|
||||
)
|
||||
assert_not rule.valid?
|
||||
assert_includes rule.errors[:conditions], "cidr must be IPv4 for network_v4 rules"
|
||||
# Network rules now validate differently - they need a network_range
|
||||
assert_includes rule.errors[:network_range], "is required for network rules"
|
||||
end
|
||||
|
||||
test "should validate rate_limit has limit and window in metadata" do
|
||||
rule = Rule.new(
|
||||
rule_type: "rate_limit",
|
||||
action: "rate_limit",
|
||||
waf_rule_type: "rate_limit",
|
||||
waf_action: "rate_limit",
|
||||
conditions: { cidr: "0.0.0.0/0", scope: "global" },
|
||||
metadata: { limit: 100 } # Missing window
|
||||
metadata: { limit: 100 }, # Missing window
|
||||
user: users(:one)
|
||||
)
|
||||
assert_not rule.valid?
|
||||
assert_includes rule.errors[:metadata], "must include 'limit' and 'window' for rate_limit rules"
|
||||
@@ -84,46 +94,56 @@ class RuleTest < ActiveSupport::TestCase
|
||||
|
||||
# Default value tests
|
||||
test "should default enabled to true" do
|
||||
network_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" }
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: network_range,
|
||||
user: users(:one)
|
||||
)
|
||||
assert rule.enabled?
|
||||
end
|
||||
|
||||
# Priority calculation tests
|
||||
test "should calculate priority from IPv4 CIDR prefix" do
|
||||
network_range = NetworkRange.create!(cidr: "192.168.1.0/24")
|
||||
rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.1.0/24" }
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: network_range,
|
||||
user: users(:one)
|
||||
)
|
||||
assert_equal 24, rule.priority
|
||||
end
|
||||
|
||||
# Scope tests
|
||||
test "active scope returns enabled and non-expired rules" do
|
||||
active_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
active = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
enabled: true
|
||||
)
|
||||
|
||||
disabled = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "192.168.0.0/16" },
|
||||
enabled: false
|
||||
)
|
||||
|
||||
expired = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "172.16.0.0/12" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: active_range,
|
||||
enabled: true,
|
||||
expires_at: 1.hour.ago
|
||||
user: users(:one)
|
||||
)
|
||||
|
||||
disabled_range = NetworkRange.create!(cidr: "192.168.0.0/16")
|
||||
disabled = Rule.create!(
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: disabled_range,
|
||||
enabled: false,
|
||||
user: users(:one)
|
||||
)
|
||||
|
||||
expired_range = NetworkRange.create!(cidr: "172.16.0.0/12")
|
||||
expired = Rule.create!(
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: expired_range,
|
||||
enabled: true,
|
||||
expires_at: 1.hour.ago,
|
||||
user: users(:one)
|
||||
)
|
||||
|
||||
results = Rule.active.to_a
|
||||
@@ -134,20 +154,24 @@ class RuleTest < ActiveSupport::TestCase
|
||||
|
||||
# Instance method tests
|
||||
test "active? returns true for enabled non-expired rule" do
|
||||
network_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
enabled: true
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: network_range,
|
||||
enabled: true,
|
||||
user: users(:one)
|
||||
)
|
||||
assert rule.active?
|
||||
end
|
||||
|
||||
test "disable! sets enabled to false and adds metadata" do
|
||||
network_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" }
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: network_range,
|
||||
user: users(:one)
|
||||
)
|
||||
|
||||
rule.disable!(reason: "False positive")
|
||||
@@ -159,20 +183,22 @@ class RuleTest < ActiveSupport::TestCase
|
||||
|
||||
# Agent format tests
|
||||
test "to_agent_format returns correct structure" do
|
||||
network_range = NetworkRange.create!(cidr: "10.0.0.0/8")
|
||||
rule = Rule.create!(
|
||||
rule_type: "network_v4",
|
||||
action: "deny",
|
||||
conditions: { cidr: "10.0.0.0/8" },
|
||||
waf_rule_type: "network",
|
||||
waf_action: "deny",
|
||||
network_range: network_range,
|
||||
expires_at: 1.day.from_now,
|
||||
source: "manual",
|
||||
metadata: { reason: "Test" }
|
||||
metadata: { reason: "Test" },
|
||||
user: users(:one)
|
||||
)
|
||||
|
||||
format = rule.to_agent_format
|
||||
|
||||
assert_equal rule.id, format[:id]
|
||||
assert_equal "network_v4", format[:rule_type]
|
||||
assert_equal "deny", format[:action]
|
||||
assert_equal "network", format[:waf_rule_type]
|
||||
assert_equal "deny", format[:waf_action]
|
||||
assert_equal 8, format[:priority]
|
||||
assert_equal true, format[:enabled]
|
||||
end
|
||||
|
||||
7
test/models/setting_test.rb
Normal file
7
test/models/setting_test.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
require "test_helper"
|
||||
|
||||
class SettingTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
||||
474
test/models/waf_policy_test.rb
Normal file
474
test/models/waf_policy_test.rb
Normal file
@@ -0,0 +1,474 @@
|
||||
require "test_helper"
|
||||
|
||||
class WafPolicyTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@user = users(:jason)
|
||||
@policy = WafPolicy.new(
|
||||
name: "Block Malicious IPs",
|
||||
policy_type: "country",
|
||||
targets: ["BR", "CN"],
|
||||
policy_action: "deny",
|
||||
user: @user
|
||||
)
|
||||
end
|
||||
|
||||
# Validations
|
||||
test "should be valid with all required attributes" do
|
||||
assert @policy.valid?
|
||||
end
|
||||
|
||||
test "should not be valid without name" do
|
||||
@policy.name = nil
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:name], "can't be blank"
|
||||
end
|
||||
|
||||
test "should not be valid without unique name" do
|
||||
@policy.name = waf_policies(:one).name
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:name], "has already been taken"
|
||||
end
|
||||
|
||||
test "should validate policy_type inclusion" do
|
||||
@policy.policy_type = "invalid_type"
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:policy_type], "is not included in the list"
|
||||
end
|
||||
|
||||
test "should validate policy_action inclusion" do
|
||||
@policy.policy_action = "invalid_action"
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:policy_action], "is not included in the list"
|
||||
end
|
||||
|
||||
test "should not be valid without targets" do
|
||||
@policy.targets = []
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:targets], "can't be blank"
|
||||
end
|
||||
|
||||
test "should validate targets is an array" do
|
||||
@policy.targets = "not an array"
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:targets], "must be an array"
|
||||
end
|
||||
|
||||
test "should validate country targets format" do
|
||||
@policy.policy_type = "country"
|
||||
|
||||
# Valid country codes
|
||||
@policy.targets = ["US", "BR", "CN"]
|
||||
assert @policy.valid?
|
||||
|
||||
# Invalid country codes
|
||||
@policy.targets = ["USA", "123", "B"]
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:targets], "must be valid ISO country codes"
|
||||
end
|
||||
|
||||
test "should validate ASN targets format" do
|
||||
@policy.policy_type = "asn"
|
||||
|
||||
# Valid ASNs
|
||||
@policy.targets = [12345, 67890]
|
||||
assert @policy.valid?
|
||||
|
||||
# Invalid ASNs
|
||||
@policy.targets = ["AS12345", -1, 0]
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:targets], "must be valid ASNs"
|
||||
end
|
||||
|
||||
test "should validate company targets format" do
|
||||
@policy.policy_type = "company"
|
||||
|
||||
# Valid company names
|
||||
@policy.targets = ["Google", "Amazon Web Services", "Microsoft"]
|
||||
assert @policy.valid?
|
||||
|
||||
# Invalid company names
|
||||
@policy.targets = ["", nil, " "]
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:targets], "must be valid company names"
|
||||
end
|
||||
|
||||
test "should validate network_type targets format" do
|
||||
@policy.policy_type = "network_type"
|
||||
|
||||
# Valid network types
|
||||
@policy.targets = ["datacenter", "proxy", "vpn", "standard"]
|
||||
assert @policy.valid?
|
||||
|
||||
# Invalid network types
|
||||
@policy.targets = ["invalid", "malicious", "botnet"]
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:targets], "must be one of: datacenter, proxy, vpn, standard"
|
||||
end
|
||||
|
||||
test "should validate redirect configuration" do
|
||||
@policy.policy_action = "redirect"
|
||||
|
||||
# Valid redirect config
|
||||
@policy.additional_data = { "redirect_url" => "https://example.com/blocked" }
|
||||
assert @policy.valid?
|
||||
|
||||
# Missing redirect URL
|
||||
@policy.additional_data = { "other_config" => "value" }
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:additional_data], "must include 'redirect_url' for redirect action"
|
||||
end
|
||||
|
||||
test "should validate challenge configuration" do
|
||||
@policy.policy_action = "challenge"
|
||||
|
||||
# Valid challenge types
|
||||
@policy.additional_data = { "challenge_type" => "captcha" }
|
||||
assert @policy.valid?
|
||||
|
||||
@policy.additional_data = { "challenge_type" => "javascript" }
|
||||
assert @policy.valid?
|
||||
|
||||
# Invalid challenge type
|
||||
@policy.additional_data = { "challenge_type" => "invalid" }
|
||||
assert_not @policy.valid?
|
||||
assert_includes @policy.errors[:additional_data], "challenge_type must be one of: captcha, javascript, proof_of_work"
|
||||
|
||||
# No challenge type (should be valid, uses defaults)
|
||||
@policy.additional_data = {}
|
||||
assert @policy.valid?
|
||||
end
|
||||
|
||||
# Defaults and Callbacks
|
||||
test "should default to enabled" do
|
||||
@policy.enabled = nil
|
||||
@policy.save!
|
||||
assert @policy.enabled?
|
||||
end
|
||||
|
||||
test "should default targets to empty array" do
|
||||
policy = WafPolicy.new(
|
||||
name: "Test Policy",
|
||||
policy_type: "country",
|
||||
policy_action: "deny",
|
||||
user: @user
|
||||
)
|
||||
# before_validation should set defaults
|
||||
policy.valid?
|
||||
assert_equal [], policy.targets
|
||||
end
|
||||
|
||||
test "should default additional_data to empty hash" do
|
||||
policy = WafPolicy.new(
|
||||
name: "Test Policy",
|
||||
policy_type: "country",
|
||||
targets: ["US"],
|
||||
policy_action: "deny",
|
||||
user: @user
|
||||
)
|
||||
policy.valid?
|
||||
assert_equal({}, policy.additional_data)
|
||||
end
|
||||
|
||||
# Policy Type Methods
|
||||
test "policy type predicate methods work correctly" do
|
||||
country_policy = WafPolicy.new(policy_type: "country")
|
||||
assert country_policy.country_policy?
|
||||
assert_not country_policy.asn_policy?
|
||||
assert_not country_policy.company_policy?
|
||||
assert_not country_policy.network_type_policy?
|
||||
|
||||
asn_policy = WafPolicy.new(policy_type: "asn")
|
||||
assert_not asn_policy.country_policy?
|
||||
assert asn_policy.asn_policy?
|
||||
assert_not asn_policy.company_policy?
|
||||
assert_not asn_policy.network_type_policy?
|
||||
|
||||
company_policy = WafPolicy.new(policy_type: "company")
|
||||
assert_not company_policy.country_policy?
|
||||
assert_not company_policy.asn_policy?
|
||||
assert company_policy.company_policy?
|
||||
assert_not company_policy.network_type_policy?
|
||||
|
||||
network_type_policy = WafPolicy.new(policy_type: "network_type")
|
||||
assert_not network_type_policy.country_policy?
|
||||
assert_not network_type_policy.asn_policy?
|
||||
assert_not network_type_policy.company_policy?
|
||||
assert network_type_policy.network_type_policy?
|
||||
end
|
||||
|
||||
# Action Methods
|
||||
test "action predicate methods work correctly" do
|
||||
allow_policy = WafPolicy.new(policy_action: "allow")
|
||||
assert allow_policy.allow_action?
|
||||
assert_not allow_policy.deny_action?
|
||||
assert_not allow_policy.redirect_action?
|
||||
assert_not allow_policy.challenge_action?
|
||||
|
||||
deny_policy = WafPolicy.new(policy_action: "deny")
|
||||
assert_not deny_policy.allow_action?
|
||||
assert deny_policy.deny_action?
|
||||
assert_not deny_policy.redirect_action?
|
||||
assert_not deny_policy.challenge_action?
|
||||
|
||||
redirect_policy = WafPolicy.new(policy_action: "redirect")
|
||||
assert_not redirect_policy.allow_action?
|
||||
assert_not redirect_policy.deny_action?
|
||||
assert redirect_policy.redirect_action?
|
||||
assert_not redirect_policy.challenge_action?
|
||||
|
||||
challenge_policy = WafPolicy.new(policy_action: "challenge")
|
||||
assert_not challenge_policy.allow_action?
|
||||
assert_not challenge_policy.deny_action?
|
||||
assert_not challenge_policy.redirect_action?
|
||||
assert challenge_policy.challenge_action?
|
||||
end
|
||||
|
||||
# Policy action methods (to avoid Rails conflicts)
|
||||
test "policy action predicate methods work correctly" do
|
||||
policy = WafPolicy.new(policy_action: "deny")
|
||||
assert policy.deny_policy_action?
|
||||
assert_not policy.allow_policy_action?
|
||||
assert_not policy.redirect_policy_action?
|
||||
assert_not policy.challenge_policy_action?
|
||||
end
|
||||
|
||||
# Lifecycle Methods
|
||||
test "active? works correctly" do
|
||||
# Active policy
|
||||
active_policy = WafPolicy.new(enabled: true, expires_at: nil)
|
||||
assert active_policy.active?
|
||||
|
||||
# Enabled but expired
|
||||
expired_policy = WafPolicy.new(enabled: true, expires_at: 1.day.ago)
|
||||
assert_not expired_policy.active?
|
||||
|
||||
# Disabled with future expiration
|
||||
disabled_policy = WafPolicy.new(enabled: false, expires_at: 1.day.from_now)
|
||||
assert_not disabled_policy.active?
|
||||
|
||||
# Disabled with no expiration
|
||||
disabled_no_exp = WafPolicy.new(enabled: false, expires_at: nil)
|
||||
assert_not disabled_no_exp.active?
|
||||
|
||||
# Enabled with future expiration
|
||||
future_exp = WafPolicy.new(enabled: true, expires_at: 1.day.from_now)
|
||||
assert future_exp.active?
|
||||
end
|
||||
|
||||
test "expired? works correctly" do
|
||||
assert_not WafPolicy.new(expires_at: nil).expired?
|
||||
assert_not WafPolicy.new(expires_at: 1.day.from_now).expired?
|
||||
assert WafPolicy.new(expires_at: 1.day.ago).expired?
|
||||
assert WafPolicy.new(expires_at: Time.current).expired?
|
||||
end
|
||||
|
||||
test "activate! enables policy" do
|
||||
@policy.enabled = false
|
||||
@policy.save!
|
||||
|
||||
@policy.activate!
|
||||
assert @policy.reload.enabled?
|
||||
end
|
||||
|
||||
test "deactivate! disables policy" do
|
||||
@policy.enabled = true
|
||||
@policy.save!
|
||||
|
||||
@policy.deactivate!
|
||||
assert_not @policy.reload.enabled?
|
||||
end
|
||||
|
||||
test "expire! sets expiration to now" do
|
||||
@policy.expire!
|
||||
assert @policy.reload.expires_at <= Time.current
|
||||
end
|
||||
|
||||
# Scopes
|
||||
test "enabled scope returns only enabled policies" do
|
||||
enabled_policy = WafPolicy.create!(
|
||||
name: "Enabled Policy",
|
||||
policy_type: "country",
|
||||
targets: ["US"],
|
||||
policy_action: "deny",
|
||||
user: @user,
|
||||
enabled: true
|
||||
)
|
||||
disabled_policy = WafPolicy.create!(
|
||||
name: "Disabled Policy",
|
||||
policy_type: "country",
|
||||
targets: ["US"],
|
||||
policy_action: "deny",
|
||||
user: @user,
|
||||
enabled: false
|
||||
)
|
||||
|
||||
enabled_policies = WafPolicy.enabled
|
||||
assert_includes enabled_policies, enabled_policy
|
||||
assert_not_includes enabled_policies, disabled_policy
|
||||
end
|
||||
|
||||
test "active scope returns only active policies" do
|
||||
active_policy = WafPolicy.create!(
|
||||
name: "Active Policy",
|
||||
policy_type: "country",
|
||||
targets: ["US"],
|
||||
policy_action: "deny",
|
||||
user: @user,
|
||||
enabled: true,
|
||||
expires_at: 1.day.from_now
|
||||
)
|
||||
expired_policy = WafPolicy.create!(
|
||||
name: "Expired Policy",
|
||||
policy_type: "country",
|
||||
targets: ["US"],
|
||||
policy_action: "deny",
|
||||
user: @user,
|
||||
enabled: true,
|
||||
expires_at: 1.day.ago
|
||||
)
|
||||
disabled_policy = WafPolicy.create!(
|
||||
name: "Disabled Policy",
|
||||
policy_type: "country",
|
||||
targets: ["US"],
|
||||
policy_action: "deny",
|
||||
user: @user,
|
||||
enabled: false
|
||||
)
|
||||
|
||||
active_policies = WafPolicy.active
|
||||
assert_includes active_policies, active_policy
|
||||
assert_not_includes active_policies, expired_policy
|
||||
assert_not_includes active_policies, disabled_policy
|
||||
end
|
||||
|
||||
# Class Factory Methods
|
||||
test "create_country_policy works correctly" do
|
||||
policy = WafPolicy.create_country_policy(
|
||||
["US", "CA"],
|
||||
policy_action: "deny",
|
||||
user: @user,
|
||||
name: "Custom Name"
|
||||
)
|
||||
|
||||
assert policy.persisted?
|
||||
assert_equal "Custom Name", policy.name
|
||||
assert_equal "country", policy.policy_type
|
||||
assert_equal "deny", policy.policy_action
|
||||
assert_equal ["US", "CA"], policy.targets
|
||||
assert_equal @user, policy.user
|
||||
end
|
||||
|
||||
test "create_asn_policy works correctly" do
|
||||
policy = WafPolicy.create_asn_policy(
|
||||
[12345, 67890],
|
||||
policy_action: "challenge",
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert policy.persisted?
|
||||
assert_equal "challenge ASNs 12345, 67890", policy.name
|
||||
assert_equal "asn", policy.policy_type
|
||||
assert_equal "challenge", policy.policy_action
|
||||
assert_equal [12345, 67890], policy.targets
|
||||
end
|
||||
|
||||
test "create_company_policy works correctly" do
|
||||
policy = WafPolicy.create_company_policy(
|
||||
["Google", "Amazon"],
|
||||
policy_action: "deny",
|
||||
user: @user
|
||||
)
|
||||
|
||||
assert policy.persisted?
|
||||
assert_equal "deny Google, Amazon", policy.name
|
||||
assert_equal "company", policy.policy_type
|
||||
assert_equal ["Google", "Amazon"], policy.targets
|
||||
end
|
||||
|
||||
test "create_network_type_policy works correctly" do
|
||||
policy = WafPolicy.create_network_type_policy(
|
||||
["datacenter", "proxy"],
|
||||
policy_action: "redirect",
|
||||
user: @user,
|
||||
additional_data: { redirect_url: "https://example.com/blocked" }
|
||||
)
|
||||
|
||||
assert policy.persisted?
|
||||
assert_equal "redirect datacenter, proxy", policy.name
|
||||
assert_equal "network_type", policy.policy_type
|
||||
assert_equal ["datacenter", "proxy"], policy.targets
|
||||
end
|
||||
|
||||
# Redirect/Challenge Specific Methods
|
||||
test "redirect_url and redirect_status methods work" do
|
||||
policy = WafPolicy.new(
|
||||
policy_action: "redirect",
|
||||
additional_data: {
|
||||
"redirect_url" => "https://example.com/blocked",
|
||||
"redirect_status" => 301
|
||||
}
|
||||
)
|
||||
|
||||
assert_equal "https://example.com/blocked", policy.redirect_url
|
||||
assert_equal 301, policy.redirect_status
|
||||
|
||||
# Default status
|
||||
policy.additional_data = { "redirect_url" => "https://example.com/blocked" }
|
||||
assert_equal 302, policy.redirect_status
|
||||
end
|
||||
|
||||
test "challenge_type and challenge_message methods work" do
|
||||
policy = WafPolicy.new(
|
||||
policy_action: "challenge",
|
||||
additional_data: {
|
||||
"challenge_type" => "javascript",
|
||||
"challenge_message" => "Please verify you are human"
|
||||
}
|
||||
)
|
||||
|
||||
assert_equal "javascript", policy.challenge_type
|
||||
assert_equal "Please verify you are human", policy.challenge_message
|
||||
|
||||
# Default challenge type
|
||||
policy.additional_data = {}
|
||||
assert_equal "captcha", policy.challenge_type
|
||||
end
|
||||
|
||||
# Statistics
|
||||
test "generated_rules_count works" do
|
||||
@policy.save!
|
||||
|
||||
# Initially no rules
|
||||
assert_equal 0, @policy.generated_rules_count
|
||||
|
||||
# Create some rules
|
||||
network_range = NetworkRange.create!(ip_range: "192.168.1.0/24")
|
||||
@policy.create_rule_for_network_range(network_range)
|
||||
|
||||
assert_equal 1, @policy.generated_rules_count
|
||||
end
|
||||
|
||||
test "effectiveness_stats returns correct data" do
|
||||
@policy.save!
|
||||
|
||||
stats = @policy.effectiveness_stats
|
||||
|
||||
assert_equal 0, stats[:total_rules_generated]
|
||||
assert_equal 0, stats[:active_rules]
|
||||
assert_equal 0, stats[:rules_last_7_days]
|
||||
assert_equal "country", stats[:policy_type]
|
||||
assert_equal "deny", stats[:policy_action]
|
||||
assert_equal 2, stats[:targets_count]
|
||||
end
|
||||
|
||||
# String representations
|
||||
test "to_s returns name" do
|
||||
assert_equal @policy.name, @policy.to_s
|
||||
end
|
||||
|
||||
test "to_param parameterizes name" do
|
||||
@policy.name = "Block Brazil & China"
|
||||
expected = "block-brazil-china"
|
||||
assert_equal expected, @policy.to_param
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user