diff --git a/Gemfile b/Gemfile index f662b2f..9ad90a8 100644 --- a/Gemfile +++ b/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" diff --git a/Gemfile.lock b/Gemfile.lock index f6dc474..21eaa1a 100644 --- a/Gemfile.lock +++ b/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 diff --git a/README.md b/README.md index a8e6118..6bb408b 100644 --- a/README.md +++ b/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 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index aafb680..b5117c7 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -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; diff --git a/app/controllers/analytics_controller.rb b/app/controllers/analytics_controller.rb index ec73907..fe49fa8 100644 --- a/app/controllers/analytics_controller.rb +++ b/app/controllers/analytics_controller.rb @@ -11,17 +11,41 @@ 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) - .group(:waf_action) - .count - .transform_keys do |action_id| + # 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| case action_id when 0 then 'allow' when 1 then 'deny' @@ -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") - .count - .sort_by { |_, count| -count } - .first(10) + # 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) - .where(waf_action: 1) # deny action in enum - .group(:ip_address) - .count - .sort_by { |_, count| -count } - .first(10) + # 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 = { - datacenter_ranges: NetworkRange.datacenter.count, - vpn_ranges: NetworkRange.vpn.count, - proxy_ranges: NetworkRange.proxy.count, - total_ranges: NetworkRange.count - } + # 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 = { - 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 - } + @recent_rules = Rails.cache.fetch("analytics/recent_rules", expires_in: 5.minutes) do + Rule.order(created_at: :desc).limit(5).to_a + end - # Prepare data for charts - @chart_data = prepare_chart_data + # 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 + + # 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,40 +125,42 @@ 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") - .order("event_count DESC") - .limit(20) + # 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") - .order("event_count DESC") - .limit(15) + # 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") - .order("event_count DESC") - .limit(15) + # 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) # Suspicious network activity patterns @suspicious_patterns = calculate_suspicious_patterns(@start_time) @@ -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_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 + + (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 - # Events over time (hourly buckets for last 24 hours) - events_by_hour = Event.where("timestamp >= ?", 24.hours.ago) + # 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 - keep everything in UTC for consistency + # 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 + # 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 - if scope - network_stats = scope.joins("LEFT JOIN events ON events.ip_address <<= network_ranges.network") - .where("events.timestamp >= ? OR events.timestamp IS NULL", start_time) - .select("COUNT(events.id) as event_count, COUNT(DISTINCT events.ip_address) as unique_ips, COUNT(DISTINCT network_ranges.id) as network_count") - .first - - results[network_type[:type]] = { - label: network_type[:label], - networks: network_stats.network_count, - events: network_stats.event_count, - unique_ips: network_stats.unique_ips, - percentage: total_events > 0 ? ((network_stats.event_count.to_f / total_events) * 100).round(1) : 0 - } - end + results[network_type[:type]] = { + label: network_type[:label], + 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 # 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) - .count + 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 - } + 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) - .group(:company) - .select(:company, "COUNT(DISTINCT network) as subnet_count") - .having("COUNT(DISTINCT network) > 5") - .order("subnet_count DESC") - .limit(10) + # 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 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 \ No newline at end of file diff --git a/app/controllers/api/events_controller.rb b/app/controllers/api/events_controller.rb index e76a329..83374b7 100644 --- a/app/controllers/api/events_controller.rb +++ b/app/controllers/api/events_controller.rb @@ -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 diff --git a/app/controllers/dsns_controller.rb b/app/controllers/dsns_controller.rb index 6704a60..4b0044c 100644 --- a/app/controllers/dsns_controller.rb +++ b/app/controllers/dsns_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index ead1215..9fe0ae8 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/network_ranges_controller.rb b/app/controllers/network_ranges_controller.rb index e056b9f..83ca2c1 100644 --- a/app/controllers/network_ranges_controller.rb +++ b/app/controllers/network_ranges_controller.rb @@ -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) - } + { + total_requests: total_events, + 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 \ No newline at end of file diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index 61d2068..4014b45 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -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 \ No newline at end of file diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb new file mode 100644 index 0000000..6486572 --- /dev/null +++ b/app/controllers/settings_controller.rb @@ -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 diff --git a/app/controllers/waf_policies_controller.rb b/app/controllers/waf_policies_controller.rb index 6a5bf75..e0e3337 100644 --- a/app/controllers/waf_policies_controller.rb +++ b/app/controllers/waf_policies_controller.rb @@ -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,10 +137,22 @@ class WafPoliciesController < ApplicationController private def set_waf_policy - @waf_policy = WafPolicy.find(params[:id]) - authorize @waf_policy - rescue ActiveRecord::RecordNotFound - redirect_to waf_policies_path, alert: 'WAF policy not found.' + # 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 + else + redirect_to waf_policies_path, alert: 'WAF policy not found.' + end end def waf_policy_params @@ -155,7 +160,7 @@ class WafPoliciesController < ApplicationController :name, :description, :policy_type, - :action, + :policy_action, :enabled, :expires_at, targets: [], diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 671a85e..39397e0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -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 diff --git a/app/javascript/controllers/dashboard_controller.js b/app/javascript/controllers/dashboard_controller.js index 78c940a..edb3ab8 100644 --- a/app/javascript/controllers/dashboard_controller.js +++ b/app/javascript/controllers/dashboard_controller.js @@ -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() { diff --git a/app/javascript/controllers/quick_create_rule_controller.js b/app/javascript/controllers/quick_create_rule_controller.js new file mode 100644 index 0000000..236a130 --- /dev/null +++ b/app/javascript/controllers/quick_create_rule_controller.js @@ -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() + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/waf_policy_form_controller.js b/app/javascript/controllers/waf_policy_form_controller.js new file mode 100644 index 0000000..4bcbe61 --- /dev/null +++ b/app/javascript/controllers/waf_policy_form_controller.js @@ -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 + } + } +} \ No newline at end of file diff --git a/app/jobs/fetch_ipapi_data_job.rb b/app/jobs/fetch_ipapi_data_job.rb new file mode 100644 index 0000000..8adeeef --- /dev/null +++ b/app/jobs/fetch_ipapi_data_job.rb @@ -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 diff --git a/app/jobs/generate_waf_rules_job.rb b/app/jobs/generate_waf_rules_job.rb deleted file mode 100644 index 1364da1..0000000 --- a/app/jobs/generate_waf_rules_job.rb +++ /dev/null @@ -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 = [ - / \ No newline at end of file diff --git a/app/views/rules/_compact_rule.html.erb b/app/views/rules/_compact_rule.html.erb new file mode 100644 index 0000000..70235e7 --- /dev/null +++ b/app/views/rules/_compact_rule.html.erb @@ -0,0 +1,33 @@ +<%# Compact rule display for showing rules on network range pages %> +
+
+ <%= link_to rule, class: "flex items-center space-x-2 min-w-0 hover:text-blue-600" do %> + <%# Action badge %> + + <%= rule.waf_action.upcase %> + + + <%# Network CIDR %> + <%= rule.network_range.cidr %> + + <%# Priority %> + P:<%= rule.priority %> + <% end %> +
+ +
+ <%# Disabled badge %> + <% unless rule.enabled? %> + + Disabled + + <% end %> + + <%# Policy badge if policy-generated %> + <% if rule.waf_policy.present? %> + + Policy + + <% end %> +
+
diff --git a/app/views/rules/edit.html.erb b/app/views/rules/edit.html.erb index 1391903..ae70a87 100644 --- a/app/views/rules/edit.html.erb +++ b/app/views/rules/edit.html.erb @@ -44,9 +44,9 @@
- <%= 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 @@
- <%= 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" } %>

