diff --git a/Gemfile b/Gemfile index e2a5d8c..1d7e387 100644 --- a/Gemfile +++ b/Gemfile @@ -4,8 +4,11 @@ source "https://rubygems.org" gem "rails", "~> 8.1.1" # The modern asset pipeline for Rails [https://github.com/rails/propshaft] gem "propshaft" -# Use sqlite3 as the database for Active Record +# Use sqlite3 as the database for Active Record (for cache/queue/cable) gem "sqlite3", ">= 2.1" + +# Use PostgreSQL as the primary database +gem "pg", ">= 1.1" # Use the Puma web server [https://github.com/puma/puma] gem "puma", ">= 5.0" # Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails] @@ -56,6 +59,9 @@ gem "maxmind-db" # HTTP client for database downloads gem "httparty" +# Authorization library +gem "pundit" + 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" diff --git a/Gemfile.lock b/Gemfile.lock index c9668c0..7ffb840 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -261,6 +261,12 @@ GEM parser (3.3.10.0) ast (~> 2.4.1) racc + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-aarch64-linux-musl) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-linux) + pg (1.6.2-x86_64-linux-musl) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -275,6 +281,8 @@ GEM public_suffix (6.0.2) puma (7.1.0) nio4r (~> 2.0) + pundit (2.5.2) + activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) rack (3.2.3) @@ -489,8 +497,10 @@ DEPENDENCIES omniauth_openid_connect (~> 0.8) openid_connect (~> 2.2) pagy + pg (>= 1.1) propshaft puma (>= 5.0) + pundit rails (~> 8.1.1) rubocop-rails-omakase selenium-webdriver diff --git a/README.md b/README.md index e6646ac..a8e6118 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Rails 8 WAF analytics and automated rule management system** ⚠️ **Experimental** -Baffle Hub provides intelligent Web Application Firewall (WAF) analytics with automated rule generation. It combines real-time threat detection with SQLite-based local storage for ultra-fast request filtering. +Baffle Hub provides intelligent Web Application Firewall (WAF) analytics with automated rule generation. It combines real-time threat detection with PostgreSQL-based database for ultra-fast request filtering. ## Features diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333..1ddd232 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -8,3 +8,26 @@ * * Consider organizing styles into separate files for maintainability. */ + +/* JSON Validator Styles */ +.json-valid { + border-color: #10b981 !important; + box-shadow: 0 0 0 1px #10b981 !important; +} + +.json-invalid { + border-color: #ef4444 !important; + box-shadow: 0 0 0 1px #ef4444 !important; +} + +.json-valid-status { + color: #10b981; + font-size: 0.875rem; + font-weight: 500; +} + +.json-invalid-status { + color: #ef4444; + font-size: 0.875rem; + font-weight: 500; +} diff --git a/app/controllers/api/events_controller.rb b/app/controllers/api/events_controller.rb index 19e7a60..f1a900b 100644 --- a/app/controllers/api/events_controller.rb +++ b/app/controllers/api/events_controller.rb @@ -2,6 +2,7 @@ class Api::EventsController < ApplicationController skip_before_action :verify_authenticity_token + allow_unauthenticated_access # Skip normal session auth, use DSN auth instead # POST /api/:project_id/events def create @@ -27,8 +28,8 @@ class Api::EventsController < ApplicationController response.headers['X-Sample-Rate'] = current_sampling[:allowed_requests].to_s response.headers['X-Sample-Until'] = current_sampling[:effective_until] - # Check if agent sent a rule version to compare against - client_version = request.headers['X-Rule-Version']&.to_i + # Check if agent sent a rule version in the JSON body to compare against + client_version = event_data.dig('last_rule_sync')&.to_i response_data = { success: true, @@ -40,8 +41,7 @@ class Api::EventsController < ApplicationController if client_version.blank? || client_version != rule_version # Get rules updated since client version if client_version.present? - since_time = Time.at(client_version / 1_000_000, client_version % 1_000_000) - rules = Rule.where("updated_at > ?", since_time).enabled.sync_order + rules = Rule.since(client_version).enabled else # Full sync for new agents rules = Rule.active.sync_order diff --git a/app/controllers/api/rules_controller.rb b/app/controllers/api/rules_controller.rb index 11742ce..77598ec 100644 --- a/app/controllers/api/rules_controller.rb +++ b/app/controllers/api/rules_controller.rb @@ -7,6 +7,7 @@ module Api # These endpoints are kept for administrative/debugging purposes only skip_before_action :verify_authenticity_token + allow_unauthenticated_access # Skip normal session auth, use project key auth instead before_action :authenticate_project! before_action :check_project_enabled @@ -23,8 +24,8 @@ module Api } end - # GET /api/:public_key/rules?since=1730646186272060 - # Incremental sync - returns rules updated since timestamp (microsecond Unix timestamp) + # GET /api/:public_key/rules?since=1730646186 + # Incremental sync - returns rules updated since timestamp (Unix timestamp in seconds) # GET /api/:public_key/rules # Full sync - returns all active rules def index @@ -69,17 +70,14 @@ module Api end def parse_timestamp(timestamp_str) - # Parse microsecond Unix timestamp + # Parse Unix timestamp in seconds unless timestamp_str.match?(/^\d+$/) - raise ArgumentError, "Invalid timestamp format. Expected microsecond Unix timestamp (e.g., 1730646186272060)" + raise ArgumentError, "Invalid timestamp format. Expected Unix timestamp in seconds (e.g., 1730646186)" end - total_microseconds = timestamp_str.to_i - seconds = total_microseconds / 1_000_000 - microseconds = total_microseconds % 1_000_000 - Time.at(seconds, microseconds) + Time.at(timestamp_str.to_i) rescue ArgumentError => e - raise ArgumentError, "Invalid timestamp format: #{e.message}. Use microsecond Unix timestamp (e.g., 1730646186272060)" + raise ArgumentError, "Invalid timestamp format: #{e.message}. Use Unix timestamp in seconds (e.g., 1730646186)" end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index e70156e..b90a4b5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -7,9 +7,13 @@ class ApplicationController < ActionController::Base stale_when_importmap_changes include Pagy::Backend + include Pagy::Frontend + include Pundit::Authorization helper_method :current_user, :user_signed_in?, :current_user_admin?, :current_user_viewer? + rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized + private def current_user @@ -43,4 +47,12 @@ class ApplicationController < ActionController::Base def after_authentication_url session.delete(:return_to_after_authenticating) || root_url end + + def user_not_authorized + if user_signed_in? + redirect_to root_path, alert: "You don't have permission to perform this action." + else + redirect_to new_session_path, alert: "Please sign in to continue." + end + end end diff --git a/app/controllers/network_ranges_controller.rb b/app/controllers/network_ranges_controller.rb new file mode 100644 index 0000000..6ecb8d2 --- /dev/null +++ b/app/controllers/network_ranges_controller.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +# NetworkRangesController - Browse and manage network ranges +# +# Provides interface for viewing, searching, and managing network ranges +# with their intelligence data and associated rules. +class NetworkRangesController < ApplicationController + # Follow proper before_action order: + # 1. Authentication/Authorization + allow_unauthenticated_access only: [:index, :show, :lookup] + + # 2. Resource loading + before_action :set_network_range, only: [:show, :edit, :update, :destroy, :enrich] + before_action :set_project, only: [:index, :show] + + # GET /network_ranges + def index + @pagy, @network_ranges = pagy(policy_scope(NetworkRange.includes(:rules)) + .order(updated_at: :desc)) + + # Apply filters + @network_ranges = apply_filters(@network_ranges) + + # Apply search + if params[:search].present? + @network_ranges = search_network_ranges(@network_ranges, params[:search]) + end + + # Statistics + @total_ranges = NetworkRange.count + @ranges_with_intelligence = NetworkRange.where.not(asn: nil).or(NetworkRange.where.not(company: nil)).count + @datacenter_ranges = NetworkRange.where(is_datacenter: true).count + @vpn_ranges = NetworkRange.where(is_vpn: true).count + @proxy_ranges = NetworkRange.where(is_proxy: true).count + + # Top countries, companies, ASNs + @top_countries = NetworkRange.where.not(country: nil).group(:country).count.sort_by { |_, c| -c }.first(10) + @top_companies = NetworkRange.where.not(company: nil).group(:company).count.sort_by { |_, c| -c }.first(10) + @top_asns = NetworkRange.where.not(asn: nil).group(:asn, :asn_org).count.sort_by { |_, c| -c }.first(10) + end + + # GET /network_ranges/:id + def show + authorize @network_range + @related_events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network") + .where("network_ranges.id = ?", @network_range.id) + .recent + .limit(100) + + @child_ranges = @network_range.child_ranges.limit(20) + @parent_ranges = @network_range.parent_ranges.limit(10) + @associated_rules = @network_range.rules.includes(:user).order(created_at: :desc) + + # Traffic analytics (if we have events) + @traffic_stats = calculate_traffic_stats(@network_range) + end + + # GET /network_ranges/new + def new + authorize NetworkRange + @network_range = NetworkRange.new + end + + # POST /network_ranges + def create + authorize NetworkRange + @network_range = NetworkRange.new(network_range_params) + @network_range.user = Current.user + @network_range.source = 'user_created' + + respond_to do |format| + if @network_range.save + format.html { redirect_to @network_range, notice: 'Network range was successfully created.' } + format.json { render json: @network_range.as_json(only: [:id, :network, :company, :asn, :asn_org, :country, :is_datacenter, :is_vpn, :is_proxy]) } + else + format.html { render :new, status: :unprocessable_entity } + format.json { render json: { error: @network_range.errors.full_messages.join(', ') }, status: :unprocessable_entity } + end + end + end + + # GET /network_ranges/:id/edit + def edit + authorize @network_range + end + + # PATCH/PUT /network_ranges/:id + def update + authorize @network_range + if @network_range.update(network_range_params) + redirect_to @network_range, notice: 'Network range was successfully updated.' + else + render :edit, status: :unprocessable_entity + end + end + + # DELETE /network_ranges/:id + def destroy + authorize @network_range + @network_range.destroy + redirect_to network_ranges_url, notice: 'Network range was successfully deleted.' + end + + # POST /network_ranges/:id/enrich + def enrich + authorize @network_range, :enrich? + # Attempt to enrich this network range with API data + # This would integrate with external IP intelligence services + enrichment_service = NetworkEnrichmentService.new(@network_range) + result = enrichment_service.enrich! + + if result[:success] + redirect_to @network_range, notice: "Network range enriched with #{result[:fields_added]} new fields." + else + redirect_to @network_range, alert: "Failed to enrich network range: #{result[:error]}" + end + end + + # GET /network_ranges/lookup + def lookup + authorize NetworkRange, :lookup? + ip_address = params[:ip] + return render json: { error: 'IP address required' }, status: :bad_request if ip_address.blank? + + @ranges = NetworkRange.contains_ip(ip_address).includes(:rules) + @ip_intelligence = IpRangeResolver.get_ip_intelligence(ip_address) + @suggested_blocks = IpRangeResolver.suggest_blocking_ranges(ip_address) + + render :lookup + end + + # GET /network_ranges/search + def search + authorize NetworkRange, :index? + query = params[:q] + + if query.blank? + render json: [] + return + end + + # Search by network CIDR (cast inet to text for ILIKE), company, ASN org, or country + @network_ranges = NetworkRange.where( + "network::text ILIKE ? OR company ILIKE ? OR asn_org ILIKE ? OR country ILIKE ? OR asn::text ILIKE ?", + "%#{query}%", "%#{query}%", "%#{query}%", "%#{query}%", "%#{query}%" + ).limit(20) + + render json: @network_ranges.as_json( + only: [:id, :network, :company, :asn, :asn_org, :country, :is_datacenter, :is_vpn, :is_proxy] + ) + end + + private + + def set_network_range + # Handle CIDR slugs (e.g., "40.77.167.100_32" -> "40.77.167.100/32") + cidr = params[:id].gsub('_', '/') + @network_range = NetworkRange.find_by!(network: cidr) + end + + def set_project + # For now, use the first project or create a default one + @project = Project.first || Project.create!( + name: 'Default Project', + slug: 'default', + public_key: SecureRandom.hex(32) + ) + end + + def network_range_params + params.require(:network_range).permit( + :network, + :source, + :creation_reason, + :asn, + :asn_org, + :company, + :country, + :is_datacenter, + :is_proxy, + :is_vpn, + :abuser_scores, + :additional_data + ) + end + + def apply_filters(scope) + scope = scope.where(country: params[:country]) if params[:country].present? + scope = scope.where(company: params[:company]) if params[:company].present? + scope = scope.where(asn: params[:asn].to_i) if params[:asn].present? + scope = scope.where(is_datacenter: true) if params[:datacenter] == 'true' + scope = scope.where(is_vpn: true) if params[:vpn] == 'true' + scope = scope.where(is_proxy: true) if params[:proxy] == 'true' + scope = scope.where(source: params[:source]) if params[:source].present? + scope + end + + def search_network_ranges(scope, search_term) + # Search by network CIDR, company, ASN, or country + scope.where( + "network ILIKE ? OR company ILIKE ? OR asn_org ILIKE ? OR country ILIKE ?", + "%#{search_term}%", "%#{search_term}%", "%#{search_term}%", "%#{search_term}%" + ) + end + + def calculate_traffic_stats(network_range) + # Calculate traffic statistics for this network range + events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network") + .where("network_ranges.id = ?", network_range.id) + + { + total_requests: events.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) + } + end +end \ No newline at end of file diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index f95ec78..189ba2d 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,35 +1,24 @@ class PasswordsController < ApplicationController - allow_unauthenticated_access - before_action :set_user_by_token, only: %i[ edit update ] - rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } - - def new - end - - def create - if user = User.find_by(email_address: params[:email_address]) - PasswordsMailer.reset(user).deliver_later - end - - redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." - end + before_action :require_authentication def edit + @user = Current.user end def update - if @user.update(params.permit(:password, :password_confirmation)) - @user.sessions.destroy_all - redirect_to new_session_path, notice: "Password has been reset." + @user = Current.user + + if @user.authenticate(params[:current_password]) + if @user.update(params.permit(:password, :password_confirmation)) + @user.sessions.where.not(id: Current.session.id).destroy_all + redirect_to root_path, notice: "Password updated successfully." + else + flash.now[:alert] = "New password confirmation didn't match." + render :edit, status: :unprocessable_entity + end else - redirect_to edit_password_path(params[:token]), alert: "Passwords did not match." + flash.now[:alert] = "Current password is incorrect." + render :edit, status: :unprocessable_entity end end - - private - def set_user_by_token - @user = User.find_by_password_reset_token!(params[:token]) - rescue ActiveSupport::MessageVerifier::InvalidSignature - redirect_to new_password_path, alert: "Password reset link is invalid or has expired." - end end diff --git a/app/controllers/rule_sets_controller.rb b/app/controllers/rule_sets_controller.rb deleted file mode 100644 index 6d573c1..0000000 --- a/app/controllers/rule_sets_controller.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -class RuleSetsController < ApplicationController - before_action :set_rule_set, only: [:show, :edit, :update, :push_to_agents] - - def index - @rule_sets = RuleSet.includes(:rules).by_priority - end - - def show - @rules = @rule_set.rules.includes(:rule_set).by_priority - end - - def new - @rule_set = RuleSet.new - end - - def create - @rule_set = RuleSet.new(rule_set_params) - - if @rule_set.save - redirect_to @rule_set, notice: "Rule set was successfully created." - else - render :new, status: :unprocessable_entity - end - end - - def edit - end - - def update - if @rule_set.update(rule_set_params) - redirect_to @rule_set, notice: "Rule set was successfully updated." - else - render :edit, status: :unprocessable_entity - end - end - - def push_to_agents - @rule_set.push_to_agents! - redirect_to @rule_set, notice: "Rule set pushed to agents successfully." - end - - private - - def set_rule_set - @rule_set = RuleSet.find_by(slug: params[:id]) || RuleSet.find(params[:id]) - end - - def rule_set_params - params.require(:rule_set).permit(:name, :description, :enabled, :priority) - end -end \ No newline at end of file diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index e02d4db..edb81f2 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -1,29 +1,58 @@ # frozen_string_literal: true class RulesController < ApplicationController + # Follow proper before_action order: + # 1. Authentication/Authorization + allow_unauthenticated_access only: [:index, :show] + + # 2. Resource loading before_action :set_rule, only: [:show, :edit, :update, :disable, :enable] - before_action :authorize_rule + before_action :set_project, only: [:index, :show] # GET /rules def index - @rules = Rule.includes(:project).order(created_at: :desc) + @rules = policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc) @rule_types = Rule::RULE_TYPES @actions = Rule::ACTIONS end # GET /rules/new def new + authorize Rule @rule = Rule.new + + # Pre-fill from URL parameters + if params[:network_range_id].present? + network_range = NetworkRange.find_by(id: params[:network_range_id]) + @rule.network_range = network_range if network_range + end + + if params[:cidr].present? + @rule.rule_type = 'network' + end + @rule_types = Rule::RULE_TYPES @actions = Rule::ACTIONS end # POST /rules def create + authorize Rule @rule = Rule.new(rule_params) + @rule.user = Current.user @rule_types = Rule::RULE_TYPES @actions = Rule::ACTIONS + # Handle network range creation if CIDR is provided + if params[:cidr].present? && @rule.network_rule? + network_range = NetworkRange.find_or_create_by(cidr: params[:cidr]) do |range| + range.user = Current.user + range.source = 'manual' + range.creation_reason = "Created for rule ##{@rule.id}" + end + @rule.network_range = network_range + end + if @rule.save redirect_to @rule, notice: 'Rule was successfully created.' else @@ -33,16 +62,19 @@ class RulesController < ApplicationController # GET /rules/:id def show + authorize @rule end # GET /rules/:id/edit def edit + authorize @rule @rule_types = Rule::RULE_TYPES @actions = Rule::ACTIONS end # PATCH/PUT /rules/:id def update + authorize @rule if @rule.update(rule_params) redirect_to @rule, notice: 'Rule was successfully updated.' else @@ -52,6 +84,7 @@ class RulesController < ApplicationController # POST /rules/:id/disable def disable + authorize @rule, :disable? reason = params[:reason] || "Disabled manually" @rule.disable!(reason: reason) redirect_to @rule, notice: 'Rule was successfully disabled.' @@ -59,6 +92,7 @@ class RulesController < ApplicationController # POST /rules/:id/enable def enable + authorize @rule, :enable? @rule.enable! redirect_to @rule, notice: 'Rule was successfully enabled.' end @@ -69,20 +103,32 @@ class RulesController < ApplicationController @rule = Rule.find(params[:id]) end - def authorize_rule - # Add authorization logic here if needed - # For now, allow all authenticated users + def rule_params + permitted = [ + :rule_type, + :action, + :metadata, + :expires_at, + :enabled, + :source, + :network_range_id + ] + + # Only include conditions for non-network rules + if params[:rule][:rule_type] != 'network' + permitted << :conditions end - def rule_params - params.require(:rule).permit( - :rule_type, - :action, - :conditions, - :metadata, - :expires_at, - :enabled, - :source + params.require(:rule).permit(permitted) +end + + def set_project + # For now, use the first project or create a default one + @project = Project.first || Project.create!( + name: 'Default Project', + slug: 'default', + public_key: SecureRandom.hex(32) ) end -end \ No newline at end of file + + end \ No newline at end of file diff --git a/app/javascript/controllers/json_validator_controller.js b/app/javascript/controllers/json_validator_controller.js new file mode 100644 index 0000000..e3ad2f4 --- /dev/null +++ b/app/javascript/controllers/json_validator_controller.js @@ -0,0 +1,81 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["textarea", "status"] + static classes = ["valid", "invalid", "validStatus", "invalidStatus"] + + connect() { + this.validate() + } + + validate() { + const value = this.textareaTarget.value.trim() + + if (!value) { + this.clearStatus() + return true + } + + try { + JSON.parse(value) + this.showValid() + return true + } catch (error) { + this.showInvalid(error.message) + return false + } + } + + format() { + const value = this.textareaTarget.value.trim() + + if (!value) return + + try { + const parsed = JSON.parse(value) + const formatted = JSON.stringify(parsed, null, 2) + this.textareaTarget.value = formatted + this.showValid() + } catch (error) { + this.showInvalid(error.message) + } + } + + clearStatus() { + this.textareaTarget.classList.remove(...this.invalidClasses) + this.textareaTarget.classList.remove(...this.validClasses) + if (this.hasStatusTarget) { + this.statusTarget.textContent = "" + this.statusTarget.classList.remove(...this.validStatusClasses, ...this.invalidStatusClasses) + } + } + + showValid() { + this.textareaTarget.classList.remove(...this.invalidClasses) + this.textareaTarget.classList.add(...this.validClasses) + if (this.hasStatusTarget) { + this.statusTarget.textContent = "✓ Valid JSON" + this.statusTarget.classList.remove(...this.invalidStatusClasses) + this.statusTarget.classList.add(...this.validStatusClasses) + } + } + + showInvalid(errorMessage) { + this.textareaTarget.classList.remove(...this.validClasses) + this.textareaTarget.classList.add(...this.invalidClasses) + if (this.hasStatusTarget) { + this.statusTarget.textContent = `✗ Invalid JSON: ${errorMessage}` + this.statusTarget.classList.remove(...this.validStatusClasses) + this.statusTarget.classList.add(...this.invalidStatusClasses) + } + } + + insertSample(event) { + event.preventDefault() + const sample = event.params.json || event.target.dataset.jsonSample + if (sample) { + this.textareaTarget.value = sample + this.format() + } + } +} \ No newline at end of file diff --git a/app/mailers/passwords_mailer.rb b/app/mailers/passwords_mailer.rb deleted file mode 100644 index 4f0ac7f..0000000 --- a/app/mailers/passwords_mailer.rb +++ /dev/null @@ -1,6 +0,0 @@ -class PasswordsMailer < ApplicationMailer - def reset(user) - @user = user - mail subject: "Reset your password", to: user.email_address - end -end diff --git a/app/models/current.rb b/app/models/current.rb index 2bef56d..f8d1516 100644 --- a/app/models/current.rb +++ b/app/models/current.rb @@ -1,4 +1,6 @@ class Current < ActiveSupport::CurrentAttributes attribute :session + attribute :baffle_host + attribute :baffle_internal_host delegate :user, to: :session, allow_nil: true end diff --git a/app/models/event.rb b/app/models/event.rb index 37a64ea..eab5e43 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -268,11 +268,114 @@ class Event < ApplicationRecord headers.transform_keys(&:downcase) end - # GeoIP enrichment methods + # Network range resolution methods + def matching_network_ranges + return [] unless ip_address.present? + + NetworkRange.contains_ip(ip_address).map do |range| + { + range: range, + cidr: range.cidr, + prefix_length: range.prefix_length, + specificity: range.prefix_length, + intelligence: range.inherited_intelligence + } + end.sort_by { |r| -r[:specificity] } # Most specific first + end + + def most_specific_range + matching_network_ranges.first + end + + def broadest_range + matching_network_ranges.last + end + + def network_intelligence + most_specific_range&.dig(:intelligence) || {} + end + + def company + network_intelligence[:company] + end + + def asn + network_intelligence[:asn] + end + + def asn_org + network_intelligence[:asn_org] + end + + def is_datacenter? + network_intelligence[:is_datacenter] || false + end + + def is_proxy? + network_intelligence[:is_proxy] || false + end + + def is_vpn? + network_intelligence[:is_vpn] || false + end + + # IP validation + def valid_ipv4? + return false unless ip_address.present? + + IPAddr.new(ip_address).ipv4? + rescue IPAddr::InvalidAddressError + false + end + + def valid_ipv6? + return false unless ip_address.present? + + IPAddr.new(ip_address).ipv6? + rescue IPAddr::InvalidAddressError + false + end + + def valid_ip? + valid_ipv4? || valid_ipv6? + end + + # Rules affecting this IP + def matching_rules + return Rule.none unless ip_address.present? + + # Get all network ranges that contain this IP + range_ids = matching_network_ranges.map { |r| r[:range].id } + + # Find rules for those ranges, ordered by priority (most specific first) + Rule.network_rules + .where(network_range_id: range_ids) + .enabled + .includes(:network_range) + .order('masklen(network_ranges.network) DESC') + end + + def active_blocking_rules + matching_rules.where(action: 'deny') + end + + def has_blocking_rules? + active_blocking_rules.exists? + end + + # GeoIP enrichment methods (now uses network range data when available) def enrich_geo_location! return if ip_address.blank? return if country_code.present? # Already has geo data + # First try to get from network range + network_info = network_intelligence + if network_info[:country].present? + update!(country_code: network_info[:country]) + return + end + + # Fallback to direct lookup country = GeoIpService.lookup_country(ip_address) update!(country_code: country) if country.present? rescue => e @@ -282,13 +385,21 @@ class Event < ApplicationRecord # Class method to enrich multiple events def self.enrich_geo_location_batch(events = nil) events ||= where(country_code: [nil, '']).where.not(ip_address: [nil, '']) - geo_service = GeoIpService.new updated_count = 0 events.find_each do |event| next if event.country_code.present? - country = geo_service.lookup_country(event.ip_address) + # Try network range first + network_info = event.network_intelligence + if network_info[:country].present? + event.update!(country_code: network_info[:country]) + updated_count += 1 + next + end + + # Fallback to direct lookup + country = GeoIpService.lookup_country(event.ip_address) if country.present? event.update!(country_code: country) updated_count += 1 @@ -303,6 +414,11 @@ class Event < ApplicationRecord return country_code if country_code.present? return nil if ip_address.blank? + # First try network range + network_info = network_intelligence + return network_info[:country] if network_info[:country].present? + + # Fallback to direct lookup GeoIpService.lookup_country(ip_address) rescue => e Rails.logger.error "GeoIP lookup failed for #{ip_address}: #{e.message}" @@ -311,16 +427,19 @@ class Event < ApplicationRecord # Check if event has valid geo location data def has_geo_data? - country_code.present? || city.present? + country_code.present? || city.present? || network_intelligence[:country].present? end # Get full geo location details def geo_location + network_info = network_intelligence + { - country_code: country_code, + country_code: country_code || network_info[:country], city: city, ip_address: ip_address, - has_data: has_geo_data? + has_data: has_geo_data?, + network_intelligence: network_info } end diff --git a/app/models/network_range.rb b/app/models/network_range.rb new file mode 100644 index 0000000..96c21b9 --- /dev/null +++ b/app/models/network_range.rb @@ -0,0 +1,290 @@ +# frozen_string_literal: true + +# NetworkRange - Unified IPv4/IPv6 network range management +# +# Uses PostgreSQL's inet type to handle both IPv4 and IPv4 networks seamlessly. +# Provides network intelligence data including ASN, company, geographic info, +# and classification flags (datacenter, proxy, VPN). +class NetworkRange < ApplicationRecord + # Sources for network range creation + SOURCES = %w[api_imported user_created manual auto_generated inherited].freeze + + # Associations + has_many :rules, dependent: :destroy + belongs_to :user, optional: true + + # Validations + validates :network, presence: true, uniqueness: true + validates :source, inclusion: { in: SOURCES } + validates :asn, numericality: { greater_than: 0 }, allow_blank: true + + # Scopes + scope :ipv4, -> { where("family(network) = 4") } + scope :ipv6, -> { where("family(network) = 6") } + scope :by_country, ->(country) { where(country: country) } + scope :by_company, ->(company) { where(company: company) } + scope :by_asn, ->(asn) { where(asn: asn) } + scope :datacenter, -> { where(is_datacenter: true) } + scope :proxy, -> { where(is_proxy: true) } + scope :vpn, -> { where(is_vpn: true) } + scope :user_created, -> { where(source: 'user_created') } + scope :api_imported, -> { where(source: 'api_imported') } + + # Callbacks + before_validation :set_default_source + # after_save :update_children_inheritance!, if: :should_update_children_inheritance? # Disabled for now + + # Virtual attribute for CIDR notation + def cidr + network.to_s + end + + def cidr=(new_cidr) + self.network = new_cidr + end + + # Network properties + def prefix_length + # Get prefix length from IPAddr object + network.prefix + end + + def network_address + # Use PostgreSQL's host function or get from IPAddr object + network.to_s + end + + def cidr + # Return full CIDR notation + "#{network_address}/#{prefix_length}" + end + + def broadcast_address + # Use PostgreSQL's broadcast function + result = self.class.connection.execute("SELECT broadcast('#{network.to_s}')").first + result&.values&.first + end + + def family + # Check if it's IPv4 or IPv6 by looking at the address + addr = network.to_s.split('/').first + addr.include?(':') ? 6 : 4 + end + + def ipv4? + family == 4 + end + + def ipv6? + family == 6 + end + + # Network containment and overlap operations + def contains_ip?(ip_string) + # Use Postgres >>= operator for containment + self.class.where("network >>= ?::inet", ip_string).exists? + rescue => e + Rails.logger.error "Error checking IP containment: #{e.message}" + false + end + + def contains_network?(other_cidr) + other_network = IPAddr.new(other_cidr) + network_range = IPAddr.new(network) + network_range.include?(other_network) + rescue IPAddr::InvalidAddressError + false + end + + def overlaps?(other_cidr) + network_range = IPAddr.new(network) + other_network = IPAddr.new(other_cidr) + network_range.include?(other_network) || other_network.include?(network_range) + rescue IPAddr::InvalidAddressError + false + end + + # Parent/child relationships + def parent_ranges + NetworkRange.where("network << ?::inet AND masklen(network) < ?", network.to_s, prefix_length) + .order("masklen(network) DESC") + end + + def child_ranges + NetworkRange.where("network >> ?::inet AND masklen(network) > ?", network.to_s, prefix_length) + .order("masklen(network) ASC") + end + + def sibling_ranges + NetworkRange.where("masklen(network) = ?", prefix_length) + .where("network && ?::inet", network.to_s) + .where.not(id: id) + end + + # Find nearest parent with intelligence data + def parent_with_intelligence + # Use Postgres network operators to find parent ranges directly + cidr_str = network.to_s + if cidr_str.include?('/') + addr_parts = network_address.split('.') + case addr_parts.length + when 4 # IPv4 + new_prefix = [prefix_length - 8, 16].max + parent_cidr = "#{addr_parts[0]}.#{addr_parts[1]}.#{addr_parts[2]}.0/#{new_prefix}" + else # IPv6 - skip for now + nil + end + else + nil + end + + return nil unless parent_cidr + + NetworkRange.where("network <<= ?::inet AND masklen(network) < ?", parent_cidr, prefix_length) + .where.not(asn: nil) + .order("masklen(network) DESC") + .first + end + + def inherited_intelligence + return own_intelligence if has_intelligence? + + parent = parent_with_intelligence + parent ? parent.own_intelligence.merge(inherited: true, parent_cidr: parent.cidr) : {} + end + + def has_intelligence? + asn.present? || company.present? || country.present? || + is_datacenter? || is_proxy? || is_vpn? + end + + def own_intelligence + { + asn: asn, + asn_org: asn_org, + company: company, + country: country, + is_datacenter: is_datacenter, + is_proxy: is_proxy, + is_vpn: is_vpn, + inherited: false, + source: source + } + end + + # Geographic lookup + def geo_lookup_country! + return if country.present? + + sample_ip = network_address + geo_country = GeoIpService.lookup_country(sample_ip) + update!(country: geo_country) if geo_country.present? + rescue => e + Rails.logger.error "Failed to lookup geo location for network range #{cidr}: #{e.message}" + end + + # Class methods for network operations + def self.contains_ip(ip_string) + where("network >>= ?", ip_string) + .order("masklen(network) DESC") # Most specific first + end + + def self.overlapping(range_cidr) + where("network && ?", range_cidr) + end + + def self.find_or_create_by_cidr(cidr, user: nil, source: nil, reason: nil) + find_or_create_by(network: cidr) do |range| + range.user = user + range.source = source || 'user_created' + range.creation_reason = reason + end + end + + def self.import_from_cidr(cidr, **attributes) + find_or_create_by(network: cidr) do |range| + range.assign_attributes(attributes) + end + end + + # Convenience methods for JSON fields + def abuser_scores_hash + abuser_scores ? JSON.parse(abuser_scores) : {} + rescue JSON::ParserError + {} + end + + def abuser_scores_hash=(hash) + self.abuser_scores = hash.to_json + end + + def additional_data_hash + additional_data ? JSON.parse(additional_data) : {} + rescue JSON::ParserError + {} + end + + def additional_data_hash=(hash) + self.additional_data = hash.to_json + end + + # String representations + def to_s + cidr + end + + def to_param + cidr.to_s.gsub('/', '_') + end + + # Analytics methods + def events_count + Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address]).count + end + + def recent_events(limit: 100) + Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address]) + .recent + .limit(limit) + end + + def blocking_rules + rules.where(action: 'deny', enabled: true) + end + + def active_rules + rules.enabled.where("expires_at IS NULL OR expires_at > ?", Time.current) + end + + private + + def set_default_source + self.source ||= 'api_imported' + end + + def should_update_children_inheritance? + saved_change_to_attribute?(:asn) || + saved_change_to_attribute?(:company) || + saved_change_to_attribute?(:country) || + saved_change_to_attribute?(:is_datacenter) || + saved_change_to_attribute?(:is_proxy) || + saved_change_to_attribute?(:is_vpn) + end + + def update_children_inheritance! + # Find child ranges that don't have their own intelligence + child_without_intelligence = child_ranges.where( + asn: nil, + company: nil, + country: nil, + is_datacenter: false, + is_proxy: false, + is_vpn: false + ) + + child_without_intelligence.find_each do |child| + Rails.logger.info "Child range #{child.cidr} can now inherit from parent #{cidr}" + # The inherited_intelligence method will pick up the new parent data + end + end +end \ No newline at end of file diff --git a/app/models/rule.rb b/app/models/rule.rb index bde2876..3d9a550 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -1,20 +1,31 @@ # frozen_string_literal: true +# Rule - WAF rule management with NetworkRange integration +# +# Rules define actions to take for matching traffic conditions. +# Network rules are associated with NetworkRange objects for rich context. class Rule < ApplicationRecord - # Rule types for the new architecture - RULE_TYPES = %w[network_v4 network_v6 rate_limit path_pattern].freeze + # Rule types and actions + RULE_TYPES = %w[network rate_limit path_pattern].freeze ACTIONS = %w[allow deny rate_limit redirect log].freeze - SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default].freeze + SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default manual:surgical_block manual:surgical_exception].freeze + + # Associations + belongs_to :user + belongs_to :network_range, optional: true # Validations validates :rule_type, presence: true, inclusion: { in: RULE_TYPES } validates :action, presence: true, inclusion: { in: ACTIONS } - validates :conditions, presence: true + validates :conditions, presence: true, unless: :network_rule? validates :enabled, inclusion: { in: [true, false] } + validates :source, inclusion: { in: SOURCES } - # Custom validations based on rule type + # Custom validations validate :validate_conditions_by_type validate :validate_metadata_by_action + validate :network_range_required_for_network_rules + validate :validate_network_consistency, if: :network_rule? # Scopes scope :enabled, -> { where(enabled: true) } @@ -22,20 +33,80 @@ class Rule < ApplicationRecord scope :active, -> { enabled.where("expires_at IS NULL OR expires_at > ?", Time.current) } scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) } scope :by_type, ->(type) { where(rule_type: type) } - scope :network_rules, -> { where(rule_type: ["network_v4", "network_v6"]) } + scope :network_rules, -> { where(rule_type: "network") } scope :rate_limit_rules, -> { where(rule_type: "rate_limit") } scope :path_pattern_rules, -> { where(rule_type: "path_pattern") } scope :by_source, ->(source) { where(source: source) } + scope :surgical_blocks, -> { where(source: "manual:surgical_block") } + scope :surgical_exceptions, -> { where(source: "manual:surgical_exception") } - # Sync queries (ordered by updated_at for incremental sync) - scope :since, ->(timestamp) { where("updated_at >= ?", timestamp - 0.5.seconds).order(:updated_at, :id) } + # Sync queries + scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) } scope :sync_order, -> { order(:updated_at, :id) } # Callbacks before_validation :set_defaults - before_save :calculate_priority_from_cidr + before_validation :parse_json_fields + before_save :calculate_priority_for_network_rules - # Check if rule is currently active + # Rule type checks + def network_rule? + rule_type == "network" + end + + def rate_limit_rule? + rule_type == "rate_limit" + end + + def path_pattern_rule? + rule_type == "path_pattern" + end + + # Network-specific methods + def cidr + network_rule? ? network_range&.cidr : conditions&.dig("cidr") + end + + def prefix_length + network_rule? ? network_range&.prefix_length : cidr&.split("/")&.last&.to_i + end + + def network_intelligence + return {} unless network_rule? && network_range + + network_range.inherited_intelligence + end + + def network_address + network_rule? ? network_range&.network_address : nil + end + + # Surgical block methods + def surgical_block? + source == "manual:surgical_block" + end + + def surgical_exception? + source == "manual:surgical_exception" + end + + def related_surgical_rules + if surgical_block? + # Find the corresponding exception rule + surgical_exceptions.where( + conditions: { cidr: network_address ? "#{network_address}/32" : nil } + ) + elsif surgical_exception? + # Find the parent block rule + surgical_blocks.joins(:network_range).where( + network_ranges: { network: parent_cidr } + ) + else + Rule.none + end + end + + # Rule lifecycle def active? enabled? && !expired? end @@ -44,14 +115,37 @@ class Rule < ApplicationRecord expires_at.present? && expires_at <= Time.current end - # Convert to format for agent consumption + def activate! + update!(enabled: true) + end + + def deactivate! + update!(enabled: false) + end + + def disable!(reason: nil) + update!( + enabled: false, + metadata: metadata.merge( + disabled_at: Time.current.iso8601, + disabled_reason: reason + ) + ) + end + + def extend_expiry!(duration) + new_expiry = Time.current + duration + update!(expires_at: new_expiry) + end + + # Agent serialization def to_agent_format - { + format = { id: id, rule_type: rule_type, action: action, - conditions: conditions || {}, - priority: priority, + conditions: agent_conditions, + priority: agent_priority, expires_at: expires_at&.iso8601, enabled: enabled, source: source, @@ -59,50 +153,118 @@ class Rule < ApplicationRecord created_at: created_at.iso8601, updated_at: updated_at.iso8601 } - end - # Class method to get latest version (for sync cursor) - # Returns microsecond Unix timestamp for efficient machine comparison - def self.latest_version - max_time = maximum(:updated_at) - if max_time - # Convert to microseconds since epoch - (max_time.to_f * 1_000_000).to_i - else - (Time.current.to_f * 1_000_000).to_i + # Add network intelligence for debugging (optional) + if network_rule? && network_range + format[:network_intelligence] = network_intelligence end + + format end - # Disable rule (soft delete) - def disable!(reason: nil) - update!( - enabled: false, - metadata: (metadata || {}).merge( - disabled_at: Time.current.iso8601, - disabled_reason: reason - ) + # Class methods for rule creation + def self.create_network_rule(cidr, action: 'deny', user: nil, **options) + network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created') + + create!( + rule_type: 'network', + action: action, + network_range: network_range, + user: user, + **options ) end - # Enable rule - def enable! - update!(enabled: true) + def self.create_surgical_block(ip_address, parent_cidr, user: nil, reason: nil, **options) + # Create block rule for parent range + network_range = NetworkRange.find_or_create_by_cidr(parent_cidr, user: user, source: 'user_created') + + block_rule = create!( + rule_type: 'network', + action: 'deny', + network_range: network_range, + source: 'manual:surgical_block', + user: user, + metadata: { + reason: reason, + surgical_block: true, + original_ip: ip_address, + **options[:metadata] + }, + **options.except(:metadata) + ) + + # Create exception rule for specific IP + ip_network_range = NetworkRange.find_or_create_by_cidr("#{ip_address}/#{ip_address.include?(':') ? '128' : '32'}", user: user, source: 'user_created') + + exception_rule = create!( + rule_type: 'network', + action: 'allow', + network_range: ip_network_range, + source: 'manual:surgical_exception', + user: user, + priority: ip_network_range.prefix_length, # Higher priority = more specific + metadata: { + reason: "Exception for #{ip_address} in surgical block of #{parent_cidr}", + surgical_exception: true, + parent_rule_id: block_rule.id, + **options[:metadata] + }, + **options.except(:metadata) + ) + + [block_rule, exception_rule] end - # Check if this is a network rule - def network_rule? - rule_type.in?(%w[network_v4 network_v6]) + def self.create_rate_limit_rule(cidr, limit:, window:, user: nil, **options) + network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created') + + create!( + rule_type: 'rate_limit', + action: 'rate_limit', + network_range: network_range, + conditions: { cidr: cidr, scope: 'ip' }, + metadata: { + limit: limit, + window: window, + **options[:metadata] + }, + user: user, + **options.except(:metadata) + ) end - # Get CIDR from conditions (for network rules) - def cidr - conditions&.dig("cidr") if network_rule? + # Sync and versioning + def self.latest_version + max_time = maximum(:updated_at) + max_time ? max_time.to_i : Time.current.to_i end - # Get prefix length from CIDR - def prefix_length - return nil unless cidr - cidr.split("/").last.to_i + def self.active_for_agent + active.sync_order.map(&:to_agent_format) + end + + # Analytics methods + def matching_events(limit: 100) + return Event.none unless network_rule? && network_range + + # This would need efficient IP range queries + # For now, simple IP match + Event.where(ip_address: network_range.network_address) + .recent + .limit(limit) + end + + def effectiveness_stats + return {} unless network_rule? + + events = matching_events + { + total_events: events.count, + blocked_events: events.blocked.count, + allowed_events: events.allowed.count, + block_rate: events.count > 0 ? (events.blocked.count.to_f / events.count * 100).round(2) : 0 + } end private @@ -112,19 +274,40 @@ class Rule < ApplicationRecord self.conditions ||= {} self.metadata ||= {} self.source ||= "manual" + + # Set system user for auto-generated rules if no user is set + if source&.start_with?('auto:') || source == 'default' + self.user ||= User.find_by(role: 1) # admin role + end end - def calculate_priority_from_cidr - # For network rules, priority is the prefix length (more specific = higher priority) - if network_rule? && cidr.present? - self.priority = prefix_length + def calculate_priority_for_network_rules + if network_rule? && network_range + self.priority = network_range.prefix_length + end + end + + def agent_conditions + if network_rule? + { cidr: cidr } + else + conditions || {} + end + end + + def agent_priority + if network_rule? + prefix_length || 0 + else + priority || 0 end end def validate_conditions_by_type case rule_type - when "network_v4", "network_v6" - validate_network_conditions + when "network" + # Network rules don't need conditions in DB - stored in network_range + true when "rate_limit" validate_rate_limit_conditions when "path_pattern" @@ -132,29 +315,6 @@ class Rule < ApplicationRecord end end - def validate_network_conditions - cidr_value = conditions&.dig("cidr") - - if cidr_value.blank? - errors.add(:conditions, "must include 'cidr' for network rules") - return - end - - # Validate CIDR format - begin - addr = IPAddr.new(cidr_value) - - # Check IPv4 vs IPv6 matches rule_type - if rule_type == "network_v4" && !addr.ipv4? - errors.add(:conditions, "cidr must be IPv4 for network_v4 rules") - elsif rule_type == "network_v6" && !addr.ipv6? - errors.add(:conditions, "cidr must be IPv6 for network_v6 rules") - end - rescue IPAddr::InvalidAddressError => e - errors.add(:conditions, "invalid CIDR format: #{e.message}") - end - end - def validate_rate_limit_conditions scope = conditions&.dig("scope") cidr_value = conditions&.dig("cidr") @@ -163,11 +323,6 @@ class Rule < ApplicationRecord errors.add(:conditions, "must include 'scope' for rate_limit rules") end - if cidr_value.blank? - errors.add(:conditions, "must include 'cidr' for rate_limit rules") - end - - # Validate metadata has rate limit config unless metadata&.dig("limit").present? && metadata&.dig("window").present? errors.add(:metadata, "must include 'limit' and 'window' for rate_limit rules") end @@ -193,4 +348,50 @@ class Rule < ApplicationRecord end end end -end + + def network_range_required_for_network_rules + if network_rule? && network_range.nil? + errors.add(:network_range, "is required for network rules") + end + end + + def validate_network_consistency + return unless network_rule? && network_range + + # For network rules, we don't use conditions - the network_range handles everything + # So we can skip this validation for now + true + end + + def parent_cidr + return nil unless network_range + + # Find a broader network range that contains this one + network_range.parent_ranges.first&.cidr + end + + def parse_json_fields + # Parse conditions if it's a string + if conditions.is_a?(String) && conditions.present? + begin + self.conditions = JSON.parse(conditions) if conditions != "{}" + rescue JSON::ParserError + self.conditions = {} + end + end + + # Parse metadata if it's a string + if metadata.is_a?(String) && metadata.present? + begin + self.metadata = JSON.parse(metadata) if metadata != "{}" + rescue JSON::ParserError + self.metadata = {} + end + end + + # Ensure they are hashes + self.conditions ||= {} + self.metadata ||= {} + end + + end \ No newline at end of file diff --git a/app/models/rule_set.rb b/app/models/rule_set.rb deleted file mode 100644 index 2dde8ad..0000000 --- a/app/models/rule_set.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -class RuleSet < ApplicationRecord - has_many :rules, dependent: :destroy - - validates :name, presence: true, uniqueness: true - validates :slug, presence: true, uniqueness: true - - scope :enabled, -> { where(enabled: true) } - scope :by_priority, -> { order(priority: :desc, created_at: :desc) } - - before_validation :generate_slug, if: :name? - before_validation :set_default_values - - # Rule Types - RULE_TYPES = %w[ip cidr path user_agent parameter method rate_limit country].freeze - ACTIONS = %w[allow deny challenge rate_limit].freeze - - def to_waf_rules - return [] unless enabled? - - rules.enabled.by_priority.map do |rule| - { - id: rule.id, - type: rule.rule_type, - target: rule.target, - action: rule.action, - conditions: rule.conditions, - priority: rule.priority, - expires_at: rule.expires_at - } - end - end - - def add_rule(rule_type, target, action, conditions: {}, expires_at: nil, priority: 100) - rules.create!( - rule_type: rule_type, - target: target, - action: action, - conditions: conditions, - expires_at: expires_at, - priority: priority - ) - end - - def remove_rule(rule_id) - rules.find(rule_id).destroy - end - - def block_ip(ip_address, expires_at: nil, reason: nil) - add_rule('ip', ip_address, 'deny', expires_at: expires_at, priority: 1000) - end - - def allow_ip(ip_address, expires_at: nil) - add_rule('ip', ip_address, 'allow', expires_at: expires_at, priority: 1000) - end - - def block_cidr(cidr, expires_at: nil, reason: nil) - add_rule('cidr', cidr, 'deny', expires_at: expires_at, priority: 900) - end - - def block_path(path, conditions: {}, expires_at: nil) - add_rule('path', path, 'deny', conditions: conditions, expires_at: expires_at, priority: 500) - end - - def block_user_agent(user_agent_pattern, expires_at: nil) - add_rule('user_agent', user_agent_pattern, 'deny', expires_at: expires_at, priority: 600) - end - - def push_to_agents! - # This would integrate with the agent distribution system - Rails.logger.info "Pushing rule set '#{name}' with #{rules.count} rules to agents" - - # Broadcast update to connected projects - projects = Project.where(id: projects_subscription || []) - projects.each(&:broadcast_rules_refresh) - end - - def active_projects - return Project.none unless projects_subscription.present? - - Project.where(id: projects_subscription).enabled - end - - def subscribe_project(project) - subscriptions = projects_subscription || [] - subscriptions << project.id unless subscriptions.include?(project.id) - update(projects_subscription: subscriptions.uniq) - end - - def unsubscribe_project(project) - subscriptions = projects_subscription || [] - subscriptions.delete(project.id) - update(projects_subscription: subscriptions) - end - - private - - def generate_slug - self.slug = name&.parameterize&.downcase - end - - def set_default_values - self.enabled = true if enabled.nil? - self.priority = 100 if priority.nil? - self.projects_subscription = [] if projects_subscription.nil? - end -end diff --git a/app/models/user.rb b/app/models/user.rb index b38dc54..78a7381 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -6,6 +6,10 @@ class User < ApplicationRecord enum :role, { admin: 0, user: 1, viewer: 2 }, default: :user + generates_token_for :password_reset, expires_in: 1.hour do + updated_at + end + validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :role, presence: true @@ -18,13 +22,18 @@ class User < ApplicationRecord user = find_or_initialize_by(email_address: email) - # Map OIDC groups to role + # Map OIDC groups to role for new users or update existing user's role if auth_hash.dig('extra', 'raw_info', 'groups') user.role = map_oidc_groups_to_role(auth_hash.dig('extra', 'raw_info', 'groups')) end - # Don't override password for OIDC users - user.save!(validate: false) if user.new_record? + # For OIDC users, set a random password if they don't have one + if user.new_record? && !user.password_digest? + user.password = SecureRandom.hex(32) # OIDC users won't use this + end + + # Save the user (skip password validation for OIDC users) + user.save!(validate: false) if user.changed? user end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb new file mode 100644 index 0000000..94cdd48 --- /dev/null +++ b/app/policies/application_policy.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def index? + false + end + + def show? + false + end + + def create? + false + end + + def new? + create? + end + + def update? + false + end + + def edit? + update? + end + + def destroy? + false + end + + private + + def current_user + @user || Current.user + end + + class Scope + def initialize(user, scope) + @user = user + @scope = scope + end + + def resolve + raise NoMethodError, "You must define #resolve in #{self.class}" + end + + private + + attr_reader :user, :scope + end +end diff --git a/app/policies/network_range_policy.rb b/app/policies/network_range_policy.rb new file mode 100644 index 0000000..36bb263 --- /dev/null +++ b/app/policies/network_range_policy.rb @@ -0,0 +1,62 @@ +class NetworkRangePolicy < ApplicationPolicy + # NOTE: Up to Pundit v2.3.1, the inheritance was declared as + # `Scope < Scope` rather than `Scope < ApplicationPolicy::Scope`. + # In most cases the behavior will be identical, but if updating existing + # code, beware of possible changes to the ancestors: + # https://gist.github.com/Burgestrand/4b4bc22f31c8a95c425fc0e30d7ef1f5 + + def index? + true # Anyone can browse network ranges + end + + def show? + true # Anyone can view network range details + end + + def lookup? + true # Anyone can lookup IP addresses + end + + def new? + current_user.present? # Must be authenticated to create network ranges + end + + def create? + current_user.present? # Must be authenticated to create network ranges + end + + def edit? + return false unless current_user.present? + return true if current_user.admin? + + # Users can edit their own network ranges + record.user == current_user + end + + def update? + return false unless current_user.present? + return true if current_user.admin? + + # Users can update their own network ranges + record.user == current_user + end + + def destroy? + return false unless current_user.present? + return true if current_user.admin? + + # Users can delete their own network ranges + record.user == current_user + end + + def enrich? + update? # Same permissions as update + end + + class Scope < ApplicationPolicy::Scope + def resolve + # All users can see all network ranges + scope.all + end + end +end diff --git a/app/policies/rule_policy.rb b/app/policies/rule_policy.rb new file mode 100644 index 0000000..f93f032 --- /dev/null +++ b/app/policies/rule_policy.rb @@ -0,0 +1,62 @@ +class RulePolicy < ApplicationPolicy + # NOTE: Up to Pundit v2.3.1, the inheritance was declared as + # `Scope < Scope` rather than `Scope < ApplicationPolicy::Scope`. + # In most cases the behavior will be identical, but if updating existing + # code, beware of possible changes to the ancestors: + # https://gist.github.com/Burgestrand/4b4bc22f31c8a95c425fc0e30d7ef1f5 + + def index? + true # Anyone can browse rules + end + + def show? + true # Anyone can view rule details + end + + def new? + current_user.present? # Must be authenticated to create rules + end + + def create? + current_user.present? # Must be authenticated to create rules + end + + def edit? + return false unless current_user.present? + return true if current_user.admin? + + # Users can edit their own rules + record.user == current_user + end + + def update? + return false unless current_user.present? + return true if current_user.admin? + + # Users can update their own rules + record.user == current_user + end + + def destroy? + return false unless current_user.present? + return true if current_user.admin? + + # Users can delete their own rules + record.user == current_user + end + + def enable? + update? + end + + def disable? + update? + end + + class Scope < ApplicationPolicy::Scope + def resolve + # All users can see all rules + scope.all + end + end +end diff --git a/app/services/ip_range_resolver.rb b/app/services/ip_range_resolver.rb new file mode 100644 index 0000000..f949b81 --- /dev/null +++ b/app/services/ip_range_resolver.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +# IpRangeResolver - Service for resolving IP addresses to network ranges +# +# Provides methods to find matching network ranges for IP addresses, +# create surgical blocks, and analyze IP intelligence. +class IpRangeResolver + # Find all network ranges that contain the given IP address + # Returns array of hashes with range data, ordered by specificity (most specific first) + def self.resolve(ip_address) + return [] unless ip_address.present? + + NetworkRange.contains_ip(ip_address).map do |range| + { + range: range, + cidr: range.cidr, + prefix_length: range.prefix_length, + specificity: range.prefix_length, + intelligence: range.inherited_intelligence + } + end.sort_by { |r| -r[:specificity] } # Most specific first + end + + # Find the most specific network range for an IP + def self.most_specific_range(ip_address) + resolve(ip_address).first + end + + # Find all network ranges that overlap with a given CIDR + def self.overlapping_ranges(cidr) + return [] unless cidr.present? + + NetworkRange.overlapping(cidr).map do |range| + { + range: range, + cidr: range.cidr, + prefix_length: range.prefix_length, + specificity: range.prefix_length, + intelligence: range.inherited_intelligence + } + end.sort_by { |r| -r[:specificity] } + end + + # Create network range if it doesn't exist + def self.find_or_create_range(cidr, user: nil, source: nil, reason: nil, **attributes) + return nil unless cidr.present? + + NetworkRange.find_or_create_by_cidr(cidr, user: user, source: source, reason: reason) do |range| + # Try to inherit attributes from parent ranges + inherited_attrs = inherited_attributes(cidr) + range.assign_attributes(inherited_attrs.merge(attributes)) + end + end + + # Create surgical block (block parent range, allow specific IP) + def self.create_surgical_block(ip_address, parent_cidr, user: nil, reason: nil, **options) + return [nil, nil] unless ip_address.present? && parent_cidr.present? + + Rule.create_surgical_block(ip_address, parent_cidr, user: user, reason: reason, **options) + end + + # Get IP intelligence data + def self.get_ip_intelligence(ip_address) + ranges = resolve(ip_address) + + { + ip_address: ip_address, + ranges: ranges, + most_specific_range: ranges.first, + intelligence: ranges.first&.dig(:intelligence) || {}, + + # Suggested blocking ranges + suggested_blocks: suggest_blocking_ranges(ip_address, ranges) + } + end + + # Suggest CIDR ranges for blocking based on network hierarchy + def self.suggest_blocking_ranges(ip_address, ranges = nil) + ranges ||= resolve(ip_address) + return [] if ranges.empty? + + ip_obj = IPAddr.new(ip_address) + suggestions = [] + + # Current /32 or /128 (single IP) + suggestions << { + cidr: "#{ip_address}/#{ip_obj.ipv4? ? '32' : '128'}", + type: 'single_ip', + description: 'Single IP address', + current_block: ranges.any? { |r| r[:prefix_length] == (ip_obj.ipv4? ? 32 : 128) } + } + + # Look for common network sizes + if ip_obj.ipv4? + [24, 23, 22, 21, 20, 19, 18, 16].each do |prefix| + network_cidr = calculate_network_cidr(ip_address, prefix) + next unless network_cidr + + suggestions << { + cidr: network_cidr, + type: 'network_block', + description: "/#{prefix} network block", + current_block: ranges.any? { |r| r[:prefix_length] == prefix }, + existing_range: ranges.find { |r| r[:prefix_length] <= prefix } + } + end + end + + suggestions + end + + # Find related IPs from same network ranges + def self.find_related_ips(ip_address, limit_per_range: 100, total_limit: 500) + ranges = resolve(ip_address) + return [] if ranges.empty? + + related_ips = {} + + ranges.each do |range_data| + range = range_data[:range] + + # Find events from this range (excluding the original IP) + events = Event.where("ip_address <<= ?", range.cidr) # Postgres <<= operator + .where.not(ip_address: ip_address) + .limit(limit_per_range) + .distinct(:ip_address) + .pluck(:ip_address) + + related_ips[range.cidr] = events unless events.empty? + + break if related_ips.values.flatten.size >= total_limit + end + + related_ips + end + + # Check if IP is currently blocked by any rule + def self.ip_blocked?(ip_address) + ranges = resolve(ip_address) + return false if ranges.empty? + + range_ids = ranges.map { |r| r[:range].id } + + Rule.network_rules + .where(network_range_id: range_ids) + .where(action: 'deny') + .enabled + .where("expires_at IS NULL OR expires_at > ?", Time.current) + .exists? + end + + # Get blocking rules for an IP + def self.blocking_rules_for_ip(ip_address) + ranges = resolve(ip_address) + return Rule.none if ranges.empty? + + range_ids = ranges.map { |r| r[:range].id } + + Rule.network_rules + .where(network_range_id: range_ids) + .where(action: 'deny') + .enabled + .where("expires_at IS NULL OR expires_at > ?", Time.current) + .includes(:network_range) + .order('network_ranges.network_prefix DESC') + end + + # Analyze traffic patterns for a network range + def self.analyze_network_traffic(cidr, time_range: 1.week.ago..Time.current) + network_range = NetworkRange.find_by(network: cidr) + return nil unless network_range + + events = Event.where("ip_address <<= ?", cidr) # Postgres <<= operator + .where(timestamp: time_range) + + { + network_range: network_range, + total_requests: events.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), + time_distribution: events.group_by_hour(:timestamp).count + } + end + + private + + # Inherit attributes from parent network ranges + def self.inherited_attributes(cidr) + ip_obj = IPAddr.new(cidr) + + parent = NetworkRange.where("network <<= ? AND masklen(network) < ?", cidr, ip_obj.prefixlen) + .where.not(asn: nil) + .order("masklen(network) DESC") + .first + + if parent + { + asn: parent.asn, + asn_org: parent.asn_org, + company: parent.company, + country: parent.country, + is_datacenter: parent.is_datacenter, + is_proxy: parent.is_proxy, + is_vpn: parent.is_vpn + } + else + {} + end + end + + # Calculate network CIDR for an IP and prefix length + def self.calculate_network_cidr(ip_address, prefix_length) + ip_obj = IPAddr.new(ip_address) + network = ip_obj.mask(prefix_length) + "#{network}/#{prefix_length}" + rescue IPAddr::InvalidAddressError + nil + end +end \ No newline at end of file diff --git a/app/services/ipapi.rb b/app/services/ipapi.rb new file mode 100644 index 0000000..ba5d57a --- /dev/null +++ b/app/services/ipapi.rb @@ -0,0 +1,43 @@ +class Ipapi + include BookoAgent + BASE_URL = "https://api.ipapi.is/" + API_KEY = Rails.application.credentials.ipapi_key + + def lookup(ip) = json_at("#{BASE_URL}?q=#{ip}&key=#{API_KEY}") + + def self.lookup(ip) = new.lookup(ip) + + def multi_lookup(ips) + ips = Array(ips) + ips.each_slice(100).flat_map { |slice| post_data({ips: slice}) } + end + + def data(ip) + uri = URI.parse(BASE_URL) + + if ip.is_a?(Array) + post_data(ip) + else + uri.query = "q=#{ip}" + JSON.parse(http.request(uri).body) + end + rescue JSON::ParserError + {} + end + + def post_data(ips) + url = URI.parse(BASE_URL + "?key=#{API_KEY}") + + results = post_json(url, body: ips) + + results["response"].map do |ip, data| + IPAddr.new(ip) + cidr = data.dig("asn", "route") + + NetworkRange.add_network(cidr).tap { |acl| acl&.update(ip_api_data: data) } + rescue IPAddr::InvalidAddressError + puts "Skipping #{ip}" + next + end + end +end \ No newline at end of file diff --git a/app/services/network_data_importer.rb b/app/services/network_data_importer.rb new file mode 100644 index 0000000..8e712c3 --- /dev/null +++ b/app/services/network_data_importer.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +# NetworkDataImporter - Service for importing production network data +# +# Imports network ranges from JSONL format with rich metadata. +# Optimized for bulk importing large datasets. +class NetworkDataImporter + def self.import_from_jsonl(file_path, limit: nil, batch_size: 1000) + puts "Starting import from #{file_path}" + + imported_count = 0 + batch = [] + + File.foreach(file_path) do |line| + break if limit && imported_count >= limit + + begin + data = JSON.parse(line) + batch << convert_to_network_range(data) + + if batch.size >= batch_size + import_batch(batch) + imported_count += batch.size + puts "Imported #{imported_count} records..." + batch = [] + end + + rescue JSON::ParserError => e + Rails.logger.error "Failed to parse line: #{e.message}" + rescue => e + Rails.logger.error "Error processing record: #{e.message}" + end + end + + # Import remaining records + if batch.any? + import_batch(batch) + imported_count += batch.size + end + + puts "Import completed. Total records: #{imported_count}" + imported_count + end + + def self.import_sample(file_path, sample_size: 1000) + puts "Importing sample of #{sample_size} records from #{file_path}" + + imported_count = 0 + batch = [] + + File.foreach(file_path) do |line| + break if imported_count >= sample_size + + begin + data = JSON.parse(line) + batch << convert_to_network_range(data) + + if batch.size >= 100 + import_batch(batch) + imported_count += batch.size + batch = [] + end + + rescue JSON::ParserError => e + Rails.logger.error "Failed to parse line: #{e.message}" + rescue => e + Rails.logger.error "Error processing record: #{e.message}" + end + end + + # Import remaining records + if batch.any? + import_batch(batch) + imported_count += batch.size + end + + puts "Sample import completed. Total records: #{imported_count}" + imported_count + end + + def self.test_import_with_lookup(file_path, test_ips: ['8.8.8.8', '1.1.1.1', '192.168.1.100']) + puts "Importing sample data and testing IP lookups..." + + # Import a small sample first + import_sample(file_path, sample_size: 10000) + + # Test IP resolution + puts "\n=== Testing IP Resolution ===" + test_ips.each do |ip| + puts "\nTesting IP: #{ip}" + + # Find matching ranges + ranges = NetworkRange.contains_ip(ip) + puts "Found #{ranges.count} matching ranges" + + ranges.each_with_index do |range, index| + puts " #{index + 1}. #{range.cidr} (#{range.prefix_length})" + puts " Company: #{range.company || 'Unknown'}" + puts " ASN: #{range.asn || 'Unknown'}" + puts " Country: #{range.country || 'Unknown'}" + puts " Datacenter: #{range.is_datacenter? ? 'Yes' : 'No'}" + puts " VPN: #{range.is_vpn? ? 'Yes' : 'No'}" + puts " Proxy: #{range.is_proxy? ? 'Yes' : 'No'}" + end + + # Test IpRangeResolver + puts "\nUsing IpRangeResolver:" + resolved = IpRangeResolver.resolve(ip) + puts "Resolved #{resolved.count} ranges" + resolved.first(3).each_with_index do |range_data, index| + intel = range_data[:intelligence] + puts " #{index + 1}. #{range_data[:cidr]} (specificity: #{range_data[:specificity]})" + puts " Company: #{intel[:company] || 'Unknown'}" + puts " Inherited: #{intel[:inherited] ? 'Yes' : 'No'}" + end + end + + # Test rule creation + puts "\n=== Testing Rule Creation ===" + test_ip = test_ips.first + matching_range = NetworkRange.contains_ip(test_ip).first + + if matching_range + puts "Creating rule for #{matching_range.cidr}" + user = User.first || User.create!(email_address: 'test@example.com', password: 'password123') + + rule = Rule.create_network_rule(matching_range.cidr, action: 'deny', user: user) + puts "Rule created: #{rule.id} - #{rule.cidr}" + puts "Rule network intelligence: #{rule.network_intelligence[:company]}" + + # Test surgical blocking + puts "\nTesting surgical blocking for IP #{test_ip}" + parent_cidr = matching_range.cidr + + block_rule, exception_rule = Rule.create_surgical_block( + test_ip, parent_cidr, user: user, reason: 'Test surgical block' + ) + puts "Block rule: #{block_rule.id} - #{block_rule.cidr}" + puts "Exception rule: #{exception_rule.id} - #{exception_rule.cidr}" + end + end + + private + + def self.convert_to_network_range(data) + # Convert integer network_start to IP address + network_start_ip = integer_to_ip(data['network_start'], data['ip_version']) + network_end_ip = integer_to_ip(data['network_end'], data['ip_version']) + + # Create CIDR notation + cidr = if data['ip_version'] == 4 + "#{network_start_ip}/#{data['network_prefix']}" + else + "#{network_start_ip}/#{data['network_prefix']}" + end + + metadata = data['metadata'] || {} + + { + network: cidr, + source: 'production_import', + asn: metadata['asn'], + asn_org: metadata['org'], + company: metadata['company_name'], + country: metadata['country_code'], + is_datacenter: metadata['is_datacenter'] || false, + is_proxy: metadata['is_proxy'] || false, + is_vpn: metadata['is_vpn'] || false, + abuser_scores: metadata['abuser_score'] ? { score: metadata['abuser_score'] } : nil, + additional_data: metadata.except('asn', 'org', 'company_name', 'country_code', + 'is_datacenter', 'is_proxy', 'is_vpn', 'abuser_score').to_json + } + end + + def self.integer_to_ip(integer, version) + if version == 4 + IPAddr.new(integer, Socket::AF_INET).to_s + else + # For IPv6, convert 128-bit integer + IPAddr.new(integer, Socket::AF_INET6).to_s + end + rescue => e + Rails.logger.error "Failed to convert integer #{integer} to IP: #{e.message}" + "0.0.0.0" + end + + def self.import_batch(batch_data) + NetworkRange.insert_all(batch_data) + rescue => e + Rails.logger.error "Failed to import batch: #{e.message}" + + # Fallback to individual imports + batch_data.each do |data| + begin + NetworkRange.create!(data) + rescue => individual_error + Rails.logger.error "Failed to import individual record: #{individual_error.message}" + end + end + end +end \ No newline at end of file diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 5252740..92207b5 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -71,10 +71,12 @@ <%= current_user.role %> diff --git a/app/views/network_ranges/index.html.erb b/app/views/network_ranges/index.html.erb new file mode 100644 index 0000000..bc0b724 --- /dev/null +++ b/app/views/network_ranges/index.html.erb @@ -0,0 +1,313 @@ +<% content_for :title, "Network Ranges - #{@project.name}" %> + +
+ +
+
+
+

