diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index aafb680..b5117c7 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -9,6 +9,8 @@ * Consider organizing styles into separate files for maintainability. */ +@import "tom-select.css"; + /* JSON Validator Styles */ .json-valid { border-color: #10b981 !important; diff --git a/app/controllers/api/events_controller.rb b/app/controllers/api/events_controller.rb index e76a329..83374b7 100644 --- a/app/controllers/api/events_controller.rb +++ b/app/controllers/api/events_controller.rb @@ -46,8 +46,23 @@ class Api::EventsController < ApplicationController rules = Rule.active.sync_order end - response_data[:rules] = rules.map(&:to_agent_format) + agent_rules = rules.map(&:to_agent_format) + response_data[:rules] = agent_rules response_data[:rules_changed] = true + + # Include path segments dictionary for path_pattern rules + path_segment_ids = agent_rules + .select { |r| r[:waf_rule_type] == 'path_pattern' } + .flat_map { |r| r.dig(:conditions, :segment_ids) } + .compact + .uniq + + if path_segment_ids.any? + response_data[:path_segments] = PathSegment + .where(id: path_segment_ids) + .pluck(:id, :segment) + .to_h + end else response_data[:rules_changed] = false end diff --git a/app/controllers/rules_controller.rb b/app/controllers/rules_controller.rb index ac111fe..4014b45 100644 --- a/app/controllers/rules_controller.rb +++ b/app/controllers/rules_controller.rb @@ -189,6 +189,38 @@ end def process_quick_create_parameters return unless @rule + # Handle path pattern parameters + if @rule.path_pattern_rule? && params[:path_pattern].present? && params[:match_type].present? + begin + pattern = params[:path_pattern] + match_type = params[:match_type] + + # Parse pattern to segments + segments = pattern.split('/').reject(&:blank?).map(&:downcase) + + # Find or create PathSegment entries + segment_ids = segments.map do |seg| + PathSegment.find_or_create_segment(seg).id + end + + # Set conditions with segment IDs and match type + @rule.conditions = { + segment_ids: segment_ids, + match_type: match_type, + original_pattern: pattern + } + + # Add to metadata for display + @rule.metadata ||= {} + @rule.metadata.merge!({ + segments: segments, + pattern_display: "/" + segments.join("/") + }) + rescue => e + @rule.errors.add(:base, "Failed to create path pattern: #{e.message}") + end + end + # Handle rate limiting parameters if @rule.rate_limit_rule? && params[:rate_limit].present? && params[:rate_window].present? rate_limit_data = { @@ -322,55 +354,4 @@ end @rule.priority = 50 # Default priority end end - - def process_quick_create_parameters - return unless @rule - - # Handle rate limiting parameters - if @rule.rate_limit_rule? && params[:rate_limit].present? && params[:rate_window].present? - rate_limit_data = { - limit: params[:rate_limit].to_i, - window_seconds: params[:rate_window].to_i, - scope: 'per_ip' - } - - # Update conditions with rate limit data - @rule.conditions ||= {} - @rule.conditions.merge!(rate_limit_data) - end - - # Handle redirect URL - if @rule.redirect_action? && params[:redirect_url].present? - @rule.metadata ||= {} - if @rule.metadata.is_a?(String) - begin - @rule.metadata = JSON.parse(@rule.metadata) - rescue JSON::ParserError - @rule.metadata = {} - end - end - @rule.metadata.merge!({ - redirect_url: params[:redirect_url], - redirect_status: 302 - }) - end - - # Parse metadata if it's a string that looks like JSON - if @rule.metadata.is_a?(String) && @rule.metadata.starts_with?('{') - begin - @rule.metadata = JSON.parse(@rule.metadata) - rescue JSON::ParserError - # Keep as string if not valid JSON - end - end - - # Add reason to metadata if provided - if params.dig(:rule, :metadata).present? - if @rule.metadata.is_a?(Hash) - @rule.metadata['reason'] = params[:rule][:metadata] - else - @rule.metadata = { 'reason' => params[:rule][:metadata] } - end - end - end end \ No newline at end of file diff --git a/app/models/rule.rb b/app/models/rule.rb index b4bcbe1..3881ab2 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -236,9 +236,36 @@ class Rule < ApplicationRecord end end + # For path_pattern rules, include segment IDs and match type + if path_pattern_rule? + format[:conditions] = { + segment_ids: path_segment_ids, + match_type: path_match_type + } + end + format end + # Path pattern rule helper methods + def path_segment_ids + conditions&.dig("segment_ids") || [] + end + + def path_match_type + conditions&.dig("match_type") + end + + def path_segments_text + return [] if path_segment_ids.empty? + PathSegment.where(id: path_segment_ids).order(:id).pluck(:segment) + end + + def path_pattern_display + return nil unless path_pattern_rule? + "/" + path_segments_text.join("/") + end + # Class methods for rule creation def self.create_network_rule(cidr, action: 'deny', user: nil, **options) network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created') @@ -252,6 +279,42 @@ class Rule < ApplicationRecord ) end + def self.create_path_pattern_rule(pattern:, match_type:, action: 'deny', user: nil, **options) + # Parse pattern string to segments (case-insensitive) + segments = pattern.split('/').reject(&:blank?).map(&:downcase) + + if segments.empty? + raise ArgumentError, "Pattern must contain at least one path segment" + end + + unless %w[exact prefix suffix contains].include?(match_type) + raise ArgumentError, "Match type must be one of: exact, prefix, suffix, contains" + end + + # Find or create PathSegment entries + segment_ids = segments.map do |seg| + PathSegment.find_or_create_segment(seg).id + end + + create!( + waf_rule_type: 'path_pattern', + waf_action: action, + conditions: { + segment_ids: segment_ids, + match_type: match_type, + original_pattern: pattern + }, + metadata: { + segments: segments, + pattern_display: "/" + segments.join("/") + }, + user: user, + source: options[:source] || 'manual', + priority: options[:priority] || 50, + **options.except(:source, :priority) + ) + end + def self.create_surgical_block(ip_address, parent_cidr, user: nil, reason: nil, **options) # Create block rule for parent range network_range = NetworkRange.find_or_create_by_cidr(parent_cidr, user: user, source: 'user_created') @@ -418,10 +481,24 @@ class Rule < ApplicationRecord end def validate_path_pattern_conditions - patterns = conditions&.dig("patterns") + segment_ids = conditions&.dig("segment_ids") + match_type = conditions&.dig("match_type") - if patterns.blank? || !patterns.is_a?(Array) - errors.add(:conditions, "must include 'patterns' array for path_pattern rules") + if segment_ids.blank? || !segment_ids.is_a?(Array) + errors.add(:conditions, "must include 'segment_ids' array for path_pattern rules") + end + + unless %w[exact prefix suffix contains].include?(match_type) + errors.add(:conditions, "match_type must be one of: exact, prefix, suffix, contains") + end + + # Validate that all segment IDs exist + if segment_ids.is_a?(Array) && segment_ids.any? + existing_ids = PathSegment.where(id: segment_ids).pluck(:id) + missing_ids = segment_ids - existing_ids + if missing_ids.any? + errors.add(:conditions, "references non-existent path segment IDs: #{missing_ids.join(', ')}") + end end end diff --git a/app/models/waf_policy.rb b/app/models/waf_policy.rb index 4cd0dec..ff39972 100644 --- a/app/models/waf_policy.rb +++ b/app/models/waf_policy.rb @@ -6,7 +6,7 @@ # generate specific Rules when matching network ranges are discovered. class WafPolicy < ApplicationRecord # Policy types - different categories of blocking rules - POLICY_TYPES = %w[country asn company network_type].freeze + POLICY_TYPES = %w[country asn company network_type path_pattern].freeze # Actions - what to do when traffic matches this policy ACTIONS = %w[allow deny redirect challenge].freeze @@ -57,6 +57,10 @@ validate :targets_must_be_array policy_type == 'network_type' end + def path_pattern_policy? + policy_type == 'path_pattern' + end + # Action methods def allow_action? policy_action == 'allow' @@ -130,6 +134,21 @@ validate :targets_must_be_array end end + # Event matching methods (for path patterns) + def matches_event?(event) + return false unless active? + + case policy_type + when 'country', 'asn', 'company', 'network_type' + # For network-based policies, use the event's network range + event.network_range && matches_network_range?(event.network_range) + when 'path_pattern' + matches_path_patterns?(event) + else + false + end + end + def create_rule_for_network_range(network_range) return nil unless matches_network_range?(network_range) @@ -164,6 +183,59 @@ validate :targets_must_be_array rule end + def create_rule_for_event(event) + return nil unless matches_event?(event) + + # For path pattern policies, create a path_pattern rule + if path_pattern_policy? + # Check for existing path_pattern rule with same policy and patterns + existing_rule = Rule.find_by( + waf_rule_type: 'path_pattern', + waf_action: policy_action, + waf_policy: self, + enabled: true + ) + + if existing_rule + Rails.logger.debug "Path pattern rule already exists for policy #{name}" + return existing_rule + end + + rule = Rule.create!( + waf_rule_type: 'path_pattern', + waf_action: policy_action, + waf_policy: self, + user: user, + source: "policy", + conditions: build_path_pattern_conditions(event), + metadata: build_path_pattern_metadata(event), + priority: 50 # Default priority for path rules + ) + + # Handle redirect/challenge specific data + if redirect_action? && additional_data['redirect_url'] + rule.update!( + metadata: rule.metadata.merge( + redirect_url: additional_data['redirect_url'], + redirect_status: additional_data['redirect_status'] || 302 + ) + ) + elsif challenge_action? + rule.update!( + metadata: rule.metadata.merge( + challenge_type: additional_data['challenge_type'] || 'captcha', + challenge_message: additional_data['challenge_message'] + ) + ) + end + + rule + else + # For network-based policies, fall back to network range rule creation + create_rule_for_network_range(event.network_range) + end + end + # Class methods for creating common policies def self.create_country_policy(countries, policy_action: 'deny', user:, **options) create!( @@ -209,6 +281,17 @@ validate :targets_must_be_array ) end + def self.create_path_pattern_policy(patterns, policy_action: 'deny', user:, **options) + create!( + name: "#{policy_action.capitalize} path patterns: #{Array(patterns).join(', ')}", + policy_type: 'path_pattern', + targets: Array(patterns), + policy_action: policy_action, + user: user, + **options + ) + end + # Redirect/challenge specific methods def redirect_url additional_data&.dig('redirect_url') @@ -283,6 +366,8 @@ validate :targets_must_be_array validate_company_targets when 'network_type' validate_network_type_targets + when 'path_pattern' + validate_path_pattern_targets end end @@ -311,6 +396,24 @@ validate :targets_must_be_array end end + def validate_path_pattern_targets + unless targets.all? { |target| target.is_a?(String) && target.present? } + errors.add(:targets, "must be valid path pattern strings") + end + + # Validate path patterns format (basic validation) + targets.each do |pattern| + begin + # Basic validation - ensure it's a reasonable pattern + unless pattern.match?(/\A[a-zA-Z0-9\-\._\*\/\?\[\]\{\}]+\z/) + errors.add(:targets, "contains invalid characters in pattern: #{pattern}") + end + rescue => e + errors.add(:targets, "invalid path pattern: #{pattern} - #{e.message}") + end + end + end + def validate_redirect_configuration if additional_data['redirect_url'].blank? errors.add(:additional_data, "must include 'redirect_url' for redirect action") @@ -413,4 +516,62 @@ validate :targets_must_be_array types.join(',') || 'standard' end end + + # Path pattern matching methods + def matches_path_patterns?(event) + return false if event.request_path.blank? + + path = event.request_path.downcase + targets.any? { |pattern| matches_path_pattern?(pattern, path) } + end + + def matches_path_pattern?(pattern, path) + pattern = pattern.downcase + + # Handle different pattern types + case pattern + when /\*/, /\?/, /\[/ + # Glob patterns - simple matching + match_glob_pattern(pattern, path) + when /\.php$/, /\.exe$/, /\.js$/ + # File extension patterns + path.end_with?(pattern) + when /\A\// + # Exact path match + path == pattern + else + # Simple substring match + path.include?(pattern) + end + end + + def match_glob_pattern(pattern, path) + # Convert simple glob patterns to regex + regex_pattern = pattern + .gsub('*', '.*') + .gsub('?', '.') + .gsub('[', '\[') + .gsub(']', '\]') + + path.match?(/\A#{regex_pattern}\z/) + end + + def build_path_pattern_conditions(event) + { + "patterns" => targets, + "match_type" => "path_pattern" + } + end + + def build_path_pattern_metadata(event) + base_metadata = { + generated_by_policy: id, + policy_name: name, + policy_type: policy_type, + matched_path: event.request_path, + generated_from: "event" + } + + base_metadata.merge!(additional_data || {}) + end end diff --git a/app/policies/waf_policy_policy.rb b/app/policies/waf_policy_policy.rb index 63ca1f8..7e4d965 100644 --- a/app/policies/waf_policy_policy.rb +++ b/app/policies/waf_policy_policy.rb @@ -37,6 +37,16 @@ class WafPolicyPolicy < ApplicationPolicy !user.viewer? # All authenticated users except viewers can deactivate policies end + # Path pattern policy permissions + def new_path_pattern? + create? + end + + def create_path_pattern? + create? + end + + # Country policy permissions def new_country? create? end diff --git a/app/services/waf_policy_matcher.rb b/app/services/waf_policy_matcher.rb index f852c81..0ed62c5 100644 --- a/app/services/waf_policy_matcher.rb +++ b/app/services/waf_policy_matcher.rb @@ -30,17 +30,19 @@ class WafPolicyMatcher policy.matches_event?(event) end - # Sort by priority: country > asn > company > network_type, then by creation date + # Sort by priority: path_pattern > country > asn > company > network_type, then by creation date @matching_policies.sort_by do |policy| priority_score = case policy.policy_type + when 'path_pattern' + 1 # Highest priority for path-specific rules when 'country' - 1 - when 'asn' 2 - when 'company' + when 'asn' 3 - when 'network_type' + when 'company' 4 + when 'network_type' + 5 else 99 end @@ -54,22 +56,21 @@ class WafPolicyMatcher return [] if matching_policies.empty? @generated_rules = matching_policies.map do |policy| - # Check if rule already exists for this network range and policy - existing_rule = Rule.find_by( - network_range: network_range, - waf_policy: policy, - enabled: true - ) + # Use the policy's event-based rule creation method + rule = policy.create_rule_for_event(event) - if existing_rule - Rails.logger.debug "Rule already exists for network_range #{network_range.cidr} and policy #{policy.name}" - existing_rule - else - rule = policy.create_rule_for_network_range(network_range) - if rule - Rails.logger.info "Generated rule for network_range #{network_range.cidr} from policy #{policy.name}" + if rule + if rule.persisted? + Rails.logger.info "Generated rule for event #{event.id} from policy #{policy.name}" + rule + else + # Rule creation failed validation + Rails.logger.warn "Failed to create rule for event #{event.id}: #{rule.errors.full_messages.join(', ')}" + nil end - rule + else + # Policy didn't match or returned nil (e.g., supernet already exists) + nil end end.compact end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 8b956f3..1771240 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -21,9 +21,6 @@ <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> - <%# Tom Select CSS for enhanced multi-select %> - - <%= javascript_importmap_tags %>