diff --git a/app/assets/stylesheets/tom-select.css b/app/assets/stylesheets/tom-select.css new file mode 100644 index 0000000..5634e64 --- /dev/null +++ b/app/assets/stylesheets/tom-select.css @@ -0,0 +1,412 @@ +/** + * tom-select.css (v2.3.1) + * Copyright (c) contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this + * file except in compliance with the License. You may obtain a copy of the License at: + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF + * ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + * + */ +.ts-control { + border: 1px solid #d0d0d0; + padding: 8px 8px; + width: 100%; + overflow: hidden; + position: relative; + z-index: 1; + box-sizing: border-box; + box-shadow: none; + border-radius: 3px; + display: flex; + flex-wrap: wrap; +} +.ts-wrapper.multi.has-items .ts-control { + padding: calc(8px - 2px - 0) 8px calc(8px - 2px - 3px - 0); +} +.full .ts-control { + background-color: #fff; +} +.disabled .ts-control, .disabled .ts-control * { + cursor: default !important; +} +.focus .ts-control { + box-shadow: none; +} +.ts-control > * { + vertical-align: baseline; + display: inline-block; +} +.ts-wrapper.multi .ts-control > div { + cursor: pointer; + margin: 0 3px 3px 0; + padding: 2px 6px; + background: #f2f2f2; + color: #303030; + border: 0 solid #d0d0d0; +} +.ts-wrapper.multi .ts-control > div.active { + background: #e8e8e8; + color: #303030; + border: 0 solid #cacaca; +} +.ts-wrapper.multi.disabled .ts-control > div, .ts-wrapper.multi.disabled .ts-control > div.active { + color: #7d7d7d; + background: white; + border: 0 solid white; +} +.ts-control > input { + flex: 1 1 auto; + min-width: 7rem; + display: inline-block !important; + padding: 0 !important; + min-height: 0 !important; + max-height: none !important; + max-width: 100% !important; + margin: 0 !important; + text-indent: 0 !important; + border: 0 none !important; + background: none !important; + line-height: inherit !important; + -webkit-user-select: auto !important; + -moz-user-select: auto !important; + -ms-user-select: auto !important; + user-select: auto !important; + box-shadow: none !important; +} +.ts-control > input::-ms-clear { + display: none; +} +.ts-control > input:focus { + outline: none !important; +} +.has-items .ts-control > input { + margin: 0 4px !important; +} +.ts-control.rtl { + text-align: right; +} +.ts-control.rtl.single .ts-control:after { + left: 15px; + right: auto; +} +.ts-control.rtl .ts-control > input { + margin: 0 4px 0 -2px !important; +} +.disabled .ts-control { + opacity: 0.5; + background-color: #fafafa; +} +.input-hidden .ts-control > input { + opacity: 0; + position: absolute; + left: -10000px; +} + +.ts-dropdown { + position: absolute; + top: 100%; + left: 0; + width: 100%; + z-index: 10; + border: 1px solid #d0d0d0; + background: #fff; + margin: 0.25rem 0 0; + border-top: 0 none; + box-sizing: border-box; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + border-radius: 0 0 3px 3px; +} +.ts-dropdown [data-selectable] { + cursor: pointer; + overflow: hidden; +} +.ts-dropdown [data-selectable] .highlight { + background: rgba(125, 168, 208, 0.2); + border-radius: 1px; +} +.ts-dropdown .option, +.ts-dropdown .optgroup-header, +.ts-dropdown .no-results, +.ts-dropdown .create { + padding: 5px 8px; +} +.ts-dropdown .option, .ts-dropdown [data-disabled], .ts-dropdown [data-disabled] [data-selectable].option { + cursor: inherit; + opacity: 0.5; +} +.ts-dropdown [data-selectable].option { + opacity: 1; + cursor: pointer; +} +.ts-dropdown .optgroup:first-child .optgroup-header { + border-top: 0 none; +} +.ts-dropdown .optgroup-header { + color: #303030; + background: #fff; + cursor: default; +} +.ts-dropdown .active { + background-color: #f5fafd; + color: #495c68; +} +.ts-dropdown .active.create { + color: #495c68; +} +.ts-dropdown .create { + color: rgba(48, 48, 48, 0.5); +} +.ts-dropdown .spinner { + display: inline-block; + width: 30px; + height: 30px; + margin: 5px 8px; +} +.ts-dropdown .spinner::after { + content: " "; + display: block; + width: 24px; + height: 24px; + margin: 3px; + border-radius: 50%; + border: 5px solid #d0d0d0; + border-color: #d0d0d0 transparent #d0d0d0 transparent; + animation: lds-dual-ring 1.2s linear infinite; +} +@keyframes lds-dual-ring { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.ts-dropdown-content { + overflow: hidden auto; + max-height: 200px; + scroll-behavior: smooth; +} + +.ts-wrapper.plugin-drag_drop .ts-dragging { + color: transparent !important; +} +.ts-wrapper.plugin-drag_drop .ts-dragging > * { + visibility: hidden !important; +} + +.plugin-checkbox_options:not(.rtl) .option input { + margin-right: 0.5rem; +} + +.plugin-checkbox_options.rtl .option input { + margin-left: 0.5rem; +} + +/* stylelint-disable function-name-case */ +.plugin-clear_button { + --ts-pr-clear-button: 1em; +} +.plugin-clear_button .clear-button { + opacity: 0; + position: absolute; + top: 50%; + transform: translateY(-50%); + right: calc(8px - 6px); + margin-right: 0 !important; + background: transparent !important; + transition: opacity 0.5s; + cursor: pointer; +} +.plugin-clear_button.form-select .clear-button, .plugin-clear_button.single .clear-button { + right: max(var(--ts-pr-caret), 8px); +} +.plugin-clear_button.focus.has-items .clear-button, .plugin-clear_button:not(.disabled):hover.has-items .clear-button { + opacity: 1; +} + +.ts-wrapper .dropdown-header { + position: relative; + padding: 10px 8px; + border-bottom: 1px solid #d0d0d0; + background: color-mix(#fff, #d0d0d0, 85%); + border-radius: 3px 3px 0 0; +} +.ts-wrapper .dropdown-header-close { + position: absolute; + right: 8px; + top: 50%; + color: #303030; + opacity: 0.4; + margin-top: -12px; + line-height: 20px; + font-size: 20px !important; +} +.ts-wrapper .dropdown-header-close:hover { + color: black; +} + +.plugin-dropdown_input.focus.dropdown-active .ts-control { + box-shadow: none; + border: 1px solid #d0d0d0; +} +.plugin-dropdown_input .dropdown-input { + border: 1px solid #d0d0d0; + border-width: 0 0 1px; + display: block; + padding: 8px 8px; + box-shadow: none; + width: 100%; + background: transparent; +} +.plugin-dropdown_input .items-placeholder { + border: 0 none !important; + box-shadow: none !important; + width: 100%; +} +.plugin-dropdown_input.has-items .items-placeholder, .plugin-dropdown_input.dropdown-active .items-placeholder { + display: none !important; +} + +.ts-wrapper.plugin-input_autogrow.has-items .ts-control > input { + min-width: 0; +} +.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input { + flex: none; + min-width: 4px; +} +.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::-ms-input-placeholder { + color: transparent; +} +.ts-wrapper.plugin-input_autogrow.has-items.focus .ts-control > input::placeholder { + color: transparent; +} + +.ts-dropdown.plugin-optgroup_columns .ts-dropdown-content { + display: flex; +} +.ts-dropdown.plugin-optgroup_columns .optgroup { + border-right: 1px solid #f2f2f2; + border-top: 0 none; + flex-grow: 1; + flex-basis: 0; + min-width: 0; +} +.ts-dropdown.plugin-optgroup_columns .optgroup:last-child { + border-right: 0 none; +} +.ts-dropdown.plugin-optgroup_columns .optgroup::before { + display: none; +} +.ts-dropdown.plugin-optgroup_columns .optgroup-header { + border-top: 0 none; +} + +.ts-wrapper.plugin-remove_button .item { + display: inline-flex; + align-items: center; +} +.ts-wrapper.plugin-remove_button .item .remove { + color: inherit; + text-decoration: none; + vertical-align: middle; + display: inline-block; + padding: 0 6px; + border-radius: 0 2px 2px 0; + box-sizing: border-box; +} +.ts-wrapper.plugin-remove_button .item .remove:hover { + background: rgba(0, 0, 0, 0.05); +} +.ts-wrapper.plugin-remove_button.disabled .item .remove:hover { + background: none; +} +.ts-wrapper.plugin-remove_button .remove-single { + position: absolute; + right: 0; + top: 0; + font-size: 23px; +} + +.ts-wrapper.plugin-remove_button:not(.rtl) .item { + padding-right: 0 !important; +} +.ts-wrapper.plugin-remove_button:not(.rtl) .item .remove { + border-left: 1px solid #d0d0d0; + margin-left: 6px; +} +.ts-wrapper.plugin-remove_button:not(.rtl) .item.active .remove { + border-left-color: #cacaca; +} +.ts-wrapper.plugin-remove_button:not(.rtl).disabled .item .remove { + border-left-color: white; +} + +.ts-wrapper.plugin-remove_button.rtl .item { + padding-left: 0 !important; +} +.ts-wrapper.plugin-remove_button.rtl .item .remove { + border-right: 1px solid #d0d0d0; + margin-right: 6px; +} +.ts-wrapper.plugin-remove_button.rtl .item.active .remove { + border-right-color: #cacaca; +} +.ts-wrapper.plugin-remove_button.rtl.disabled .item .remove { + border-right-color: white; +} + +:root { + --ts-pr-clear-button: 0; + --ts-pr-caret: 0; + --ts-pr-min: .75rem; +} + +.ts-wrapper.single .ts-control, .ts-wrapper.single .ts-control input { + cursor: pointer; +} + +.ts-control:not(.rtl) { + padding-right: max(var(--ts-pr-min), var(--ts-pr-clear-button) + var(--ts-pr-caret)) !important; +} + +.ts-control.rtl { + padding-left: max(var(--ts-pr-min), var(--ts-pr-clear-button) + var(--ts-pr-caret)) !important; +} + +.ts-wrapper { + position: relative; +} + +.ts-dropdown, +.ts-control, +.ts-control input { + color: #303030; + font-family: inherit; + font-size: 13px; + line-height: 18px; +} + +.ts-control, +.ts-wrapper.single.input-active .ts-control { + background: #fff; + cursor: text; +} + +.ts-hidden-accessible { + border: 0 !important; + clip: rect(0 0 0 0) !important; + -webkit-clip-path: inset(50%) !important; + clip-path: inset(50%) !important; + overflow: hidden !important; + padding: 0 !important; + position: absolute !important; + width: 1px !important; + white-space: nowrap !important; +} +/*# sourceMappingURL=tom-select.css.map */ \ No newline at end of file diff --git a/app/models/event.rb b/app/models/event.rb index 1c4f1fc..c68e338 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -9,11 +9,13 @@ class Event < ApplicationRecord has_one :waf_policy, through: :rule # Enums for fixed value sets + # Canonical WAF action order - aligned with Rule and Agent models enum :waf_action, { - allow: 0, # allow/pass - deny: 1, # deny/block + deny: 0, # deny/block + allow: 1, # allow/pass redirect: 2, # redirect - challenge: 3 # challenge (future implementation) + challenge: 3, # challenge (CAPTCHA, JS challenge, etc.) + log: 4 # log only, no action (monitoring mode) }, default: :allow, scopes: false enum :request_method, { @@ -42,7 +44,7 @@ class Event < ApplicationRecord scope :by_waf_action, ->(waf_action) { where(waf_action: waf_action) } scope :blocked, -> { where(waf_action: :deny) } scope :allowed, -> { where(waf_action: :allow) } - scope :rate_limited, -> { where(waf_action: 'rate_limit') } + scope :logged, -> { where(waf_action: :log) } # Tag-based filtering scopes using PostgreSQL array operators scope :with_tag, ->(tag) { where("tags @> ARRAY[?]", tag.to_s) } @@ -346,8 +348,8 @@ class Event < ApplicationRecord waf_action.in?(['allow', 'pass']) end - def rate_limited? - waf_action == 'rate_limit' + def logged? + waf_action == 'log' end def challenged? diff --git a/app/models/rule.rb b/app/models/rule.rb index 3881ab2..58ac6f1 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -6,12 +6,10 @@ # Network rules are associated with NetworkRange objects for rich context. class Rule < ApplicationRecord # Rule enums (prefix needed to avoid rate_limit collision) - enum :waf_action, { allow: 0, deny: 1, rate_limit: 2, redirect: 3, log: 4, challenge: 5 }, prefix: :action + # Canonical WAF action order - aligned with Agent and Event models + enum :waf_action, { deny: 0, allow: 1, redirect: 2, challenge: 3, log: 4 }, prefix: :action enum :waf_rule_type, { network: 0, rate_limit: 1, path_pattern: 2 }, prefix: :type - # Legacy string constants for backward compatibility - RULE_TYPES = %w[network rate_limit path_pattern].freeze - ACTIONS = %w[allow deny rate_limit redirect log challenge].freeze SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default manual:surgical_block manual:surgical_exception policy].freeze # Associations @@ -27,14 +25,6 @@ class Rule < ApplicationRecord validates :enabled, inclusion: { in: [true, false] } validates :source, inclusion: { in: SOURCES } - # Legacy enum definitions (disabled to prevent conflicts) - # enum :action, { allow: "allow", deny: "deny", rate_limit: "rate_limit", redirect: "redirect", log: "log", challenge: "challenge" }, scopes: false - # enum :rule_type, { network: "network", rate_limit: "rate_limit", path_pattern: "path_pattern" }, scopes: false - - # Legacy validations for backward compatibility during transition - # validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }, allow_nil: true - # validates :action, presence: true, inclusion: { in: ACTIONS }, allow_nil: true - # Custom validations validate :validate_conditions_by_type validate :validate_metadata_by_action @@ -356,12 +346,12 @@ class Rule < ApplicationRecord [block_rule, exception_rule] end - def self.create_rate_limit_rule(cidr, limit:, window:, user: nil, **options) + def self.create_rate_limit_rule(cidr, limit:, window:, user: nil, action: 'deny', **options) network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created') create!( waf_rule_type: 'rate_limit', - waf_action: 'rate_limit', + waf_action: action, # Action to take when rate limit exceeded (deny, redirect, challenge, log) network_range: network_range, conditions: { cidr: cidr, scope: 'ip' }, metadata: { @@ -514,10 +504,6 @@ class Rule < ApplicationRecord if challenge_type_value && !%w[captcha javascript proof_of_work].include?(challenge_type_value) errors.add(:metadata, "challenge_type must be one of: captcha, javascript, proof_of_work") end - when "rate_limit" - unless metadata&.dig("limit").present? && metadata&.dig("window").present? - errors.add(:metadata, "must include 'limit' and 'window' for rate_limit action") - end end end diff --git a/app/services/event_normalizer.rb b/app/services/event_normalizer.rb index 3152dee..89cd814 100644 --- a/app/services/event_normalizer.rb +++ b/app/services/event_normalizer.rb @@ -41,10 +41,11 @@ class EventNormalizer return unless raw_action.present? action_enum = case raw_action.to_s.downcase - when 'allow', 'pass' then :allow when 'deny', 'block' then :deny - when 'challenge' then :challenge + when 'allow', 'pass' then :allow when 'redirect' then :redirect + when 'challenge' then :challenge + when 'log', 'monitor' then :log else :allow end diff --git a/app/services/path_rule_matcher.rb b/app/services/path_rule_matcher.rb new file mode 100644 index 0000000..7f0ec2e --- /dev/null +++ b/app/services/path_rule_matcher.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# PathRuleMatcher - Service to match Events against path_pattern Rules +# +# This service provides path pattern matching logic for evaluating whether +# an event matches a path_pattern rule. Used for hub-side testing and validation +# before agent deployment. +# +# Match Types: +# - exact: All segments must match exactly +# - prefix: Event path must start with rule segments +# - suffix: Event path must end with rule segments +# - contains: Rule segments must appear consecutively somewhere in event path +class PathRuleMatcher + def self.matches?(rule, event) + return false unless rule.path_pattern_rule? + return false if event.request_segment_ids.blank? + + rule_segments = rule.path_segment_ids + event_segments = event.request_segment_ids + + return false if rule_segments.blank? + + case rule.path_match_type + when 'exact' + exact_match?(event_segments, rule_segments) + when 'prefix' + prefix_match?(event_segments, rule_segments) + when 'suffix' + suffix_match?(event_segments, rule_segments) + when 'contains' + contains_match?(event_segments, rule_segments) + else + false + end + end + + # Find all path_pattern rules that match the given event + def self.matching_rules(event) + return [] if event.request_segment_ids.blank? + + Rule.path_pattern_rules.active.select do |rule| + matches?(rule, event) + end + end + + # Evaluate an event against path rules and return the first matching action + def self.evaluate(event) + matching_rule = matching_rules(event).first + matching_rule&.waf_action || 'allow' + end + + private + + # Exact match: all segments must match exactly + # Example: [1, 2, 3] matches [1, 2, 3] only + def self.exact_match?(event_segments, rule_segments) + event_segments == rule_segments + end + + # Prefix match: event path must start with rule segments + # Example: rule [1, 2] matches events [1, 2], [1, 2, 3], [1, 2, 3, 4] + def self.prefix_match?(event_segments, rule_segments) + return false if event_segments.length < rule_segments.length + event_segments[0...rule_segments.length] == rule_segments + end + + # Suffix match: event path must end with rule segments + # Example: rule [2, 3] matches events [2, 3], [1, 2, 3], [0, 1, 2, 3] + def self.suffix_match?(event_segments, rule_segments) + return false if event_segments.length < rule_segments.length + event_segments[-rule_segments.length..-1] == rule_segments + end + + # Contains match: rule segments must appear consecutively somewhere in event path + # Example: rule [2, 3] matches [1, 2, 3, 4], [2, 3], [0, 2, 3, 5] + def self.contains_match?(event_segments, rule_segments) + return false if event_segments.length < rule_segments.length + + # Check if rule_segments appear consecutively anywhere in event_segments + (0..event_segments.length - rule_segments.length).any? do |i| + event_segments[i, rule_segments.length] == rule_segments + end + end +end diff --git a/app/views/analytics/index.html.erb b/app/views/analytics/index.html.erb index 6de5009..b36fbd8 100644 --- a/app/views/analytics/index.html.erb +++ b/app/views/analytics/index.html.erb @@ -302,8 +302,15 @@ <% @recent_events.first(3).each do |event| %>