Network Ranges

+

Browse and manage network ranges with intelligence data

+
+
+ <%= link_to "IP Lookup", lookup_network_ranges_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" %> + <%= link_to "Add Range", new_network_range_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> +
+
+
+ + + <% if params[:asn].present? || params[:country].present? || params[:company].present? || params[:datacenter].present? || params[:vpn].present? || params[:proxy].present? || params[:source].present? || params[:search].present? %> +
+
+
+

Active Filters

+
+ <% if params[:asn].present? %> + + ASN: <%= params[:asn] %> + <%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %> + + <% end %> + <% if params[:country].present? %> + + Country: <%= params[:country] %> + <%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %> + + <% end %> + <% if params[:company].present? %> + + Company: <%= params[:company] %> + <%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %> + + <% end %> + <% if params[:datacenter].present? %> + + Datacenter + <%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %> + + <% end %> + <% if params[:vpn].present? %> + + VPN + <%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %> + + <% end %> + <% if params[:proxy].present? %> + + Proxy + <%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %> + + <% end %> + <% if params[:source].present? %> + + Source: <%= params[:source] %> + <%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %> + + <% end %> + <% if params[:search].present? %> + + Search: <%= params[:search] %> + <%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %> + + <% end %> +
+
+
+ <%= link_to "Clear All", network_ranges_path, class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> +
+
+
+ <% end %> + + +
+
+
+
+
+ + + +
+
+
+
Total Ranges
+
<%= number_with_delimiter(@total_ranges) %>
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
With Intelligence
+
<%= number_with_delimiter(@ranges_with_intelligence) %>
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Datacenters
+
<%= number_with_delimiter(@datacenter_ranges) %>
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
VPNs
+
<%= number_with_delimiter(@vpn_ranges) %>
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Proxies
+
<%= number_with_delimiter(@proxy_ranges) %>
+
+
+
+
+
+
+ + +
+
+

