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 %>