What action to take when this rule matches

diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb index 2a9626f..21808e6 100644 --- a/app/views/rules/index.html.erb +++ b/app/views/rules/index.html.erb @@ -25,7 +25,7 @@
Total Rules
-
<%= number_with_delimiter(@rules.count) %>
+
<%= number_with_delimiter(Rule.count) %>
@@ -42,26 +42,8 @@
-
Active Rules
-
<%= number_with_delimiter(@rules.active.count) %>
-
-
-
- - - -
-
-
-
- - - -
-
-
-
Block Rules
-
<%= number_with_delimiter(@rules.where(action: 'deny').count) %>
+
Active Block Rules
+
<%= number_with_delimiter(Rule.deny.active.count) %>
@@ -73,13 +55,31 @@
+ + +
+
+
+
Disabled Rules
+
<%= number_with_delimiter(Rule.where(enabled: false).count) %>
+
+
+
+
+
+ +
+
+
+
+
Expired Rules
-
<%= number_with_delimiter(@rules.expired.count) %>
+
<%= number_with_delimiter(Rule.expired.count) %>
@@ -114,7 +114,7 @@ Rule Type Action - Target + Events Status Created Actions @@ -123,54 +123,77 @@ <% @rules.each do |rule| %> - -
+ +
+
<%= link_to "Rule ##{rule.id}", rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
-
- <%= rule.source.humanize %> - <% 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" %> - <% end %> -
+ + + <% if rule.waf_policy.present? %> +
+
Policy
+
+ <%= link_to rule.waf_policy.name, waf_policy_path(rule.waf_policy), class: "text-blue-600 hover:text-blue-900" %> +
+
+ <% end %> + + + <% if rule.network_range? && rule.network_range %> +
+
IP network
+
+ <%= 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? %> +
<%= rule.network_range.company %>
+ <% end %> +
+
+ <% elsif rule.conditions.present? && rule.conditions&.dig('cidr').present? %> +
+
IP network
+
+ <%= rule.conditions['cidr'] %> +
+
+ <% end %>
- <%= rule.rule_type.humanize %> + <%= rule.waf_rule_type.humanize %> - <%= rule.action.upcase %> + <%= rule.waf_action.upcase %> - - <% if rule.network_range? && rule.network_range %> - <%= rule.network_range.cidr %> - <% if rule.network_range.company.present? %> -
<%= rule.network_range.company %>
- <% end %> - <% elsif rule.conditions.present? %> -
- <%= JSON.parse(rule.conditions || "{}").map { |k, v| "#{k}: #{v}" }.join(", ") rescue "Invalid JSON" %> + + <% 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" %> +
+ <%= rule.events.where(waf_action: :deny).count %> blocked
<% else %> - diff --git a/app/views/rules/new.html.erb b/app/views/rules/new.html.erb index 34d7233..c591e1e 100644 --- a/app/views/rules/new.html.erb +++ b/app/views/rules/new.html.erb @@ -40,9 +40,9 @@
- <%= 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 @@
- <%= 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" } %>