Filters & Search

+
+
+ <%= form_with url: network_ranges_path, method: :get, class: "space-y-4" do |form| %> +
+
+ <%= form.label :search, "Search", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :search, value: params[:search], placeholder: "CIDR, Company, ASN...", class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> +
+ +
+ <%= form.label :country, "Country", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :country, options_for_select([["All Countries", ""]] + @top_countries.map { |c, _| [c, c] }), { selected: params[:country] }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %> +
+ +
+ <%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :source, options_for_select([["All Sources", ""], "production_import", "user_created", "api_imported", "manual", "auto:scanner_detected"], params[:source]), { }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %> +
+ +
+ <%= form.label :flags, "Flags", class: "block text-sm font-medium text-gray-700" %> +
+ + + +
+
+
+ +
+ <%= form.submit "Apply Filters", 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" %> + <%= link_to "Clear", network_ranges_path, class: "ml-3 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" %> +
+ <% end %> +
+
+ + +
+
+

Network Ranges

+

Showing <%= @network_ranges.count %> of <%= number_with_delimiter(@total_ranges) %> ranges

+
+ + + + <% if @network_ranges.empty? %> +
+ + + +

No network ranges found

+

Get started by importing network data or creating ranges manually.

+
+ <%= link_to "Add Network Range", new_network_range_path, class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" %> +
+
+ <% end %> +
+ + + <% if @pagy.present? %> +
+ <%= pagy_nav(@pagy) %> +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/network_ranges/lookup.html.erb b/app/views/network_ranges/lookup.html.erb new file mode 100644 index 0000000..1cd9d71 --- /dev/null +++ b/app/views/network_ranges/lookup.html.erb @@ -0,0 +1,131 @@ +<% content_for :title, "IP Address Lookup" %> + +
+
+

