Compare commits

5 Commits

Author SHA1 Message Date
Dan Milne
de1cf0b237 Use network IDS, rather than the CIDR containment method 2025-11-17 22:21:15 +11:00
Dan Milne
4964d1a190 Fix missing network_count in ASN analytics query
The @top_asns query was missing the network_count field that the view
expects, causing NoMethodError on analytics/networks page.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 22:21:15 +11:00
Dan Milne
5d3e35a4ac Add a setting for maximum age of events 2025-11-17 22:21:15 +11:00
Dan Milne
830810305b Path matching 2025-11-17 22:21:15 +11:00
093ee71c9f Merge pull request 'path-matching' (#1) from path-matching into main
Reviewed-on: #1
2025-11-15 01:55:46 +00:00
20 changed files with 769 additions and 74 deletions

View 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 */

View File

@@ -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)

View File

@@ -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)

View File

@@ -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?

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

@@ -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"

View File

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

View File

@@ -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?

View File

@@ -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)