What action to take when this rule matches

@@ -113,13 +113,47 @@
- + + + + @@ -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') { + 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'); - conditionsSection.classList.add('hidden'); + } else if (ruleType === 'path_pattern') { + pathPatternSection.classList.remove('hidden'); } else { - networkSection.classList.add('hidden'); 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 = '
  • Enter a pattern to see examples
  • '; + 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 => `
  • ${ex}
  • `).join(''); + } + ruleTypeSelect.addEventListener('change', toggleSections); + pathPatternInput.addEventListener('input', updatePathExamples); + matchTypeSelect.addEventListener('change', updatePathExamples); + toggleSections(); // Initial state // Pre-select network range if provided diff --git a/app/views/rules/show.html.erb b/app/views/rules/show.html.erb index 810a19f..43687c9 100644 --- a/app/views/rules/show.html.erb +++ b/app/views/rules/show.html.erb @@ -1,4 +1,4 @@ -<% content_for :title, "Rule ##{@rule.id} - #{@rule.action.upcase}" %> +<% content_for :title, "Rule ##{@rule.id} - #{@rule.waf_action.upcase}" %>
    @@ -23,15 +23,16 @@

    Rule #<%= @rule.id %>

    - <%= @rule.action.upcase %> + <%= @rule.waf_action.upcase %>
    @@ -60,12 +61,12 @@
    Rule Type
    -
    <%= @rule.rule_type.humanize %>
    +
    <%= @rule.waf_rule_type.humanize %>
    Action
    -
    <%= @rule.action.upcase %>
    +
    <%= @rule.waf_action.upcase %>
    @@ -118,6 +119,38 @@
    + +
    +
    +

    Event Statistics

    + <%= link_to "View Events", events_path(rule_id: @rule.id), class: "text-sm text-blue-600 hover:text-blue-800" %> +
    +
    +
    +
    +
    Total Events
    +
    <%= @rule.events.count %>
    +
    + +
    +
    Blocked Events
    +
    <%= @rule.events.where(waf_action: :deny).count %>
    +
    + +
    +
    Allowed Events
    +
    <%= @rule.events.where(waf_action: :allow).count %>
    +
    +
    + + <% if @rule.events.any? %> +
    + Last event: <%= time_ago_in_words(@rule.events.maximum(:timestamp)) %> ago +
    + <% end %> +
    +
    + <% if @rule.network_rule? && @rule.network_range.present? %>
    diff --git a/app/views/settings/index.html.erb b/app/views/settings/index.html.erb new file mode 100644 index 0000000..9601fec --- /dev/null +++ b/app/views/settings/index.html.erb @@ -0,0 +1,60 @@ +<% content_for :title, "Settings" %> + +
    + +
    +
    +
    +

    Settings

    +

    Manage system configuration and API keys

    +
    +
    +
    + + +
    +
    +

    API Configuration

    + + +
    + <%= form_with url: settings_path, method: :patch, class: "space-y-4" do |f| %> + <%= hidden_field_tag :key, 'ipapi_key' %> + +
    + +
    + <%= 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" %> +
    +

    + <% if @settings['ipapi_key']&.value.present? %> + ✓ Configured in database + <% elsif ENV['IPAPI_KEY'].present? %> + Using environment variable (IPAPI_KEY) + <% else %> + ipapi.is not active + <% end %> +

    +

    + Get your API key from ipapi.is +

    +
    + <% end %> +
    +
    +
    + + +
    +
    +

    Additional Settings

    +

    More configuration options will be added here as needed.

    +
    +
    +
    diff --git a/app/views/waf_policies/edit.html.erb b/app/views/waf_policies/edit.html.erb index 229fda3..e651ba3 100644 --- a/app/views/waf_policies/edit.html.erb +++ b/app/views/waf_policies/edit.html.erb @@ -13,7 +13,7 @@
    - <%= 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| %>
    @@ -35,14 +35,14 @@ placeholder: "Explain why this policy is needed..." %>
    - +
    - <%= 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" } } %>
    @@ -164,7 +164,7 @@

    ⚙️ Additional Configuration

    -
    +
    <%= 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 @@
    -
    +
    <%= label_tag "additional_data[challenge_type]", "Challenge Type", class: "block text-sm font-medium text-gray-700" %> <%= select_tag "additional_data[challenge_type]", @@ -205,36 +205,4 @@ class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
    <% end %> -
    - - \ No newline at end of file +
    \ No newline at end of file diff --git a/app/views/waf_policies/index.html.erb b/app/views/waf_policies/index.html.erb index 5cce23f..ef7208c 100644 --- a/app/views/waf_policies/index.html.erb +++ b/app/views/waf_policies/index.html.erb @@ -85,7 +85,7 @@
    Deny Policies
    - <%= number_with_delimiter(@waf_policies.where(action: 'deny').count) %> + <%= number_with_delimiter(@waf_policies.where(policy_action: 'deny').count) %>
    @@ -137,15 +137,15 @@ <% end %> - + - <%= policy.action.upcase %> + <%= policy.policy_action.upcase %>
    diff --git a/app/views/waf_policies/new.html.erb b/app/views/waf_policies/new.html.erb index 4266abc..c67cb92 100644 --- a/app/views/waf_policies/new.html.erb +++ b/app/views/waf_policies/new.html.erb @@ -13,7 +13,7 @@
    - <%= 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| %>
    @@ -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" } } %>
    - +
    - <%= 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" } } %>
    @@ -63,7 +63,7 @@

    🎯 Targets Configuration

    -