IP Address Lookup

+

Lookup IP addresses to find matching network ranges and intelligence

+
+ + +
+
+ <%= form_with url: lookup_network_ranges_path, method: :get, local: true, class: "space-y-4" do |form| %> +
+ <%= form.label :ip_address, "IP Address", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.text_field :ip, value: params[:ip], placeholder: "Enter IP address (e.g., 8.8.8.8)", class: "flex-1 min-w-0 block w-full rounded-none rounded-l-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> + <%= form.submit "Lookup", class: "-ml-px relative inline-flex items-center px-4 py-2 border border-transparent rounded-r-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> +
+
+ <% end %> +
+
+ + <% if params[:ip].present? && @ranges %> + +
+
+

IP Intelligence: <%= params[:ip] %>

+
+
+
+
+

Matching Network Ranges

+ <% if @ranges.any? %> +
+ <% @ranges.each do |range| %> +
+
+ <%= link_to range.cidr, network_range_path(range), class: "text-sm font-medium text-blue-600 hover:text-blue-900" %> + Priority: <%= range[:specificity] %> +
+ + <% intel = range[:intelligence] %> +
+ <% if intel[:company] %> +
Company: <%= intel[:company] %>
+ <% end %> + <% if intel[:asn] %> +
ASN: <%= intel[:asn] %> (<%= intel[:asn_org] %>)
+ <% end %> + <% if intel[:country] %> +
Country: <%= intel[:country] %>
+ <% end %> + +
+ <% if intel[:is_datacenter] %> + DC + <% end %> + <% if intel[:is_vpn] %> + VPN + <% end %> + <% if intel[:is_proxy] %> + Proxy + <% end %> + <% if intel[:inherited] %> + Inherited + <% end %> +
+
+
+ <% end %> +
+ <% else %> +

