Compare commits
5 Commits
path-match
...
de1cf0b237
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1cf0b237 | ||
|
|
4964d1a190 | ||
|
|
5d3e35a4ac | ||
|
|
830810305b | ||
| 093ee71c9f |
412
app/assets/stylesheets/tom-select.css
Normal file
412
app/assets/stylesheets/tom-select.css
Normal file
@@ -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 */
|
||||||
@@ -45,15 +45,7 @@ class AnalyticsController < ApplicationController
|
|||||||
Event.where("timestamp >= ?", @start_time)
|
Event.where("timestamp >= ?", @start_time)
|
||||||
.group(:waf_action)
|
.group(:waf_action)
|
||||||
.count
|
.count
|
||||||
.transform_keys do |action_id|
|
# Keys are already strings ("allow", "deny", etc.) from the enum
|
||||||
case action_id
|
|
||||||
when 0 then 'allow'
|
|
||||||
when 1 then 'deny'
|
|
||||||
when 2 then 'redirect'
|
|
||||||
when 3 then 'challenge'
|
|
||||||
else 'unknown'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Top countries by event count - cached (now uses denormalized country column)
|
# Top countries by event count - cached (now uses denormalized country column)
|
||||||
@@ -151,7 +143,7 @@ class AnalyticsController < ApplicationController
|
|||||||
# ASN breakdown (using denormalized asn columns)
|
# ASN breakdown (using denormalized asn columns)
|
||||||
@top_asns = Event.where("timestamp >= ? AND asn IS NOT NULL", @start_time)
|
@top_asns = Event.where("timestamp >= ? AND asn IS NOT NULL", @start_time)
|
||||||
.group(:asn, :asn_org)
|
.group(:asn, :asn_org)
|
||||||
.select("asn, asn_org, COUNT(*) as event_count, COUNT(DISTINCT ip_address) as unique_ips")
|
.select("asn, asn_org, COUNT(*) as event_count, COUNT(DISTINCT ip_address) as unique_ips, COUNT(DISTINCT network_range_id) as network_count")
|
||||||
.order("event_count DESC")
|
.order("event_count DESC")
|
||||||
.limit(15)
|
.limit(15)
|
||||||
|
|
||||||
|
|||||||
@@ -245,8 +245,10 @@ class NetworkRangesController < ApplicationController
|
|||||||
if network_range.persisted?
|
if network_range.persisted?
|
||||||
# Real network - use cached events_count for total requests (much more performant)
|
# Real network - use cached events_count for total requests (much more performant)
|
||||||
if network_range.events_count > 0
|
if network_range.events_count > 0
|
||||||
# Base query for consistent IP containment logic
|
# Use indexed network_range_id for much better performance instead of expensive CIDR operator
|
||||||
base_query = Event.where("ip_address <<= ?", network_range.cidr)
|
# Include child network ranges to capture all traffic within this network block
|
||||||
|
network_ids = [network_range.id] + network_range.child_ranges.pluck(:id)
|
||||||
|
base_query = Event.where(network_range_id: network_ids)
|
||||||
|
|
||||||
# Use separate queries: one for grouping (without ordering), one for recent activity (with ordering)
|
# Use separate queries: one for grouping (without ordering), one for recent activity (with ordering)
|
||||||
events_for_grouping = base_query.limit(1000)
|
events_for_grouping = base_query.limit(1000)
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ class Event < ApplicationRecord
|
|||||||
has_one :waf_policy, through: :rule
|
has_one :waf_policy, through: :rule
|
||||||
|
|
||||||
# Enums for fixed value sets
|
# Enums for fixed value sets
|
||||||
|
# Canonical WAF action order - aligned with Rule and Agent models
|
||||||
enum :waf_action, {
|
enum :waf_action, {
|
||||||
allow: 0, # allow/pass
|
deny: 0, # deny/block
|
||||||
deny: 1, # deny/block
|
allow: 1, # allow/pass
|
||||||
redirect: 2, # redirect
|
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
|
}, default: :allow, scopes: false
|
||||||
|
|
||||||
enum :request_method, {
|
enum :request_method, {
|
||||||
@@ -42,7 +44,7 @@ class Event < ApplicationRecord
|
|||||||
scope :by_waf_action, ->(waf_action) { where(waf_action: waf_action) }
|
scope :by_waf_action, ->(waf_action) { where(waf_action: waf_action) }
|
||||||
scope :blocked, -> { where(waf_action: :deny) }
|
scope :blocked, -> { where(waf_action: :deny) }
|
||||||
scope :allowed, -> { where(waf_action: :allow) }
|
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
|
# Tag-based filtering scopes using PostgreSQL array operators
|
||||||
scope :with_tag, ->(tag) { where("tags @> ARRAY[?]", tag.to_s) }
|
scope :with_tag, ->(tag) { where("tags @> ARRAY[?]", tag.to_s) }
|
||||||
@@ -346,8 +348,8 @@ class Event < ApplicationRecord
|
|||||||
waf_action.in?(['allow', 'pass'])
|
waf_action.in?(['allow', 'pass'])
|
||||||
end
|
end
|
||||||
|
|
||||||
def rate_limited?
|
def logged?
|
||||||
waf_action == 'rate_limit'
|
waf_action == 'log'
|
||||||
end
|
end
|
||||||
|
|
||||||
def challenged?
|
def challenged?
|
||||||
|
|||||||
@@ -6,12 +6,10 @@
|
|||||||
# Network rules are associated with NetworkRange objects for rich context.
|
# Network rules are associated with NetworkRange objects for rich context.
|
||||||
class Rule < ApplicationRecord
|
class Rule < ApplicationRecord
|
||||||
# Rule enums (prefix needed to avoid rate_limit collision)
|
# 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
|
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
|
SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default manual:surgical_block manual:surgical_exception policy].freeze
|
||||||
|
|
||||||
# Associations
|
# Associations
|
||||||
@@ -27,14 +25,6 @@ class Rule < ApplicationRecord
|
|||||||
validates :enabled, inclusion: { in: [true, false] }
|
validates :enabled, inclusion: { in: [true, false] }
|
||||||
validates :source, inclusion: { in: SOURCES }
|
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
|
# Custom validations
|
||||||
validate :validate_conditions_by_type
|
validate :validate_conditions_by_type
|
||||||
validate :validate_metadata_by_action
|
validate :validate_metadata_by_action
|
||||||
@@ -356,12 +346,12 @@ class Rule < ApplicationRecord
|
|||||||
[block_rule, exception_rule]
|
[block_rule, exception_rule]
|
||||||
end
|
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')
|
network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created')
|
||||||
|
|
||||||
create!(
|
create!(
|
||||||
waf_rule_type: 'rate_limit',
|
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,
|
network_range: network_range,
|
||||||
conditions: { cidr: cidr, scope: 'ip' },
|
conditions: { cidr: cidr, scope: 'ip' },
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -514,10 +504,6 @@ class Rule < ApplicationRecord
|
|||||||
if challenge_type_value && !%w[captcha javascript proof_of_work].include?(challenge_type_value)
|
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")
|
errors.add(:metadata, "challenge_type must be one of: captcha, javascript, proof_of_work")
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,9 @@ class Setting < ApplicationRecord
|
|||||||
def self.ipapi_key
|
def self.ipapi_key
|
||||||
get('ipapi_key', ENV['IPAPI_KEY'])
|
get('ipapi_key', ENV['IPAPI_KEY'])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Convenience method for event retention days (default: 90 days)
|
||||||
|
def self.event_retention_days
|
||||||
|
get('event_retention_days', '90').to_i
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -41,10 +41,11 @@ class EventNormalizer
|
|||||||
return unless raw_action.present?
|
return unless raw_action.present?
|
||||||
|
|
||||||
action_enum = case raw_action.to_s.downcase
|
action_enum = case raw_action.to_s.downcase
|
||||||
when 'allow', 'pass' then :allow
|
|
||||||
when 'deny', 'block' then :deny
|
when 'deny', 'block' then :deny
|
||||||
when 'challenge' then :challenge
|
when 'allow', 'pass' then :allow
|
||||||
when 'redirect' then :redirect
|
when 'redirect' then :redirect
|
||||||
|
when 'challenge' then :challenge
|
||||||
|
when 'log', 'monitor' then :log
|
||||||
else :allow
|
else :allow
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
85
app/services/path_rule_matcher.rb
Normal file
85
app/services/path_rule_matcher.rb
Normal file
@@ -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
|
||||||
@@ -302,8 +302,15 @@
|
|||||||
<% @recent_events.first(3).each do |event| %>
|
<% @recent_events.first(3).each do |event| %>
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="w-2 h-2 rounded-full mr-2
|
<% dot_color = case event.waf_action
|
||||||
<%= event.waf_action == 'allow' ? 'bg-green-500' : 'bg-red-500' %>"></div>
|
when 'allow' then 'bg-green-500'
|
||||||
|
when 'deny' then 'bg-red-500'
|
||||||
|
when 'redirect' then 'bg-blue-500'
|
||||||
|
when 'challenge' then 'bg-yellow-500'
|
||||||
|
when 'log' then 'bg-gray-500'
|
||||||
|
else 'bg-gray-500'
|
||||||
|
end %>
|
||||||
|
<div class="w-2 h-2 rounded-full mr-2 <%= dot_color %>"></div>
|
||||||
<span class="text-gray-900 truncate max-w-[120px]"><%= event.ip_address %></span>
|
<span class="text-gray-900 truncate max-w-[120px]"><%= event.ip_address %></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-gray-500"><%= time_ago_in_words(event.timestamp) %> ago</span>
|
<span class="text-gray-500"><%= time_ago_in_words(event.timestamp) %> ago</span>
|
||||||
|
|||||||
@@ -119,8 +119,15 @@
|
|||||||
<% @recent_events.first(3).each do |event| %>
|
<% @recent_events.first(3).each do |event| %>
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="w-2 h-2 rounded-full mr-2
|
<% dot_color = case event.waf_action
|
||||||
<%= event.waf_action == 'allow' ? 'bg-green-500' : 'bg-red-500' %>"></div>
|
when 'allow' then 'bg-green-500'
|
||||||
|
when 'deny' then 'bg-red-500'
|
||||||
|
when 'redirect' then 'bg-blue-500'
|
||||||
|
when 'challenge' then 'bg-yellow-500'
|
||||||
|
when 'log' then 'bg-gray-500'
|
||||||
|
else 'bg-gray-500'
|
||||||
|
end %>
|
||||||
|
<div class="w-2 h-2 rounded-full mr-2 <%= dot_color %>"></div>
|
||||||
<span class="text-gray-900 truncate max-w-[120px]"><%= event.ip_address %></span>
|
<span class="text-gray-900 truncate max-w-[120px]"><%= event.ip_address %></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-gray-500"><%= time_ago_in_words(event.timestamp) %> ago</span>
|
<span class="text-gray-500"><%= time_ago_in_words(event.timestamp) %> ago</span>
|
||||||
|
|||||||
@@ -711,7 +711,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= event.waf_action == 'deny' ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' %>">
|
<% action_classes = case event.waf_action
|
||||||
|
when 'deny' then 'bg-red-100 text-red-800'
|
||||||
|
when 'allow' then 'bg-green-100 text-green-800'
|
||||||
|
when 'redirect' then 'bg-blue-100 text-blue-800'
|
||||||
|
when 'challenge' then 'bg-yellow-100 text-yellow-800'
|
||||||
|
when 'log' then 'bg-gray-100 text-gray-800'
|
||||||
|
else 'bg-gray-100 text-gray-800'
|
||||||
|
end %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= action_classes %>">
|
||||||
<%= event.waf_action %>
|
<%= event.waf_action %>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
<div class="flex items-center space-x-2 min-w-0 flex-1">
|
<div class="flex items-center space-x-2 min-w-0 flex-1">
|
||||||
<%= link_to rule, class: "flex items-center space-x-2 min-w-0 hover:text-blue-600" do %>
|
<%= link_to rule, class: "flex items-center space-x-2 min-w-0 hover:text-blue-600" do %>
|
||||||
<%# Action badge %>
|
<%# Action badge %>
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= rule.waf_action == 'deny' ? 'bg-red-100 text-red-800' : rule.waf_action == 'allow' ? 'bg-green-100 text-green-800' : 'bg-blue-100 text-blue-800' %>">
|
<% action_classes = case rule.waf_action
|
||||||
|
when 'deny' then 'bg-red-100 text-red-800'
|
||||||
|
when 'allow' then 'bg-green-100 text-green-800'
|
||||||
|
when 'redirect' then 'bg-blue-100 text-blue-800'
|
||||||
|
when 'challenge' then 'bg-yellow-100 text-yellow-800'
|
||||||
|
when 'log' then 'bg-gray-100 text-gray-800'
|
||||||
|
else 'bg-gray-100 text-gray-800'
|
||||||
|
end %>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= action_classes %>">
|
||||||
<%= rule.waf_action.upcase %>
|
<%= rule.waf_action.upcase %>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= form_with url: session_url, class: "contents" do |form| %>
|
<%= form_with url: session_url, class: "contents", data: { turbo: false } do |form| %>
|
||||||
<div class="my-5">
|
<div class="my-5">
|
||||||
<%= 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-blue-600 px-3 py-2 mt-2 w-full" %>
|
<%= 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-blue-600 px-3 py-2 mt-2 w-full" %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,11 +50,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Future Settings Section -->
|
<!-- Data Retention Settings -->
|
||||||
<div class="mt-6 bg-gray-50 shadow sm:rounded-lg">
|
<div class="mt-6 bg-white shadow sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-2">Additional Settings</h3>
|
<h3 class="text-lg font-medium leading-6 text-gray-900 mb-4">Data Retention</h3>
|
||||||
<p class="text-sm text-gray-500">More configuration options will be added here as needed.</p>
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<%= form_with url: settings_path, method: :patch, class: "space-y-4" do |f| %>
|
||||||
|
<%= hidden_field_tag :key, 'event_retention_days' %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="event_retention_days" class="block text-sm font-medium text-gray-700">
|
||||||
|
Event Retention Period (days)
|
||||||
|
</label>
|
||||||
|
<div class="mt-1 flex rounded-md shadow-sm">
|
||||||
|
<%= number_field_tag :value,
|
||||||
|
@settings['event_retention_days']&.value || 90,
|
||||||
|
class: "flex-1 min-w-0 block w-full px-3 py-2 rounded-md border-gray-300 focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
|
||||||
|
placeholder: "90",
|
||||||
|
min: 0 %>
|
||||||
|
<%= f.submit "Update", class: "ml-3 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
Events older than this many days will be automatically deleted by the cleanup job (runs hourly).
|
||||||
|
Set to 0 to disable automatic cleanup. Default: 90 days.
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
Current setting: <strong><%= Setting.event_retention_days %> days</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,14 +12,20 @@
|
|||||||
# No recurring tasks configured yet
|
# No recurring tasks configured yet
|
||||||
# (previously had clear_solid_queue_finished_jobs, but now preserve_finished_jobs: false in queue.yml)
|
# (previously had clear_solid_queue_finished_jobs, but now preserve_finished_jobs: false in queue.yml)
|
||||||
|
|
||||||
# Backfill network intelligence for recent events (catches events before network data imported)
|
|
||||||
backfill_recent_network_intelligence:
|
|
||||||
class: BackfillRecentNetworkIntelligenceJob
|
|
||||||
queue: default
|
|
||||||
schedule: every 5 minutes
|
|
||||||
|
|
||||||
# Clean up failed jobs older than 1 day
|
# Clean up failed jobs older than 1 day
|
||||||
cleanup_failed_jobs:
|
cleanup_failed_jobs:
|
||||||
command: "SolidQueue::FailedExecution.where('created_at < ?', 1.day.ago).delete_all"
|
command: "SolidQueue::FailedExecution.where('created_at < ?', 1.day.ago).delete_all"
|
||||||
queue: background
|
queue: background
|
||||||
schedule: every 6 hours
|
schedule: every 6 hours
|
||||||
|
|
||||||
|
# Disable expired rules automatically
|
||||||
|
expired_rules_cleanup:
|
||||||
|
class: ExpiredRulesCleanupJob
|
||||||
|
queue: default
|
||||||
|
schedule: every hour
|
||||||
|
|
||||||
|
# Clean up old events based on retention setting
|
||||||
|
cleanup_old_events:
|
||||||
|
class: CleanupOldEventsJob
|
||||||
|
queue: background
|
||||||
|
schedule: every hour
|
||||||
|
|||||||
151
db/migrate/20251116025003_align_waf_action_enums.rb
Normal file
151
db/migrate/20251116025003_align_waf_action_enums.rb
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
class AlignWafActionEnums < ActiveRecord::Migration[8.1]
|
||||||
|
def up
|
||||||
|
# Current enum mapping (BEFORE):
|
||||||
|
# allow: 0, deny: 1, rate_limit: 2, redirect: 3, log: 4, challenge: 5
|
||||||
|
#
|
||||||
|
# Target enum mapping (AFTER):
|
||||||
|
# deny: 0, allow: 1, redirect: 2, challenge: 3, log: 4
|
||||||
|
#
|
||||||
|
# Strategy: Use temporary values to avoid conflicts during swap
|
||||||
|
|
||||||
|
say "Aligning WAF action enums to canonical order (deny:0, allow:1, redirect:2, challenge:3, log:4)"
|
||||||
|
|
||||||
|
# === Rules Table ===
|
||||||
|
say_with_time "Updating rules table..." do
|
||||||
|
# Temporarily disable triggers to avoid FK constraint issues during enum swap
|
||||||
|
execute "SET session_replication_role = replica;"
|
||||||
|
|
||||||
|
# Step 1: Move existing values to temporary range (100+)
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE rules
|
||||||
|
SET waf_action = CASE
|
||||||
|
WHEN waf_action = 0 THEN 100 -- allow -> temp(100)
|
||||||
|
WHEN waf_action = 1 THEN 101 -- deny -> temp(101)
|
||||||
|
WHEN waf_action = 2 THEN 102 -- rate_limit -> temp(102)
|
||||||
|
WHEN waf_action = 3 THEN 103 -- redirect -> temp(103)
|
||||||
|
WHEN waf_action = 4 THEN 104 -- log -> temp(104)
|
||||||
|
WHEN waf_action = 5 THEN 105 -- challenge -> temp(105)
|
||||||
|
ELSE waf_action
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Step 2: Move from temporary to final positions
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE rules
|
||||||
|
SET waf_action = CASE
|
||||||
|
WHEN waf_action = 101 THEN 0 -- deny -> 0
|
||||||
|
WHEN waf_action = 100 THEN 1 -- allow -> 1
|
||||||
|
WHEN waf_action = 103 THEN 2 -- redirect -> 2
|
||||||
|
WHEN waf_action = 105 THEN 3 -- challenge -> 3
|
||||||
|
WHEN waf_action = 104 THEN 4 -- log -> 4
|
||||||
|
WHEN waf_action = 102 THEN 0 -- rate_limit -> deny (rate_limit is a rule_type, not action)
|
||||||
|
ELSE waf_action
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Re-enable triggers
|
||||||
|
execute "SET session_replication_role = DEFAULT;"
|
||||||
|
|
||||||
|
# Return count without triggering model validations
|
||||||
|
connection.execute("SELECT COUNT(*) FROM rules").first["count"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# === Events Table ===
|
||||||
|
say_with_time "Updating events table..." do
|
||||||
|
# Step 1: Move existing values to temporary range (100+)
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE events
|
||||||
|
SET waf_action = CASE
|
||||||
|
WHEN waf_action = 0 THEN 100 -- allow -> temp(100)
|
||||||
|
WHEN waf_action = 1 THEN 101 -- deny -> temp(101)
|
||||||
|
WHEN waf_action = 2 THEN 102 -- redirect -> temp(102)
|
||||||
|
WHEN waf_action = 3 THEN 103 -- challenge -> temp(103)
|
||||||
|
ELSE waf_action
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Step 2: Move from temporary to final positions
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE events
|
||||||
|
SET waf_action = CASE
|
||||||
|
WHEN waf_action = 101 THEN 0 -- deny -> 0
|
||||||
|
WHEN waf_action = 100 THEN 1 -- allow -> 1
|
||||||
|
WHEN waf_action = 102 THEN 2 -- redirect -> 2
|
||||||
|
WHEN waf_action = 103 THEN 3 -- challenge -> 3
|
||||||
|
ELSE waf_action
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Return count without triggering model validations
|
||||||
|
connection.execute("SELECT COUNT(*) FROM events").first["count"]
|
||||||
|
end
|
||||||
|
|
||||||
|
say "Enum alignment complete!", true
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
# Reverse the migration - swap back to old order
|
||||||
|
say "Reverting WAF action enums to original order"
|
||||||
|
|
||||||
|
# === Rules Table ===
|
||||||
|
say_with_time "Reverting rules table..." do
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE rules
|
||||||
|
SET waf_action = CASE
|
||||||
|
WHEN waf_action = 0 THEN 100 -- deny -> temp(100)
|
||||||
|
WHEN waf_action = 1 THEN 101 -- allow -> temp(101)
|
||||||
|
WHEN waf_action = 2 THEN 102 -- redirect -> temp(102)
|
||||||
|
WHEN waf_action = 3 THEN 103 -- challenge -> temp(103)
|
||||||
|
WHEN waf_action = 4 THEN 104 -- log -> temp(104)
|
||||||
|
ELSE waf_action
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE rules
|
||||||
|
SET waf_action = CASE
|
||||||
|
WHEN waf_action = 101 THEN 0 -- allow -> 0
|
||||||
|
WHEN waf_action = 100 THEN 1 -- deny -> 1
|
||||||
|
WHEN waf_action = 104 THEN 4 -- log -> 4
|
||||||
|
WHEN waf_action = 103 THEN 3 -- redirect -> 3
|
||||||
|
WHEN waf_action = 102 THEN 2 -- rate_limit -> 2 (restore even though deprecated)
|
||||||
|
WHEN waf_action = 105 THEN 5 -- challenge -> 5
|
||||||
|
ELSE waf_action
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Return count without triggering model validations
|
||||||
|
connection.execute("SELECT COUNT(*) FROM rules").first["count"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# === Events Table ===
|
||||||
|
say_with_time "Reverting events table..." do
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE events
|
||||||
|
SET waf_action = CASE
|
||||||
|
WHEN waf_action = 0 THEN 100 -- deny -> temp(100)
|
||||||
|
WHEN waf_action = 1 THEN 101 -- allow -> temp(101)
|
||||||
|
WHEN waf_action = 2 THEN 102 -- redirect -> temp(102)
|
||||||
|
WHEN waf_action = 3 THEN 103 -- challenge -> temp(103)
|
||||||
|
ELSE waf_action
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
|
||||||
|
execute <<-SQL
|
||||||
|
UPDATE events
|
||||||
|
SET waf_action = CASE
|
||||||
|
WHEN waf_action = 101 THEN 0 -- allow -> 0
|
||||||
|
WHEN waf_action = 100 THEN 1 -- deny -> 1
|
||||||
|
WHEN waf_action = 102 THEN 2 -- redirect -> 2
|
||||||
|
WHEN waf_action = 103 THEN 3 -- challenge -> 3
|
||||||
|
ELSE waf_action
|
||||||
|
END
|
||||||
|
SQL
|
||||||
|
|
||||||
|
# Return count without triggering model validations
|
||||||
|
connection.execute("SELECT COUNT(*) FROM events").first["count"]
|
||||||
|
end
|
||||||
|
|
||||||
|
say "Revert complete!", true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_11_13_052831) do
|
ActiveRecord::Schema[8.1].define(version: 2025_11_16_025003) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pg_catalog.plpgsql"
|
enable_extension "pg_catalog.plpgsql"
|
||||||
|
|
||||||
|
|||||||
@@ -354,10 +354,4 @@ class ProcessWafEventJobTest < ActiveJob::TestCase
|
|||||||
assert_equal 100, Event.count
|
assert_equal 100, Event.count
|
||||||
assert processing_time < 5.seconds, "Processing 100 events should take less than 5 seconds"
|
assert processing_time < 5.seconds, "Processing 100 events should take less than 5 seconds"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Integration with Other Jobs
|
|
||||||
test "coordinates with BackfillRecentNetworkIntelligenceJob" do
|
|
||||||
# This would be tested based on how the job enqueues other jobs
|
|
||||||
# Implementation depends on your specific job coordination logic
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -78,14 +78,17 @@ class EventTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "create_from_waf_payload! properly normalizes waf_action enum" do
|
test "create_from_waf_payload! properly normalizes waf_action enum" do
|
||||||
|
# Updated enum values: deny:0, allow:1, redirect:2, challenge:3, log:4
|
||||||
test_actions = [
|
test_actions = [
|
||||||
["allow", :allow, 0],
|
["deny", :deny, 0],
|
||||||
["pass", :allow, 0],
|
["block", :deny, 0],
|
||||||
["deny", :deny, 1],
|
["allow", :allow, 1],
|
||||||
["block", :deny, 1],
|
["pass", :allow, 1],
|
||||||
["redirect", :redirect, 2],
|
["redirect", :redirect, 2],
|
||||||
["challenge", :challenge, 3],
|
["challenge", :challenge, 3],
|
||||||
["unknown", :allow, 0] # Default fallback
|
["log", :log, 4],
|
||||||
|
["monitor", :log, 4],
|
||||||
|
["unknown", :allow, 1] # Default fallback
|
||||||
]
|
]
|
||||||
|
|
||||||
test_actions.each do |action, expected_enum, expected_int|
|
test_actions.each do |action, expected_enum, expected_int|
|
||||||
@@ -122,20 +125,20 @@ class EventTest < ActiveSupport::TestCase
|
|||||||
test "enum values persist after save and reload" do
|
test "enum values persist after save and reload" do
|
||||||
event = Event.create_from_waf_payload!("test-persist", @sample_payload)
|
event = Event.create_from_waf_payload!("test-persist", @sample_payload)
|
||||||
|
|
||||||
# Verify initial values
|
# Verify initial values (updated enum: deny:0, allow:1)
|
||||||
assert_equal "get", event.request_method
|
assert_equal "get", event.request_method
|
||||||
assert_equal "allow", event.waf_action
|
assert_equal "allow", event.waf_action
|
||||||
assert_equal 0, event.request_method_before_type_cast
|
assert_equal 0, event.request_method_before_type_cast
|
||||||
assert_equal 0, event.waf_action_before_type_cast
|
assert_equal 1, event.waf_action_before_type_cast # allow is now 1
|
||||||
|
|
||||||
# Reload from database
|
# Reload from database
|
||||||
event.reload
|
event.reload
|
||||||
|
|
||||||
# Values should still be correct
|
# Values should still be correct (allow is now 1)
|
||||||
assert_equal "get", event.request_method
|
assert_equal "get", event.request_method
|
||||||
assert_equal "allow", event.waf_action
|
assert_equal "allow", event.waf_action
|
||||||
assert_equal 0, event.request_method_before_type_cast
|
assert_equal 0, event.request_method_before_type_cast
|
||||||
assert_equal 0, event.waf_action_before_type_cast
|
assert_equal 1, event.waf_action_before_type_cast
|
||||||
end
|
end
|
||||||
|
|
||||||
test "enum scopes work correctly" do
|
test "enum scopes work correctly" do
|
||||||
@@ -260,7 +263,7 @@ class EventTest < ActiveSupport::TestCase
|
|||||||
# Test boolean methods
|
# Test boolean methods
|
||||||
assert event.allowed?
|
assert event.allowed?
|
||||||
assert_not event.blocked?
|
assert_not event.blocked?
|
||||||
assert_not event.rate_limited?
|
assert_not event.logged? # Changed from rate_limited? to logged?
|
||||||
assert_not event.challenged?
|
assert_not event.challenged?
|
||||||
assert_not event.rule_matched?
|
assert_not event.rule_matched?
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class RuleTest < ActiveSupport::TestCase
|
|||||||
test "should create valid rate_limit rule" do
|
test "should create valid rate_limit rule" do
|
||||||
rule = Rule.new(
|
rule = Rule.new(
|
||||||
waf_rule_type: "rate_limit",
|
waf_rule_type: "rate_limit",
|
||||||
waf_action: "rate_limit",
|
waf_action: "deny", # Rate limit rules use deny action when triggered
|
||||||
conditions: { cidr: "0.0.0.0/0", scope: "global" },
|
conditions: { cidr: "0.0.0.0/0", scope: "global" },
|
||||||
metadata: { limit: 100, window: 60 },
|
metadata: { limit: 100, window: 60 },
|
||||||
source: "manual",
|
source: "manual",
|
||||||
@@ -83,7 +83,7 @@ class RuleTest < ActiveSupport::TestCase
|
|||||||
test "should validate rate_limit has limit and window in metadata" do
|
test "should validate rate_limit has limit and window in metadata" do
|
||||||
rule = Rule.new(
|
rule = Rule.new(
|
||||||
waf_rule_type: "rate_limit",
|
waf_rule_type: "rate_limit",
|
||||||
waf_action: "rate_limit",
|
waf_action: "deny", # Rate limit rules use deny action when triggered
|
||||||
conditions: { cidr: "0.0.0.0/0", scope: "global" },
|
conditions: { cidr: "0.0.0.0/0", scope: "global" },
|
||||||
metadata: { limit: 100 }, # Missing window
|
metadata: { limit: 100 }, # Missing window
|
||||||
user: users(:one)
|
user: users(:one)
|
||||||
|
|||||||
Reference in New Issue
Block a user