No network ranges found for this IP address.

+ <% end %> +
+ +
+

Suggested Block Ranges

+ <% if @suggested_blocks.any? %> +
+ <% @suggested_blocks.each do |suggestion| %> +
+
+
<%= suggestion[:cidr] %>
+
<%= suggestion[:description] %>
+
+ <% if suggestion[:current_block] %> + Already Blocked + <% else %> + <%= link_to "Block Range", new_rule_path(cidr: suggestion[:cidr]), class: "inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-white bg-red-600 hover:bg-red-700" %> + <% end %> +
+ <% end %> +
+ <% else %> +

No suggestions available.

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

Quick Lookup

+

Enter any IP address to instantly find matching network ranges

+
+ +
+ + + +

Network Intelligence

+

View company, ASN, country, and classification data

+
+ +
+ + + +

Create Rules

+

Block ranges or create surgical exceptions instantly

+
+
+
\ No newline at end of file diff --git a/app/views/network_ranges/new.html.erb b/app/views/network_ranges/new.html.erb new file mode 100644 index 0000000..23f9b62 --- /dev/null +++ b/app/views/network_ranges/new.html.erb @@ -0,0 +1,128 @@ +<% content_for :title, "Add Network Range" %> + +
+
+

Add Network Range

+

Create a new network range for tracking and rule management

+
+ +
+ <%= form_with(model: @network_range, local: true, class: "space-y-6") do |form| %> + <% if @network_range.errors.any? %> +
+
+
+ + + +
+
+

+ There were <%= pluralize(@network_range.errors.count, "error") %> with your submission: +

+
+
    + <% @network_range.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+

Network Information

+
+ +
+
+ <%= form.label :network, "Network Range (CIDR)", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :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", placeholder: "e.g., 192.168.1.0/24 or 2001:db8::/32" %> +

Enter the network range in CIDR notation. Supports both IPv4 and IPv6.

+
+ +
+ <%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :source, options_for_select([ + ["User Created", "user_created"], + ["Manual Import", "manual"], + ["API Imported", "api_imported"], + ["Auto Generated", "auto_generated"] + ], "user_created"), {}, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %> +
+ +
+ <%= form.label :creation_reason, "Creation Reason", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_area :creation_reason, 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: "Optional reason for creating this network range" %> +
+
+ +
+

Network Intelligence

+

Optional intelligence data about this network range

+
+ +
+
+
+ <%= form.label :asn, "ASN", class: "block text-sm font-medium text-gray-700" %> + <%= form.number_field :asn, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "e.g., 15169" %> +
+ +
+ <%= form.label :asn_org, "ASN Organization", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :asn_org, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "e.g., Google LLC" %> +
+ +
+ <%= form.label :company, "Company", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :company, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "e.g., Google LLC" %> +
+ +
+ <%= form.label :country, "Country Code", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_field :country, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "e.g., US" %> +
+
+ +
+
+ <%= form.check_box :is_datacenter, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> + <%= form.label :is_datacenter, "Datacenter Network", class: "text-sm font-medium text-gray-900" %> +
+ +
+ <%= form.check_box :is_vpn, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> + <%= form.label :is_vpn, "VPN Network", class: "text-sm font-medium text-gray-900" %> +
+ +
+ <%= form.check_box :is_proxy, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> + <%= form.label :is_proxy, "Proxy Network", class: "text-sm font-medium text-gray-900" %> +
+
+ +
+ <%= form.label :abuser_scores, "Abuser Scores", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_area :abuser_scores, 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: '{ "score": "0.001", "source": "api" }' %> +

JSON format with abuser scoring information

+
+ +
+ <%= form.label :additional_data, "Additional Data", class: "block text-sm font-medium text-gray-700" %> + <%= form.text_area :additional_data, 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: '{ "custom_field": "value" }' %> +

JSON format for any additional metadata

+
+
+ +
+
+ <%= link_to "Cancel", network_ranges_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" %> + <%= form.submit "Create Network Range", class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> +
+
+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/network_ranges/show.html.erb b/app/views/network_ranges/show.html.erb new file mode 100644 index 0000000..e7e4fe6 --- /dev/null +++ b/app/views/network_ranges/show.html.erb @@ -0,0 +1,347 @@ +<% content_for :title, "#{@network_range.cidr} - Network Range Details" %> + +
+ +
+
+
+ +
+

<%= @network_range.cidr %>

+ <% if @network_range.ipv4? %> + IPv4 + <% else %> + IPv6 + <% end %> +
+
+
+ <%= link_to "Edit", edit_network_range_path(@network_range), class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %> + <%= link_to "Create Rule", new_rule_path(network_range_id: @network_range.id), class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> +
+
+
+ + +
+
+

Network Intelligence

+
+
+
+
+
Network Address
+
<%= @network_range.network_address %>
+
+
+
Prefix Length
+
/<%= @network_range.prefix_length %>
+
+
+
Family
+
<%= @network_range.ipv4? ? "IPv4" : "IPv6" %>
+
+ + <% if @network_range.asn.present? %> +
+
ASN
+
+ <%= 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" %> +
+
+ <% end %> + + <% if @network_range.company.present? %> +
+
Company
+
+ <%= link_to @network_range.company, network_ranges_path(company: @network_range.company), + class: "text-blue-600 hover:text-blue-900 hover:underline" %> +
+
+ <% end %> + + <% if @network_range.country.present? %> +
+
Country
+
+ <%= link_to @network_range.country, network_ranges_path(country: @network_range.country), + class: "text-blue-600 hover:text-blue-900 hover:underline" %> +
+
+ <% end %> + +
+
Source
+
<%= @network_range.source %>
+
+ +
+
Created
+
<%= time_ago_in_words(@network_range.created_at) %> ago
+
+ +
+
Updated
+
<%= time_ago_in_words(@network_range.updated_at) %> ago
+
+ + +
+
Classification
+
+ <% if @network_range.is_datacenter? %> + + + + + Datacenter + + <% end %> + <% if @network_range.is_vpn? %> + + + + + VPN + + <% end %> + <% if @network_range.is_proxy? %> + + + + + Proxy + + <% end %> + <% if @network_range.abuser_scores_hash.any? %> + + + + + Abuser Score: <%= @network_range.abuser_scores_hash['score'] || 'Unknown' %> + + <% end %> +
+
+
+ + <% if @network_range.additional_data_hash.any? %> +
+
Additional Data
+
+
<%= JSON.pretty_generate(@network_range.additional_data_hash) %>
+
+
+ <% end %> +
+
+ + + <% if @traffic_stats[:total_requests] > 0 %> +
+
+

Traffic Statistics

+
+
+
+
+
<%= number_with_delimiter(@traffic_stats[:total_requests]) %>
+
Total Requests
+
+
+
<%= number_with_delimiter(@traffic_stats[:unique_ips]) %>
+
Unique IPs
+
+
+
<%= number_with_delimiter(@traffic_stats[:allowed_requests]) %>
+
Allowed
+
+
+
<%= number_with_delimiter(@traffic_stats[:blocked_requests]) %>
+
Blocked
+
+
+ + <% if @traffic_stats[:top_paths].any? %> +
+

Top Paths

+
+ <% @traffic_stats[:top_paths].first(5).each do |path, count| %> +
+ <%= path %> + <%= count %> +
+ <% end %> +
+
+ <% end %> +
+
+ <% end %> + + + <% if @associated_rules.any? %> +
+
+

Associated Rules (<%= @associated_rules.count %>)

+
+
+ <% @associated_rules.each do |rule| %> +
+
+
+
+
+ + <%= rule.action.upcase %> <%= rule.cidr %> + + + Priority: <%= rule.priority %> + + <% if rule.source.include?('surgical') %> + + Surgical + + <% end %> +
+
+ Created <%= time_ago_in_words(rule.created_at) %> ago by <%= rule.user&.email_address || 'System' %> +
+ <% if rule.metadata&.dig('reason').present? %> +
+ Reason: <%= rule.metadata['reason'] %> +
+ <% end %> +
+
+
+ <% if rule.enabled? %> + Active + <% else %> + Disabled + <% end %> + <%= link_to "View", rule_path(rule), class: "text-blue-600 hover:text-blue-900 text-sm font-medium" %> +
+
+
+ <% end %> +
+
+ <% end %> + + +
+ + <% if @parent_ranges.any? %> +
+
+

Parent Network Ranges

+
+
+ <% @parent_ranges.each do |parent| %> +
+
+
+ <%= link_to parent.cidr, network_range_path(parent), class: "text-sm font-medium text-gray-900 hover:text-blue-600" %> +
+ Prefix: /<%= parent.prefix_length %> | + <% if parent.company.present? %><%= parent.company %> | <% end %> + <%= parent.source %> +
+
+
+
+ <% end %> +
+
+ <% end %> + + + <% if @child_ranges.any? %> +
+
+

Child Network Ranges

+
+
+ <% @child_ranges.each do |child| %> +
+
+
+ <%= link_to child.cidr, network_range_path(child), class: "text-sm font-medium text-gray-900 hover:text-blue-600" %> +
+ Prefix: /<%= child.prefix_length %> | + <% if child.company.present? %><%= child.company %> | <% end %> + <%= child.source %> +
+
+
+
+ <% end %> +
+
+ <% end %> +
+ + + <% if @related_events.any? %> +
+
+

Recent Events (<%= @related_events.count %>)

+
+
+ + + + + + + + + + + + <% @related_events.first(20).each do |event| %> + + + + + + + + <% end %> + +
TimeIPPathActionUser Agent
+ <%= event.timestamp.strftime("%H:%M:%S") %> + + <%= event.ip_address %> + + <%= event.request_path || "-" %> + + + <%= event.waf_action %> + + +
+ <%= event.user_agent&.truncate(50) || "-" %> +
+
+
+
+ <% end %> +
\ No newline at end of file diff --git a/app/views/passwords/edit.html.erb b/app/views/passwords/edit.html.erb index 65798f8..5ac2440 100644 --- a/app/views/passwords/edit.html.erb +++ b/app/views/passwords/edit.html.erb @@ -1,21 +1,91 @@ -
- <% if alert = flash[:alert] %> -

<%= alert %>

- <% end %> +
+
+
+ <% if alert = flash[:alert] %> + + <% end %> -

Update your password

+
+
+

Account Settings

+
+
+ +
+
Profile Information
+ + + + + + + + + + + + + + + + + + + + + +
Email:<%= @user.email_address %>
Role:<%= @user.role %>
Authentication: + <% if @user.password_digest.present? %> + Local Password + <% else %> + OIDC + <% end %> +
Active Sessions:<%= @user.sessions.count %>
Member Since:<%= @user.created_at.strftime('%B %d, %Y') %>
+
- <%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %> -
- <%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> + <% if @user.password_digest.present? %> + +
+
Change Password
+

Changing your password will log you out of all other devices.

+ + <%= form_with model: @user, url: password_path, method: :patch, class: "row g-3" do |form| %> +
+ <%= form.label :current_password, "Current Password", class: "form-label" %> + <%= form.password_field :current_password, required: true, autocomplete: "current-password", class: "form-control" %> +
+ +
+ <%= form.label :password, "New Password", class: "form-label" %> + <%= form.password_field :password, required: true, autocomplete: "new-password", minlength: 8, class: "form-control" %> +
Minimum 8 characters
+
+ +
+ <%= form.label :password_confirmation, "Confirm New Password", class: "form-label" %> + <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", minlength: 8, class: "form-control" %> +
+ +
+ <%= form.submit "Update Password", class: "btn btn-primary" %> + <%= link_to "Cancel", root_path, class: "btn btn-secondary ms-2" %> +
+ <% end %> +
+ <% else %> + +
+
+
🔐 OIDC Authentication
+

Your account is managed through your OIDC provider. To change your password, please use your provider's account management tools.

+
+
+ <% end %> +
+
- -
- <%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> -
- -
- <%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> -
- <% end %> +
diff --git a/app/views/passwords/new.html.erb b/app/views/passwords/new.html.erb deleted file mode 100644 index 8360e02..0000000 --- a/app/views/passwords/new.html.erb +++ /dev/null @@ -1,17 +0,0 @@ -
- <% if alert = flash[:alert] %> -

<%= alert %>

- <% end %> - -

Forgot your password?

- - <%= form_with url: passwords_path, class: "contents" do |form| %> -
- <%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %> -
- -
- <%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %> -
- <% end %> -
diff --git a/app/views/passwords_mailer/reset.html.erb b/app/views/passwords_mailer/reset.html.erb deleted file mode 100644 index 1b09154..0000000 --- a/app/views/passwords_mailer/reset.html.erb +++ /dev/null @@ -1,6 +0,0 @@ -

- You can reset your password on - <%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. - - This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. -

diff --git a/app/views/passwords_mailer/reset.text.erb b/app/views/passwords_mailer/reset.text.erb deleted file mode 100644 index aecee82..0000000 --- a/app/views/passwords_mailer/reset.text.erb +++ /dev/null @@ -1,4 +0,0 @@ -You can reset your password on -<%= edit_password_url(@user.password_reset_token) %> - -This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. diff --git a/app/views/rules/edit.html.erb b/app/views/rules/edit.html.erb new file mode 100644 index 0000000..1391903 --- /dev/null +++ b/app/views/rules/edit.html.erb @@ -0,0 +1,213 @@ +<% content_for :title, "Edit Rule ##{@rule.id}" %> + +
+
+
+
+

Edit Rule #<%= @rule.id %>

+

Modify the WAF rule configuration

+
+
+
+ +
+ <%= form_with(model: @rule, local: true, class: "space-y-6") do |form| %> + <% if @rule.errors.any? %> +
+
+
+ + + +
+
+

+ There were <%= pluralize(@rule.errors.count, "error") %> with your submission: +

+
+
    + <% @rule.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + + +
+

Rule Configuration

+
+ +
+
+
+ <%= 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), + { 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", + disabled: true } %> +

Rule type cannot be changed after creation

+
+ +
+ <%= 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), + { }, + { 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

+
+
+ + + <% if @rule.network_rule? %> +
+
+
+ <%= form.label :network_range_id, "Network Range", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :network_range_id, + options_from_collection_for_select(NetworkRange.order(:network).limit(100), :id, :cidr, @rule.network_range_id), + { prompt: "Select a network range" }, + { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %> +

Select from recent network ranges or create new ones

+
+ +
+ <%= link_to "Create New Network Range", new_network_range_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" %> +
+
+ + <% if @rule.network_range.present? %> +
+
+
+ + + +
+
+

+ Currently targeting: <%= link_to @rule.network_range.cidr, network_range_path(@rule.network_range), class: "text-blue-600 hover:text-blue-900 underline" %> + <% if @rule.network_range.company.present? %> + - <%= @rule.network_range.company %> + <% end %> +

+
+
+
+ <% end %> +
+ <% end %> + + + <% unless @rule.network_rule? %> +
+
+ <%= 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*"}' %> +

JSON format with matching conditions

+
+
+ <% end %> + + +
+ <%= form.label :metadata, "Metadata", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.text_area :metadata, 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: '{"reason": "Suspicious activity detected", "source": "manual"}', + data: { json_validator_target: "textarea", action: "input->json-validator#validate" } %> +
+
+
+ + +
+
+
+

JSON format with additional metadata

+
+ +
+
+ <%= form.label :expires_at, "Expires At", 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" %> +

Leave blank for permanent rule

+
+ +
+ <%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :source, + options_for_select(Rule::SOURCES.map { |source| [source.humanize, source] }, @rule.source), + { }, + { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %> +

How this rule was created

+
+ +
+ <%= form.check_box :enabled, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> + <%= form.label :enabled, "Rule Enabled", class: "ml-2 block text-sm text-gray-900" %> +
+
+
+ +
+
+
+ <%= link_to "Cancel", @rule, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %> +
+
+ <%= link_to "Delete Rule", @rule, + method: :delete, + data: { confirm: "Are you sure you want to delete this rule? This action cannot be undone." }, + class: "inline-flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-red-50 hover:bg-red-100" %> + <%= form.submit "Update Rule", class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> +
+
+
+ <% end %> +
+
+ + \ No newline at end of file diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb new file mode 100644 index 0000000..767e2ed --- /dev/null +++ b/app/views/rules/index.html.erb @@ -0,0 +1,224 @@ +<% content_for :title, "Rules - #{@project.name}" %> + +
+ +
+
+
+

Rules

+

Manage WAF rules for traffic filtering and control

+
+
+ <%= link_to "Add Network Range", new_network_range_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" %> + <%= link_to "Create Rule", new_rule_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> +
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
Total Rules
+
<%= number_with_delimiter(@rules.count) %>
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Active Rules
+
<%= number_with_delimiter(@rules.active.count) %>
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Block Rules
+
<%= number_with_delimiter(@rules.where(action: 'deny').count) %>
+
+
+
+
+
+ +
+
+
+
+ + + +
+
+
+
Expired Rules
+
<%= number_with_delimiter(@rules.expired.count) %>
+
+
+
+
+
+
+ + +
+
+

All Rules

+
+ + <% if @rules.any? %> +
+ + + + + + + + + + + + + + <% @rules.each do |rule| %> + + + + + + + + + + <% end %> + +
RuleTypeActionTargetStatusCreatedActions
+
+
+
+ <%= 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 %> +
+
+
+
+ + <%= rule.rule_type.humanize %> + + + + <%= rule.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" %> +
+ <% else %> + - + <% end %> +
+
+ <% if rule.enabled? && !rule.expired? %> + Active + <% elsif rule.expired? %> + Expired + <% else %> + Disabled + <% end %> + + <% if rule.expires_at.present? %> + + <%= distance_of_time_in_words(Time.current, rule.expires_at) %> left + + <% end %> +
+
+ <%= time_ago_in_words(rule.created_at) %> ago +
+ by <%= rule.user&.email_address || 'System' %> +
+
+ <%= link_to "View", rule_path(rule), class: "text-blue-600 hover:text-blue-900 mr-3" %> + <% if rule.enabled? %> + <%= link_to "Disable", disable_rule_path(rule), + method: :post, + data: { confirm: "Are you sure you want to disable this rule?" }, + class: "text-yellow-600 hover:text-yellow-900 mr-3" %> + <% else %> + <%= link_to "Enable", enable_rule_path(rule), + method: :post, + class: "text-green-600 hover:text-green-900 mr-3" %> + <% end %> + <%= link_to "Edit", edit_rule_path(rule), class: "text-indigo-600 hover:text-indigo-900" %> +
+
+ <% else %> +
+ + + +

No rules

+

Get started by creating your first WAF rule.

+
+ <%= link_to "Create Rule", new_rule_path, class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" %> +
+
+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/rules/new.html.erb b/app/views/rules/new.html.erb new file mode 100644 index 0000000..34d7233 --- /dev/null +++ b/app/views/rules/new.html.erb @@ -0,0 +1,372 @@ +<% content_for :title, "Create New Rule" %> + +
+
+

Create New Rule

+

Create a WAF rule to allow, block, or rate limit traffic

+
+ +
+ <%= form_with(model: @rule, local: true, class: "space-y-6") do |form| %> + <% if @rule.errors.any? %> +
+
+
+ + + +
+
+

+ There were <%= pluralize(@rule.errors.count, "error") %> with your submission: +

+
+
    + <% @rule.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + + +
+

Rule Configuration

+
+ +
+
+
+ <%= 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), + { 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" } %> +

Choose the type of rule you want to create

+
+ +
+ <%= 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), + { 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

+
+
+ + + + + + + + +
+ <%= form.label :metadata, "Metadata", class: "block text-sm font-medium text-gray-700" %> +
+ <%= form.text_area :metadata, 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: '{"reason": "Suspicious activity detected", "source": "manual"}', + data: { json_validator_target: "textarea", action: "input->json-validator#validate" } %> +
+
+
+ + +
+
+
+

JSON format with additional metadata

+
+ +
+
+ <%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :source, + options_for_select(Rule::SOURCES.map { |source| [source.humanize, source] }, @rule.source || "manual"), + { }, + { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %> +

How this rule was created

+
+ +
+ <%= form.label :expires_at, "Expires At", 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" %> +

Leave blank for permanent rule

+
+ +
+ <%= form.check_box :enabled, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %> + <%= form.label :enabled, "Enable immediately", class: "ml-2 block text-sm text-gray-900" %> +
+
+
+ +
+
+ <%= link_to "Cancel", rules_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" %> + <%= form.submit "Create Rule", class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %> +
+
+ <% end %> +
+
+ + \ No newline at end of file diff --git a/app/views/rules/show.html.erb b/app/views/rules/show.html.erb new file mode 100644 index 0000000..810a19f --- /dev/null +++ b/app/views/rules/show.html.erb @@ -0,0 +1,210 @@ +<% content_for :title, "Rule ##{@rule.id} - #{@rule.action.upcase}" %> + +
+ +
+
+
+ +
+

Rule #<%= @rule.id %>

+ + <%= @rule.action.upcase %> + +
+
+
+ <%= link_to "Edit", edit_rule_path(@rule), 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" %> + <% if @rule.enabled? %> + <%= link_to "Disable", disable_rule_path(@rule), + method: :post, + data: { confirm: "Are you sure you want to disable this rule?" }, + class: "inline-flex items-center px-4 py-2 border border-yellow-300 rounded-md shadow-sm text-sm font-medium text-yellow-700 bg-yellow-50 hover:bg-yellow-100" %> + <% else %> + <%= link_to "Enable", enable_rule_path(@rule), + method: :post, + class: "inline-flex items-center px-4 py-2 border border-green-300 rounded-md shadow-sm text-sm font-medium text-green-700 bg-green-50 hover:bg-green-100" %> + <% end %> +
+
+
+ + +
+
+

Rule Details

+
+
+
+
+
Rule Type
+
<%= @rule.rule_type.humanize %>
+
+ +
+
Action
+
<%= @rule.action.upcase %>
+
+ +
+
Status
+
+ <% if @rule.enabled? && !@rule.expired? %> + Active + <% elsif @rule.expired? %> + Expired + <% else %> + Disabled + <% end %> +
+
+ +
+
Source
+
<%= @rule.source.humanize %>
+
+ +
+
Priority
+
<%= @rule.priority %>
+
+ +
+
Created
+
<%= @rule.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %>
+
+ + <% if @rule.expires_at.present? %> +
+
Expires At
+
<%= @rule.expires_at.strftime("%Y-%m-%d %H:%M:%S UTC") %>
+
+ <% end %> + +
+
Created By
+
<%= @rule.user&.email_address || 'System' %>
+
+ + <% if @rule.updated_at != @rule.created_at %> +
+
Last Updated
+
<%= @rule.updated_at.strftime("%Y-%m-%d %H:%M:%S UTC") %>
+
+ <% end %> +
+
+
+ + + <% if @rule.network_rule? && @rule.network_range.present? %> +
+
+

Network Target

+
+
+
+
+
+
+ <%= 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 %> + <% if @rule.network_range.asn.present? %> +
+ ASN <%= @rule.network_range.asn %><% if @rule.network_range.asn_org.present? %> (<%= @rule.network_range.asn_org %>)<% end %> +
+ <% end %> + <% if @rule.network_range.country.present? %> +
Country: <%= @rule.network_range.country %>
+ <% end %> +
+
+ <% if @rule.network_range.is_datacenter? %> + Datacenter + <% end %> + <% if @rule.network_range.is_vpn? %> + VPN + <% end %> + <% if @rule.network_range.is_proxy? %> + Proxy + <% end %> +
+
+
+
+
+ <% end %> + + + <% if @rule.conditions.present? %> +
+
+

Conditions

+
+
+
<%= JSON.pretty_generate(JSON.parse(@rule.conditions)) rescue @rule.conditions %>
+
+
+ <% end %> + + + <% if @rule.metadata.present? %> +
+
+

Metadata

+
+
+
<%= JSON.pretty_generate(JSON.parse(@rule.metadata)) rescue @rule.metadata %>
+
+
+ <% end %> + + +
+
+

Actions

+
+
+
+ <%= link_to "Edit Rule", edit_rule_path(@rule), 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" %> + + <% if @rule.enabled? %> + <%= form_with(model: @rule, url: disable_rule_path(@rule), method: :post, class: "inline-flex") do |form| %> + <%= form.submit "Disable Rule", class: "inline-flex items-center px-4 py-2 border border-yellow-300 rounded-md shadow-sm text-sm font-medium text-yellow-700 bg-yellow-50 hover:bg-yellow-100 cursor-pointer" %> + <% end %> + <% else %> + <%= form_with(model: @rule, url: enable_rule_path(@rule), method: :post, class: "inline-flex") do |form| %> + <%= form.submit "Enable Rule", class: "inline-flex items-center px-4 py-2 border border-green-300 rounded-md shadow-sm text-sm font-medium text-green-700 bg-green-50 hover:bg-green-100 cursor-pointer" %> + <% end %> + <% end %> + + <%= link_to "View All Rules", rules_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" %> +
+
+
+
\ No newline at end of file diff --git a/config/database.yml b/config/database.yml index 1233d7a..23e4f89 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,54 +1,69 @@ -# SQLite. Versions 3.8.0 and up are supported. -# gem install sqlite3 -# -# Ensure the SQLite 3 gem is defined in your Gemfile -# gem "sqlite3" -# -default: &default +# Primary database: PostgreSQL for network intelligence +# Cache/Queue/Cable: SQLite for auxiliary storage + +# Default configuration for SQLite databases (cache/queue/cable) +sqlite_default: &sqlite_default adapter: sqlite3 max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 +# Default configuration for PostgreSQL +postgres_default: &postgres_default + adapter: postgresql + encoding: unicode + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + host: localhost + port: 5432 + development: primary: - <<: *default - database: storage/development.sqlite3 + <<: *postgres_default + database: baffle_hub_development cache: - <<: *default + <<: *sqlite_default database: storage/development_cache.sqlite3 migrations_paths: db/cache_migrate queue: - <<: *default + <<: *sqlite_default database: storage/development_queue.sqlite3 migrations_paths: db/queue_migrate cable: - <<: *default + <<: *sqlite_default database: storage/development_cable.sqlite3 migrations_paths: db/cable_migrate -# Warning: The database defined as "test" will be erased and -# re-generated from your development database when you run "rake". -# Do not set this db to the same as development or production. test: - <<: *default - database: storage/test.sqlite3 + primary: + <<: *postgres_default + database: baffle_hub_test + cache: + <<: *sqlite_default + database: storage/test_cache.sqlite3 + migrations_paths: db/cache_migrate + queue: + <<: *sqlite_default + database: storage/test_queue.sqlite3 + migrations_paths: db/queue_migrate + cable: + <<: *sqlite_default + database: storage/test_cable.sqlite3 + migrations_paths: db/cable_migrate - -# Store production database in the storage/ directory, which by default -# is mounted as a persistent Docker volume in config/deploy.yml. production: primary: - <<: *default - database: storage/production.sqlite3 + <<: *postgres_default + database: baffle_hub_production + username: baffle_hub + password: <%= ENV["BAFFLE_HUB_DATABASE_PASSWORD"] %> cache: - <<: *default + <<: *sqlite_default database: storage/production_cache.sqlite3 migrations_paths: db/cache_migrate queue: - <<: *default + <<: *sqlite_default database: storage/production_queue.sqlite3 migrations_paths: db/queue_migrate cable: - <<: *default + <<: *sqlite_default database: storage/production_cable.sqlite3 - migrations_paths: db/cable_migrate + migrations_paths: db/cable_migrate \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 70c5bfe..5ee4a72 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -2,7 +2,7 @@ Rails.application.routes.draw do # Registration only allowed when no users exist resource :registration, only: [:new, :create] resource :session - resources :passwords, param: :token + resource :password # OIDC authentication routes get "/auth/failure", to: "omniauth_callbacks#failure" @@ -39,6 +39,20 @@ Rails.application.routes.draw do end end + # Network range management + resources :network_ranges, only: [:index, :show, :new, :create, :edit, :update, :destroy] do + member do + post :enrich + end + collection do + get :lookup + get :search + end + end + + # Support CIDR patterns with dots in network range routes + get '/network_ranges/:id', to: 'network_ranges#show', constraints: { id: /[\d\.:\/_]+/ } + # Rule management resources :rules, only: [:index, :new, :create, :show, :edit, :update] do member do diff --git a/db/migrate/001_create_projects.rb b/db/migrate/001_create_projects.rb new file mode 100644 index 0000000..2c36930 --- /dev/null +++ b/db/migrate/001_create_projects.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateProjects < ActiveRecord::Migration[8.1] + def change + create_table :projects, force: :cascade do |t| + t.string :name, null: false, index: true + t.string :slug, null: false, index: { unique: true } + t.string :public_key, null: false, index: { unique: true } + t.boolean :enabled, default: true, null: false, index: true + + # WAF settings + t.integer :rate_limit_threshold, default: 100, null: false + t.text :settings, default: "{}", null: false + t.text :custom_rules, default: "{}", null: false + + # Analytics + t.integer :blocked_ip_count, default: 0, null: false + + t.timestamps + end + end +end \ No newline at end of file diff --git a/db/migrate/002_create_users.rb b/db/migrate/002_create_users.rb new file mode 100644 index 0000000..e112737 --- /dev/null +++ b/db/migrate/002_create_users.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateUsers < ActiveRecord::Migration[8.1] + def change + create_table :users, force: :cascade do |t| + t.string :email_address, null: false, index: { unique: true } + t.string :password_digest, null: false + t.integer :role, default: 1, null: false # 1=admin, 2=user + + t.timestamps + end + end +end \ No newline at end of file diff --git a/db/migrate/003_create_network_ranges.rb b/db/migrate/003_create_network_ranges.rb new file mode 100644 index 0000000..50aaeba --- /dev/null +++ b/db/migrate/003_create_network_ranges.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class CreateNetworkRanges < ActiveRecord::Migration[8.1] + def change + create_table :network_ranges, force: :cascade do |t| + # Postgres inet type handles both IPv4 and IPv6 networks + t.inet :network, null: false, index: { unique: true, name: 'index_network_ranges_on_network_unique' } + + + # Track the source of this network range + t.string :source, default: 'api_imported', null: false, index: true + t.text :creation_reason + + # Network intelligence metadata + t.integer :asn, index: true + t.string :asn_org, index: true + t.string :company, index: true + t.string :country, index: true + + # Network classification flags + t.boolean :is_datacenter, default: false, index: true + t.boolean :is_proxy, default: false + t.boolean :is_vpn, default: false + t.index [:is_datacenter, :is_proxy, :is_vpn], name: 'idx_network_flags' + + # JSON fields for additional data + t.text :abuser_scores + t.text :additional_data + + # API enrichment tracking + t.datetime :last_api_fetch + + # Track creation (optional - some ranges are auto-imported) + t.references :user, foreign_key: true + + t.timestamps + + # Postgres network indexes for performance + # GiST index for network containment operations (>>=, <<=, &&) + t.index :network, using: :gist, opclass: :inet_ops + end + end +end \ No newline at end of file diff --git a/db/migrate/004_create_request_optimization_tables.rb b/db/migrate/004_create_request_optimization_tables.rb new file mode 100644 index 0000000..91c6734 --- /dev/null +++ b/db/migrate/004_create_request_optimization_tables.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class CreateRequestOptimizationTables < ActiveRecord::Migration[8.1] + def change + # Path segments for compression and analytics + create_table :path_segments, force: :cascade do |t| + t.string :segment, null: false, index: { unique: true } + t.integer :usage_count, default: 1, null: false + t.datetime :first_seen_at, null: false + + t.timestamps + end + + # Request hosts for compression and analytics + create_table :request_hosts, force: :cascade do |t| + t.string :hostname, null: false, index: { unique: true } + t.integer :usage_count, default: 1, null: false + t.datetime :first_seen_at, null: false + + t.timestamps + end + + # Request methods for normalization + create_table :request_methods, force: :cascade do |t| + t.string :method, null: false, index: { unique: true } + + t.timestamps + end + + # Request protocols for normalization + create_table :request_protocols, force: :cascade do |t| + t.string :protocol, null: false, index: { unique: true } + + t.timestamps + end + + # Request actions for normalization + create_table :request_actions, force: :cascade do |t| + t.string :action, null: false, index: { unique: true } + + t.timestamps + end + end +end \ No newline at end of file diff --git a/db/migrate/005_create_rules.rb b/db/migrate/005_create_rules.rb new file mode 100644 index 0000000..e9014c4 --- /dev/null +++ b/db/migrate/005_create_rules.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class CreateRules < ActiveRecord::Migration[8.1] + def change + create_table :rules, force: :cascade do |t| + # Rule classification + t.string :rule_type, null: false, index: true + t.string :action, null: false, index: true + t.string :source, limit: 100, default: 'manual', index: true + + # Priority for rule evaluation (higher = more specific) + t.integer :priority, index: true + + # Rule conditions (JSON for flexibility) + t.json :conditions, default: {} + + # Rule metadata (JSON for extensibility) + t.json :metadata, default: {} + + # Rule lifecycle + t.boolean :enabled, default: true, null: false, index: true + t.datetime :expires_at, index: true + + # Relationships + t.references :user, foreign_key: true + t.references :network_range, foreign_key: true + + t.timestamps + + # Composite indexes for common queries + t.index [:rule_type, :enabled], name: 'idx_rules_type_enabled' + t.index [:enabled, :expires_at], name: 'idx_rules_active' + t.index [:updated_at, :id], name: 'idx_rules_sync' + end + end +end \ No newline at end of file diff --git a/db/migrate/006_create_events.rb b/db/migrate/006_create_events.rb new file mode 100644 index 0000000..4666342 --- /dev/null +++ b/db/migrate/006_create_events.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class CreateEvents < ActiveRecord::Migration[8.1] + def change + create_table :events, force: :cascade do |t| + # Core event identification + t.string :event_id, null: false, index: { unique: true } + t.references :project, null: false, foreign_key: true, index: true + + # Timing + t.datetime :timestamp, null: false, index: true + + # WAF evaluation + t.integer :waf_action, default: 0, null: false, index: true + t.string :rule_matched + t.text :blocked_reason + + # Request metadata + t.inet :ip_address, index: true + t.string :user_agent + t.string :request_url + t.string :request_path + t.string :request_protocol + t.integer :request_method, default: 0 + + # Response metadata + t.integer :response_status + t.integer :response_time_ms + + # Geographic data + t.string :country_code + t.string :city + + # Server/Environment info + t.string :server_name + t.string :environment + + # WAF agent info + t.string :agent_name + t.string :agent_version + + # Normalized relationships for analytics + t.references :request_host, foreign_key: true, index: true + t.string :request_segment_ids # JSON array of path segment IDs + + # Full event payload + t.json :payload + + t.timestamps + + # Composite indexes for analytics queries + t.index [:project_id, :timestamp], name: 'idx_events_project_time' + t.index [:project_id, :waf_action], name: 'idx_events_project_action' + t.index [:project_id, :ip_address], name: 'idx_events_project_ip' + t.index [:request_host_id, :request_method, :request_segment_ids], + name: 'idx_events_host_method_path' + t.index :request_segment_ids + end + end +end \ No newline at end of file diff --git a/db/migrate/007_create_sessions.rb b/db/migrate/007_create_sessions.rb new file mode 100644 index 0000000..9bf21a2 --- /dev/null +++ b/db/migrate/007_create_sessions.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateSessions < ActiveRecord::Migration[8.1] + def change + create_table :sessions, force: :cascade do |t| + t.references :user, null: false, foreign_key: true, index: true + t.inet :ip_address + t.string :user_agent + + t.timestamps + end + end +end \ No newline at end of file diff --git a/db/migrate/20251102030111_create_network_ranges.rb b/db/migrate/20251102030111_create_network_ranges.rb deleted file mode 100644 index 666c94e..0000000 --- a/db/migrate/20251102030111_create_network_ranges.rb +++ /dev/null @@ -1,30 +0,0 @@ -class CreateNetworkRanges < ActiveRecord::Migration[8.1] - def change - create_table :network_ranges do |t| - t.binary :ip_address, null: false - t.integer :network_prefix, null: false - t.integer :ip_version, null: false - t.string :company - t.integer :asn - t.string :asn_org - t.boolean :is_datacenter, default: false - t.boolean :is_proxy, default: false - t.boolean :is_vpn, default: false - t.string :ip_api_country - t.string :geo2_country - t.text :abuser_scores - t.text :additional_data - t.timestamp :last_api_fetch - - t.timestamps - end - - # Indexes for common queries - add_index :network_ranges, [:ip_address, :network_prefix], name: 'idx_network_ranges_ip_range' - add_index :network_ranges, :asn, name: 'idx_network_ranges_asn' - add_index :network_ranges, :company, name: 'idx_network_ranges_company' - add_index :network_ranges, :ip_api_country, name: 'idx_network_ranges_country' - add_index :network_ranges, [:is_datacenter, :is_proxy, :is_vpn], name: 'idx_network_ranges_flags' - add_index :network_ranges, :ip_version, name: 'idx_network_ranges_version' - end -end diff --git a/db/migrate/20251102044000_create_projects.rb b/db/migrate/20251102044000_create_projects.rb deleted file mode 100644 index 9cc077e..0000000 --- a/db/migrate/20251102044000_create_projects.rb +++ /dev/null @@ -1,21 +0,0 @@ -class CreateProjects < ActiveRecord::Migration[8.1] - def change - create_table :projects do |t| - t.string :name, null: false - t.string :slug, null: false - t.string :public_key, null: false - t.boolean :enabled, default: true, null: false - t.integer :rate_limit_threshold, default: 100, null: false - t.integer :blocked_ip_count, default: 0, null: false - t.text :custom_rules, default: "{}", null: false - t.text :settings, default: "{}", null: false - - t.timestamps - end - - add_index :projects, :slug, unique: true - add_index :projects, :public_key, unique: true - add_index :projects, :enabled - add_index :projects, :name - end -end diff --git a/db/migrate/20251102044052_create_events.rb b/db/migrate/20251102044052_create_events.rb deleted file mode 100644 index dff24a7..0000000 --- a/db/migrate/20251102044052_create_events.rb +++ /dev/null @@ -1,37 +0,0 @@ -class CreateEvents < ActiveRecord::Migration[8.1] - def change - create_table :events do |t| - t.references :project, null: false, foreign_key: true - t.string :event_id, null: false - t.datetime :timestamp, null: false - t.string :action - t.string :ip_address - t.text :user_agent - t.string :request_method - t.string :request_path - t.string :request_url - t.string :request_protocol - t.integer :response_status - t.integer :response_time_ms - t.string :rule_matched - t.text :blocked_reason - t.string :server_name - t.string :environment - t.string :country_code - t.string :city - t.string :agent_version - t.string :agent_name - t.json :payload - - t.timestamps - end - - add_index :events, :event_id, unique: true - add_index :events, :timestamp - add_index :events, [:project_id, :timestamp] - add_index :events, [:project_id, :action] - add_index :events, [:project_id, :ip_address] - add_index :events, :ip_address - add_index :events, :action - end -end diff --git a/db/migrate/20251102080959_create_rule_sets.rb b/db/migrate/20251102080959_create_rule_sets.rb deleted file mode 100644 index 86b839a..0000000 --- a/db/migrate/20251102080959_create_rule_sets.rb +++ /dev/null @@ -1,13 +0,0 @@ -class CreateRuleSets < ActiveRecord::Migration[8.1] - def change - create_table :rule_sets do |t| - t.string :name - t.text :description - t.boolean :enabled - t.json :projects - t.json :rules - - t.timestamps - end - end -end diff --git a/db/migrate/20251102081014_create_rules.rb b/db/migrate/20251102081014_create_rules.rb deleted file mode 100644 index 7116306..0000000 --- a/db/migrate/20251102081014_create_rules.rb +++ /dev/null @@ -1,17 +0,0 @@ -class CreateRules < ActiveRecord::Migration[8.1] - def change - create_table :rules do |t| - t.references :rule_set, null: false, foreign_key: true - t.string :rule_type - t.string :target - t.string :action - t.boolean :enabled - t.datetime :expires_at - t.integer :priority - t.json :conditions - t.json :metadata - - t.timestamps - end - end -end diff --git a/db/migrate/20251102081043_add_fields_to_rule_sets.rb b/db/migrate/20251102081043_add_fields_to_rule_sets.rb deleted file mode 100644 index a88be0d..0000000 --- a/db/migrate/20251102081043_add_fields_to_rule_sets.rb +++ /dev/null @@ -1,11 +0,0 @@ -class AddFieldsToRuleSets < ActiveRecord::Migration[8.1] - def change - add_column :rule_sets, :slug, :string - add_column :rule_sets, :priority, :integer - add_column :rule_sets, :projects_subscription, :json - - add_index :rule_sets, :slug, unique: true - add_index :rule_sets, :enabled - add_index :rule_sets, :priority - end -end diff --git a/db/migrate/20251102234055_add_simple_event_normalization.rb b/db/migrate/20251102234055_add_simple_event_normalization.rb deleted file mode 100644 index 94dc9eb..0000000 --- a/db/migrate/20251102234055_add_simple_event_normalization.rb +++ /dev/null @@ -1,15 +0,0 @@ -class AddSimpleEventNormalization < ActiveRecord::Migration[8.1] - def change - # Add foreign key for hosts (most valuable normalization) - add_column :events, :request_host_id, :integer - add_foreign_key :events, :request_hosts - add_index :events, :request_host_id - - # Add path segment storage as string for LIKE queries - add_column :events, :request_segment_ids, :string - add_index :events, :request_segment_ids - - # Add composite index for common WAF queries using enums - add_index :events, [:request_host_id, :request_method, :request_segment_ids], name: 'idx_events_host_method_path' - end -end diff --git a/db/migrate/20251103035249_rename_action_to_waf_action_in_events.rb b/db/migrate/20251103035249_rename_action_to_waf_action_in_events.rb deleted file mode 100644 index 6d44595..0000000 --- a/db/migrate/20251103035249_rename_action_to_waf_action_in_events.rb +++ /dev/null @@ -1,5 +0,0 @@ -class RenameActionToWafActionInEvents < ActiveRecord::Migration[8.1] - def change - rename_column :events, :action, :waf_action - end -end diff --git a/db/migrate/20251103080823_enhance_rules_table_for_sync.rb b/db/migrate/20251103080823_enhance_rules_table_for_sync.rb deleted file mode 100644 index ebb6170..0000000 --- a/db/migrate/20251103080823_enhance_rules_table_for_sync.rb +++ /dev/null @@ -1,56 +0,0 @@ -class EnhanceRulesTableForSync < ActiveRecord::Migration[8.1] - def change - # Remove rule_sets relationship (we're skipping rule sets for Phase 1) - if foreign_key_exists?(:rules, :rule_sets) - remove_foreign_key :rules, :rule_sets - end - - if column_exists?(:rules, :rule_set_id) - remove_column :rules, :rule_set_id - end - - change_table :rules do |t| - # Add source field to track rule origin - unless column_exists?(:rules, :source) - t.string :source, limit: 100 - end - - # Ensure core fields exist with proper types - unless column_exists?(:rules, :rule_type) - t.string :rule_type, null: false - end - - unless column_exists?(:rules, :action) - t.string :action, null: false - end - - unless column_exists?(:rules, :conditions) - t.json :conditions, null: false, default: {} - end - - unless column_exists?(:rules, :metadata) - t.json :metadata, default: {} - end - - unless column_exists?(:rules, :priority) - t.integer :priority - end - - unless column_exists?(:rules, :expires_at) - t.datetime :expires_at - end - - unless column_exists?(:rules, :enabled) - t.boolean :enabled, default: true, null: false - end - end - - # Add indexes for efficient sync queries - add_index :rules, [:updated_at, :id], if_not_exists: true, name: "idx_rules_sync" - add_index :rules, :enabled, if_not_exists: true - add_index :rules, :expires_at, if_not_exists: true - add_index :rules, :source, if_not_exists: true - add_index :rules, :rule_type, if_not_exists: true - add_index :rules, [:rule_type, :enabled], if_not_exists: true, name: "idx_rules_type_enabled" - end -end diff --git a/db/migrate/20251103093205_split_network_ranges_into_ipv4_and_ipv6.rb b/db/migrate/20251103093205_split_network_ranges_into_ipv4_and_ipv6.rb deleted file mode 100644 index 4c14770..0000000 --- a/db/migrate/20251103093205_split_network_ranges_into_ipv4_and_ipv6.rb +++ /dev/null @@ -1,70 +0,0 @@ -class SplitNetworkRangesIntoIpv4AndIpv6 < ActiveRecord::Migration[8.1] - def change - # Drop the old network_ranges table (no data to preserve) - drop_table :network_ranges, if_exists: true - - # Create optimized IPv4 ranges table - create_table :ipv4_ranges do |t| - # Range fields for fast lookups - t.integer :network_start, limit: 8, null: false - t.integer :network_end, limit: 8, null: false - t.integer :network_prefix, null: false - - # IP intelligence metadata - t.string :company - t.integer :asn - t.string :asn_org - t.boolean :is_datacenter, default: false - t.boolean :is_proxy, default: false - t.boolean :is_vpn, default: false - t.string :ip_api_country - t.string :geo2_country - t.text :abuser_scores - t.text :additional_data - t.timestamp :last_api_fetch - - t.timestamps - end - - # Optimized indexes for IPv4 - add_index :ipv4_ranges, [:network_start, :network_end, :network_prefix], - name: "idx_ipv4_range_lookup" - add_index :ipv4_ranges, :asn, name: "idx_ipv4_asn" - add_index :ipv4_ranges, :company, name: "idx_ipv4_company" - add_index :ipv4_ranges, :ip_api_country, name: "idx_ipv4_country" - add_index :ipv4_ranges, [:is_datacenter, :is_proxy, :is_vpn], - name: "idx_ipv4_flags" - - # Create optimized IPv6 ranges table - create_table :ipv6_ranges do |t| - # Range fields for fast lookups (binary for 128-bit addresses) - t.binary :network_start, limit: 16, null: false - t.binary :network_end, limit: 16, null: false - t.integer :network_prefix, null: false - - # IP intelligence metadata (same as IPv4) - t.string :company - t.integer :asn - t.string :asn_org - t.boolean :is_datacenter, default: false - t.boolean :is_proxy, default: false - t.boolean :is_vpn, default: false - t.string :ip_api_country - t.string :geo2_country - t.text :abuser_scores - t.text :additional_data - t.timestamp :last_api_fetch - - t.timestamps - end - - # Optimized indexes for IPv6 - add_index :ipv6_ranges, [:network_start, :network_end, :network_prefix], - name: "idx_ipv6_range_lookup" - add_index :ipv6_ranges, :asn, name: "idx_ipv6_asn" - add_index :ipv6_ranges, :company, name: "idx_ipv6_company" - add_index :ipv6_ranges, :ip_api_country, name: "idx_ipv6_country" - add_index :ipv6_ranges, [:is_datacenter, :is_proxy, :is_vpn], - name: "idx_ipv6_flags" - end -end diff --git a/db/migrate/20251103103521_create_geo_ip_databases.rb b/db/migrate/20251103103521_create_geo_ip_databases.rb deleted file mode 100644 index 724f72e..0000000 --- a/db/migrate/20251103103521_create_geo_ip_databases.rb +++ /dev/null @@ -1,16 +0,0 @@ -class CreateGeoIpDatabases < ActiveRecord::Migration[8.1] - def change - create_table :geo_ip_databases do |t| - t.string :database_type - t.string :version - t.string :file_path - t.integer :file_size - t.string :checksum_md5 - t.datetime :downloaded_at - t.datetime :last_checked_at - t.boolean :is_active - - t.timestamps - end - end -end diff --git a/db/migrate/20251103105609_drop_geo_ip_databases_table.rb b/db/migrate/20251103105609_drop_geo_ip_databases_table.rb deleted file mode 100644 index e1eff10..0000000 --- a/db/migrate/20251103105609_drop_geo_ip_databases_table.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -class DropGeoIpDatabasesTable < ActiveRecord::Migration[8.1] - def up - drop_table :geo_ip_databases - end - - def down - create_table :geo_ip_databases do |t| - t.string :database_type, null: false - t.string :version, null: false - t.string :file_path, null: false - t.integer :file_size, null: false - t.string :checksum_md5, null: false - t.datetime :downloaded_at, null: false - t.datetime :last_checked_at - t.boolean :is_active, default: true - t.timestamps - end - - add_index :geo_ip_databases, :is_active - add_index :geo_ip_databases, :database_type - add_index :geo_ip_databases, :file_path, unique: true - end -end diff --git a/db/migrate/20251103130430_change_request_method_to_integer_in_events.rb b/db/migrate/20251103130430_change_request_method_to_integer_in_events.rb deleted file mode 100644 index 2698078..0000000 --- a/db/migrate/20251103130430_change_request_method_to_integer_in_events.rb +++ /dev/null @@ -1,74 +0,0 @@ -class ChangeRequestMethodToIntegerInEvents < ActiveRecord::Migration[8.1] - def change - # Convert enum columns from string to integer for proper enum support - reversible do |dir| - dir.up do - # Map request_method string values to enum integers - execute <<-SQL - UPDATE events - SET request_method = CASE - WHEN LOWER(request_method) = 'get' THEN '0' - WHEN LOWER(request_method) = 'post' THEN '1' - WHEN LOWER(request_method) = 'put' THEN '2' - WHEN LOWER(request_method) = 'patch' THEN '3' - WHEN LOWER(request_method) = 'delete' THEN '4' - WHEN LOWER(request_method) = 'head' THEN '5' - WHEN LOWER(request_method) = 'options' THEN '6' - ELSE '0' -- Default to GET for unknown values - END - WHERE request_method IS NOT NULL; - SQL - - # Map waf_action string values to enum integers - execute <<-SQL - UPDATE events - SET waf_action = CASE - WHEN LOWER(waf_action) = 'allow' THEN '0' - WHEN LOWER(waf_action) IN ('deny', 'block') THEN '1' - WHEN LOWER(waf_action) = 'redirect' THEN '2' - WHEN LOWER(waf_action) = 'challenge' THEN '3' - ELSE '0' -- Default to allow for unknown values - END - WHERE waf_action IS NOT NULL; - SQL - - # Change column types to integer - change_column :events, :request_method, :integer - change_column :events, :waf_action, :integer - end - - dir.down do - # Convert back to string values - change_column :events, :request_method, :string - change_column :events, :waf_action, :string - - execute <<-SQL - UPDATE events - SET request_method = CASE request_method - WHEN 0 THEN 'get' - WHEN 1 THEN 'post' - WHEN 2 THEN 'put' - WHEN 3 THEN 'patch' - WHEN 4 THEN 'delete' - WHEN 5 THEN 'head' - WHEN 6 THEN 'options' - ELSE 'get' -- Default to GET for unknown values - END - WHERE request_method IS NOT NULL; - SQL - - execute <<-SQL - UPDATE events - SET waf_action = CASE waf_action - WHEN 0 THEN 'allow' - WHEN 1 THEN 'deny' - WHEN 2 THEN 'redirect' - WHEN 3 THEN 'challenge' - ELSE 'allow' -- Default to allow for unknown values - END - WHERE waf_action IS NOT NULL; - SQL - end - end - end -end diff --git a/db/migrate/20251103225239_create_users.rb b/db/migrate/20251103225239_create_users.rb deleted file mode 100644 index 71f2ff1..0000000 --- a/db/migrate/20251103225239_create_users.rb +++ /dev/null @@ -1,11 +0,0 @@ -class CreateUsers < ActiveRecord::Migration[8.1] - def change - create_table :users do |t| - t.string :email_address, null: false - t.string :password_digest, null: false - - t.timestamps - end - add_index :users, :email_address, unique: true - end -end diff --git a/db/migrate/20251103225240_create_sessions.rb b/db/migrate/20251103225240_create_sessions.rb deleted file mode 100644 index ec9efdb..0000000 --- a/db/migrate/20251103225240_create_sessions.rb +++ /dev/null @@ -1,11 +0,0 @@ -class CreateSessions < ActiveRecord::Migration[8.1] - def change - create_table :sessions do |t| - t.references :user, null: false, foreign_key: true - t.string :ip_address - t.string :user_agent - - t.timestamps - end - end -end diff --git a/db/migrate/20251103225251_add_role_to_users.rb b/db/migrate/20251103225251_add_role_to_users.rb deleted file mode 100644 index c38592e..0000000 --- a/db/migrate/20251103225251_add_role_to_users.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddRoleToUsers < ActiveRecord::Migration[8.1] - def change - add_column :users, :role, :integer, default: 1, null: false - end -end diff --git a/db/schema.rb b/db/schema.rb index 5ba33b8..9928ffb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,10 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do +ActiveRecord::Schema[8.1].define(version: 7) do + # These are extensions that must be enabled in order to support this database + enable_extension "pg_catalog.plpgsql" + create_table "events", force: :cascade do |t| t.string "agent_name" t.string "agent_version" @@ -20,11 +23,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do t.datetime "created_at", null: false t.string "environment" t.string "event_id", null: false - t.string "ip_address" + t.inet "ip_address" t.json "payload" - t.integer "project_id", null: false - t.integer "request_host_id" - t.integer "request_method" + t.bigint "project_id", null: false + t.bigint "request_host_id" + t.integer "request_method", default: 0 t.string "request_path" t.string "request_protocol" t.string "request_segment_ids" @@ -35,13 +38,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do t.string "server_name" t.datetime "timestamp", null: false t.datetime "updated_at", null: false - t.text "user_agent" - t.integer "waf_action" + 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 ["ip_address"], name: "index_events_on_ip_address" - t.index ["project_id", "ip_address"], name: "index_events_on_project_id_and_ip_address" - t.index ["project_id", "timestamp"], name: "index_events_on_project_id_and_timestamp" - t.index ["project_id", "waf_action"], name: "index_events_on_project_id_and_waf_action" + t.index ["project_id", "ip_address"], name: "idx_events_project_ip" + t.index ["project_id", "timestamp"], name: "idx_events_project_time" + t.index ["project_id", "waf_action"], name: "idx_events_project_action" t.index ["project_id"], name: "index_events_on_project_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" @@ -50,52 +53,33 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do t.index ["waf_action"], name: "index_events_on_waf_action" end - create_table "ipv4_ranges", force: :cascade do |t| + create_table "network_ranges", force: :cascade do |t| t.text "abuser_scores" t.text "additional_data" t.integer "asn" t.string "asn_org" t.string "company" + t.string "country" t.datetime "created_at", null: false - t.string "geo2_country" - t.string "ip_api_country" + t.text "creation_reason" t.boolean "is_datacenter", default: false t.boolean "is_proxy", default: false t.boolean "is_vpn", default: false t.datetime "last_api_fetch" - t.integer "network_end", limit: 8, null: false - t.integer "network_prefix", null: false - t.integer "network_start", limit: 8, null: false + t.inet "network", null: false + t.string "source", default: "api_imported", null: false t.datetime "updated_at", null: false - t.index ["asn"], name: "idx_ipv4_asn" - t.index ["company"], name: "idx_ipv4_company" - t.index ["ip_api_country"], name: "idx_ipv4_country" - t.index ["is_datacenter", "is_proxy", "is_vpn"], name: "idx_ipv4_flags" - t.index ["network_start", "network_end", "network_prefix"], name: "idx_ipv4_range_lookup" - end - - create_table "ipv6_ranges", force: :cascade do |t| - t.text "abuser_scores" - t.text "additional_data" - t.integer "asn" - t.string "asn_org" - t.string "company" - t.datetime "created_at", null: false - t.string "geo2_country" - t.string "ip_api_country" - t.boolean "is_datacenter", default: false - t.boolean "is_proxy", default: false - t.boolean "is_vpn", default: false - t.datetime "last_api_fetch" - t.binary "network_end", limit: 16, null: false - t.integer "network_prefix", null: false - t.binary "network_start", limit: 16, null: false - t.datetime "updated_at", null: false - t.index ["asn"], name: "idx_ipv6_asn" - t.index ["company"], name: "idx_ipv6_company" - t.index ["ip_api_country"], name: "idx_ipv6_country" - t.index ["is_datacenter", "is_proxy", "is_vpn"], name: "idx_ipv6_flags" - t.index ["network_start", "network_end", "network_prefix"], name: "idx_ipv6_range_lookup" + t.bigint "user_id" + t.index ["asn"], name: "index_network_ranges_on_asn" + t.index ["asn_org"], name: "index_network_ranges_on_asn_org" + t.index ["company"], name: "index_network_ranges_on_company" + t.index ["country"], name: "index_network_ranges_on_country" + t.index ["is_datacenter", "is_proxy", "is_vpn"], name: "idx_network_flags" + 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 ["source"], name: "index_network_ranges_on_source" + t.index ["user_id"], name: "index_network_ranges_on_user_id" end create_table "path_segments", force: :cascade do |t| @@ -154,48 +138,38 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do t.index ["protocol"], name: "index_request_protocols_on_protocol", unique: true end - create_table "rule_sets", force: :cascade do |t| - t.datetime "created_at", null: false - t.text "description" - t.boolean "enabled" - t.string "name" - t.integer "priority" - t.json "projects" - t.json "projects_subscription" - t.json "rules" - t.string "slug" - t.datetime "updated_at", null: false - t.index ["enabled"], name: "index_rule_sets_on_enabled" - t.index ["priority"], name: "index_rule_sets_on_priority" - t.index ["slug"], name: "index_rule_sets_on_slug", unique: true - end - create_table "rules", force: :cascade do |t| - t.string "action" - t.json "conditions" + t.string "action", null: false + t.json "conditions", default: {} t.datetime "created_at", null: false - t.boolean "enabled" + t.boolean "enabled", default: true, null: false t.datetime "expires_at" - t.json "metadata" + t.json "metadata", default: {} + t.bigint "network_range_id" t.integer "priority" - t.string "rule_type" - t.string "source", limit: 100 - t.string "target" + t.string "rule_type", null: false + t.string "source", limit: 100, default: "manual" t.datetime "updated_at", null: false + t.bigint "user_id" + t.index ["action"], name: "index_rules_on_action" + t.index ["enabled", "expires_at"], name: "idx_rules_active" t.index ["enabled"], name: "index_rules_on_enabled" t.index ["expires_at"], name: "index_rules_on_expires_at" + 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"], name: "index_rules_on_source" t.index ["updated_at", "id"], name: "idx_rules_sync" + t.index ["user_id"], name: "index_rules_on_user_id" end create_table "sessions", force: :cascade do |t| t.datetime "created_at", null: false - t.string "ip_address" + t.inet "ip_address" t.datetime "updated_at", null: false t.string "user_agent" - t.integer "user_id", null: false + t.bigint "user_id", null: false t.index ["user_id"], name: "index_sessions_on_user_id" end @@ -210,5 +184,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do add_foreign_key "events", "projects" add_foreign_key "events", "request_hosts" + add_foreign_key "network_ranges", "users" + add_foreign_key "rules", "network_ranges" + add_foreign_key "rules", "users" add_foreign_key "sessions", "users" end diff --git a/docs/path-segment-architecture.md b/docs/path-segment-architecture.md index 2671a57..922bb3a 100644 --- a/docs/path-segment-architecture.md +++ b/docs/path-segment-architecture.md @@ -428,7 +428,8 @@ suspicious_paths = Event.where(waf_action: :deny) .pluck(:request_segment_ids) suspicious_paths.each do |seg_ids| - RuleSet.global.block_path_segments(seg_ids) + # TODO: Implement rule creation for blocking path segments + # Rule.create!(rule_type: 'path_pattern', conditions: { patterns: seg_ids }, action: 'deny') end ``` diff --git a/lib/tasks/users.rake b/lib/tasks/users.rake new file mode 100644 index 0000000..5a24596 --- /dev/null +++ b/lib/tasks/users.rake @@ -0,0 +1,102 @@ +namespace :users do + desc "Reset password for a user" + task reset_password: :environment do + email = ENV['EMAIL'] + new_password = ENV['PASSWORD'] + + if email.blank? + puts "Usage: EMAIL=user@example.com PASSWORD=newpassword rails users:reset_password" + exit 1 + end + + user = User.find_by(email_address: email) + if user.nil? + puts "Error: User with email '#{email}' not found." + exit 1 + end + + if new_password.blank? + puts "Error: PASSWORD environment variable is required." + exit 1 + end + + if user.password_digest.blank? + puts "Warning: User appears to be an OIDC user (no password set)." + print "Do you want to set a local password for this OIDC user? (y/N): " + response = STDIN.gets.chomp.downcase + unless response == 'y' || response == 'yes' + puts "Password reset cancelled." + exit 0 + end + end + + user.password = new_password + user.password_confirmation = new_password + + if user.save + # Destroy all sessions to force re-login + user.sessions.destroy_all + + puts "✅ Password successfully updated for #{user.email_address}" + puts " User: #{user.email_address} (#{user.role})" + puts " All existing sessions have been terminated." + puts " User will need to log in with the new password." + else + puts "❌ Failed to update password:" + user.errors.full_messages.each { |msg| puts " - #{msg}" } + exit 1 + end + end + + desc "List all users" + task list: :environment do + users = User.order(:role, :email_address) + + puts "Users (#{users.count}):" + puts "=" * 60 + users.each do |user| + has_password = user.password_digest.present? ? "local" : "OIDC" + last_login = user.sessions.maximum(:created_at) + + puts "📧 #{user.email_address}" + puts " Role: #{user.role} | Auth: #{has_password}" + puts " Last login: #{last_login ? last_login.strftime('%Y-%m-%d %H:%M') : 'Never'}" + puts " Active sessions: #{user.sessions.count}" + puts + end + end + + desc "Create admin user (only if no users exist)" + task create_admin: :environment do + if User.any? + puts "❌ Users already exist. Admin creation is disabled." + puts " Use 'rails users:reset_password' to reset an existing user's password." + exit 1 + end + + email = ENV['EMAIL'] + password = ENV['PASSWORD'] + + if email.blank? || password.blank? + puts "Usage: EMAIL=admin@example.com PASSWORD=securepassword rails users:create_admin" + exit 1 + end + + user = User.new( + email_address: email, + password: password, + password_confirmation: password + ) + + if user.save + puts "✅ Admin user created successfully:" + puts " Email: #{user.email_address}" + puts " Role: #{user.role}" + puts " You can now log in to the application." + else + puts "❌ Failed to create admin user:" + user.errors.full_messages.each { |msg| puts " - #{msg}" } + exit 1 + end + end +end \ No newline at end of file diff --git a/test/controllers/passwords_controller_test.rb b/test/controllers/passwords_controller_test.rb deleted file mode 100644 index e1a1b03..0000000 --- a/test/controllers/passwords_controller_test.rb +++ /dev/null @@ -1,67 +0,0 @@ -require "test_helper" - -class PasswordsControllerTest < ActionDispatch::IntegrationTest - setup { @user = User.take } - - test "new" do - get new_password_path - assert_response :success - end - - test "create" do - post passwords_path, params: { email_address: @user.email_address } - assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ] - assert_redirected_to new_session_path - - follow_redirect! - assert_notice "reset instructions sent" - end - - test "create for an unknown user redirects but sends no mail" do - post passwords_path, params: { email_address: "missing-user@example.com" } - assert_enqueued_emails 0 - assert_redirected_to new_session_path - - follow_redirect! - assert_notice "reset instructions sent" - end - - test "edit" do - get edit_password_path(@user.password_reset_token) - assert_response :success - end - - test "edit with invalid password reset token" do - get edit_password_path("invalid token") - assert_redirected_to new_password_path - - follow_redirect! - assert_notice "reset link is invalid" - end - - test "update" do - assert_changes -> { @user.reload.password_digest } do - put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" } - assert_redirected_to new_session_path - end - - follow_redirect! - assert_notice "Password has been reset" - end - - test "update with non matching passwords" do - token = @user.password_reset_token - assert_no_changes -> { @user.reload.password_digest } do - put password_path(token), params: { password: "no", password_confirmation: "match" } - assert_redirected_to edit_password_path(token) - end - - follow_redirect! - assert_notice "Passwords did not match" - end - - private - def assert_notice(text) - assert_select "div", /#{text}/ - end -end diff --git a/test/mailers/previews/passwords_mailer_preview.rb b/test/mailers/previews/passwords_mailer_preview.rb deleted file mode 100644 index 01d07ec..0000000 --- a/test/mailers/previews/passwords_mailer_preview.rb +++ /dev/null @@ -1,7 +0,0 @@ -# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer -class PasswordsMailerPreview < ActionMailer::Preview - # Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset - def reset - PasswordsMailer.reset(User.take) - end -end diff --git a/test/models/rule_set_test.rb b/test/models/rule_set_test.rb deleted file mode 100644 index 616301d..0000000 --- a/test/models/rule_set_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class RuleSetTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end