From 2c7b801ed56485eb82f8c39811929000c76e41af Mon Sep 17 00:00:00 2001 From: Dan Milne Date: Thu, 13 Nov 2025 08:35:00 +1100 Subject: [PATCH] Add DeviceDetector and postres_cursor --- Gemfile | 7 ++ Gemfile.lock | 16 +++ .../quick_create_rule_controller.js | 61 ++++++---- .../controllers/waf_policy_form_controller.js | 55 +++++++++ .../network_ranges/_geolite_data.html.erb | 87 ++++++++++++++ app/views/network_ranges/_ipapi_data.html.erb | 112 ++++++++++++++++++ app/views/network_ranges/show.html.erb | 34 +++++- app/views/waf_policies/edit.html.erb | 50 ++------ app/views/waf_policies/index.html.erb | 8 +- app/views/waf_policies/new.html.erb | 87 +++----------- app/views/waf_policies/new_country.html.erb | 43 +++++-- app/views/waf_policies/show.html.erb | 6 +- ...action_to_policy_action_on_waf_policies.rb | 5 + ...2227_add_network_intelligence_to_events.rb | 23 ++++ lib/tasks/events.rake | 36 ++++++ 15 files changed, 472 insertions(+), 158 deletions(-) create mode 100644 app/javascript/controllers/waf_policy_form_controller.js create mode 100644 app/views/network_ranges/_geolite_data.html.erb create mode 100644 app/views/network_ranges/_ipapi_data.html.erb create mode 100644 db/migrate/20251111062944_rename_action_to_policy_action_on_waf_policies.rb create mode 100644 db/migrate/20251112012227_add_network_intelligence_to_events.rb create mode 100644 lib/tasks/events.rake diff --git a/Gemfile b/Gemfile index f662b2f..9ad90a8 100644 --- a/Gemfile +++ b/Gemfile @@ -63,6 +63,9 @@ gem "countries" # Authorization library gem "pundit" +# User agent parsing +gem "device_detector" + group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" @@ -87,3 +90,7 @@ group :test do gem "capybara" gem "selenium-webdriver" end + +gem "sentry-rails", "~> 6.1" + +gem "postgresql_cursor", "~> 0.6.9" diff --git a/Gemfile.lock b/Gemfile.lock index f6dc474..21eaa1a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,12 +105,15 @@ GEM xpath (~> 3.2) concurrent-ruby (1.3.5) connection_pool (2.5.4) + countries (8.0.4) + unaccent (~> 0.3) crass (1.0.6) csv (3.3.5) date (3.5.0) debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) + device_detector (1.1.3) dotenv (3.1.8) drb (2.2.3) ed25519 (1.4.0) @@ -258,6 +261,8 @@ GEM pg (1.6.2-arm64-darwin) pg (1.6.2-x86_64-linux) pg (1.6.2-x86_64-linux-musl) + postgresql_cursor (0.6.9) + activerecord (>= 6.0) pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -371,6 +376,12 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) + sentry-rails (6.1.0) + railties (>= 5.2.0) + sentry-ruby (~> 6.1.0) + sentry-ruby (6.1.0) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) solid_cable (3.0.12) actioncable (>= 7.2) activejob (>= 7.2) @@ -430,6 +441,7 @@ GEM railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + unaccent (0.4.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) @@ -473,7 +485,9 @@ DEPENDENCIES brakeman bundler-audit capybara + countries debug + device_detector httparty image_processing (~> 1.2) importmap-rails @@ -483,12 +497,14 @@ DEPENDENCIES openid_connect (~> 2.2) pagy pg (>= 1.1) + postgresql_cursor (~> 0.6.9) propshaft puma (>= 5.0) pundit rails (~> 8.1.1) rubocop-rails-omakase selenium-webdriver + sentry-rails (~> 6.1) solid_cable solid_cache solid_queue diff --git a/app/javascript/controllers/quick_create_rule_controller.js b/app/javascript/controllers/quick_create_rule_controller.js index 0d1bd29..0502d9d 100644 --- a/app/javascript/controllers/quick_create_rule_controller.js +++ b/app/javascript/controllers/quick_create_rule_controller.js @@ -2,18 +2,29 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["form", "toggle", "ruleTypeSelect", "actionSelect", "patternFields", "rateLimitFields", "redirectFields", "helpText", "conditionsField"] + static targets = ["form", "toggle", "ruleTypeSelect", "actionSelect", "patternFields", "rateLimitFields", "redirectFields", "helpText", "conditionsField", "expiresAtField"] connect() { - this.setupEventListeners() + console.log("QuickCreateRuleController connected") this.initializeFieldVisibility() } toggle() { - this.formTarget.classList.toggle("hidden") + console.log("Toggle method called") + console.log("Form target:", this.formTarget) - if (this.formTarget.classList.contains("hidden")) { - this.resetForm() + if (this.formTarget) { + this.formTarget.classList.toggle("hidden") + console.log("Toggled hidden class, now:", this.formTarget.classList.contains("hidden")) + + if (this.formTarget.classList.contains("hidden")) { + this.resetForm() + } else { + // Form is being shown, clear the expires_at field for Safari + this.clearExpiresAtField() + } + } else { + console.error("Form target not found!") } } @@ -81,13 +92,28 @@ export default class extends Controller { if (this.hasRedirectFieldsTarget) this.redirectFieldsTarget.classList.add("hidden") } + clearExpiresAtField() { + // Clear the expires_at field - much simpler with text field + if (this.hasExpiresAtFieldTarget) { + this.expiresAtFieldTarget.value = '' + } + } + resetForm() { if (this.formTarget) { - this.formTarget.reset() - // Reset rule type to default - if (this.hasRuleTypeSelectTarget) { - this.ruleTypeSelectTarget.value = "network" - this.updateRuleTypeFields() + // Find the actual form element within the form target div + const formElement = this.formTarget.querySelector('form') + if (formElement) { + formElement.reset() + + // Explicitly clear the expires_at field since browser reset might not clear datetime-local fields properly + this.clearExpiresAtField() + + // Reset rule type to default + if (this.hasRuleTypeSelectTarget) { + this.ruleTypeSelectTarget.value = "network" + this.updateRuleTypeFields() + } } } } @@ -95,19 +121,8 @@ export default class extends Controller { // Private methods setupEventListeners() { - // Set up action change listener to show/hide redirect fields - if (this.hasActionSelectTarget) { - this.actionSelectTarget.addEventListener("change", () => { - this.updateRuleTypeFields() - }) - } - - // Set up toggle button listener - if (this.hasToggleTarget) { - this.toggleTarget.addEventListener("click", () => { - this.toggle() - }) - } + // Event listeners are handled via data-action attributes in the HTML + // No manual event listeners needed } initializeFieldVisibility() { diff --git a/app/javascript/controllers/waf_policy_form_controller.js b/app/javascript/controllers/waf_policy_form_controller.js new file mode 100644 index 0000000..4bcbe61 --- /dev/null +++ b/app/javascript/controllers/waf_policy_form_controller.js @@ -0,0 +1,55 @@ +import { Controller } from "@hotwired/stimulus" + +export default class WafPolicyFormController extends Controller { + static targets = ["policyTypeSelect", "policyActionSelect", "countryTargets", "asnTargets", + "companyTargets", "networkTypeTargets", "redirectConfig", "challengeConfig"] + + connect() { + this.updateTargetsVisibility() + this.updateActionConfig() + } + + updateTargetsVisibility() { + const selectedType = this.policyTypeSelectTarget.value + + // Hide all target sections + this.countryTargetsTarget.classList.add('hidden') + this.asnTargetsTarget.classList.add('hidden') + this.companyTargetsTarget.classList.add('hidden') + this.networkTypeTargetsTarget.classList.add('hidden') + + // Show relevant target section + switch(selectedType) { + case 'country': + this.countryTargetsTarget.classList.remove('hidden') + break + case 'asn': + this.asnTargetsTarget.classList.remove('hidden') + break + case 'company': + this.companyTargetsTarget.classList.remove('hidden') + break + case 'network_type': + this.networkTypeTargetsTarget.classList.remove('hidden') + break + } + } + + updateActionConfig() { + const selectedAction = this.policyActionSelectTarget.value + + // Hide all config sections + this.redirectConfigTarget.classList.add('hidden') + this.challengeConfigTarget.classList.add('hidden') + + // Show relevant config section + switch(selectedAction) { + case 'redirect': + this.redirectConfigTarget.classList.remove('hidden') + break + case 'challenge': + this.challengeConfigTarget.classList.remove('hidden') + break + } + } +} \ No newline at end of file diff --git a/app/views/network_ranges/_geolite_data.html.erb b/app/views/network_ranges/_geolite_data.html.erb new file mode 100644 index 0000000..3d47cfb --- /dev/null +++ b/app/views/network_ranges/_geolite_data.html.erb @@ -0,0 +1,87 @@ +<% geolite_data = network_range.network_data_for(:geolite) %> + +<% if geolite_data.present? %> +
+
+

MaxMind GeoLite2 Data

+
+ +
+
+ + <% if geolite_data['asn'].present? %> +
+
ASN (MaxMind)
+
+ AS<%= geolite_data['asn']['autonomous_system_number'] %> + <% if geolite_data['asn']['autonomous_system_organization'].present? %> +
<%= geolite_data['asn']['autonomous_system_organization'] %>
+ <% end %> +
+
+ <% end %> + + + <% if geolite_data['country'].present? %> +
+
Country (MaxMind)
+
+ <%= geolite_data['country']['country_name'] || geolite_data['country']['country_iso_code'] %> + <% if geolite_data['country']['country_iso_code'].present? %> + <%= country_flag(geolite_data['country']['country_iso_code']) %> + <% end %> +
+
+ + <% if geolite_data['country']['continent_name'].present? %> +
+
Continent
+
+ <%= geolite_data['country']['continent_name'] %> + (<%= geolite_data['country']['continent_code'] %>) +
+
+ <% end %> + + <% if geolite_data['country']['geoname_id'].present? %> +
+
GeoName ID
+
+ <%= geolite_data['country']['geoname_id'] %> +
+
+ <% end %> + + +
+
MaxMind Flags
+
+ <% if geolite_data['country']['is_anonymous_proxy'] %> + Anonymous Proxy + <% end %> + <% if geolite_data['country']['is_satellite_provider'] %> + Satellite Provider + <% end %> + <% if geolite_data['country']['is_anycast'] %> + Anycast + <% end %> + <% if geolite_data['country']['is_in_european_union'] == "1" %> + 🇪🇺 EU Member + <% end %> +
+
+ <% end %> +
+ + +
+ + Show Raw MaxMind Data + +
+
<%= JSON.pretty_generate(geolite_data) %>
+
+
+
+
+<% end %> diff --git a/app/views/network_ranges/_ipapi_data.html.erb b/app/views/network_ranges/_ipapi_data.html.erb new file mode 100644 index 0000000..b174a41 --- /dev/null +++ b/app/views/network_ranges/_ipapi_data.html.erb @@ -0,0 +1,112 @@ +
+
+

IPAPI Enrichment Data

+
+ + <% if ipapi_loading %> +
+
+

Fetching enrichment data...

+
+ <% elsif ipapi_data.present? %> +
+ <% if parent_with_ipapi %> +
+
+ + + + + Data inherited from parent network <%= link_to parent_with_ipapi.cidr, network_range_path(parent_with_ipapi), class: "font-mono font-medium hover:underline" %> + +
+
+ <% end %> + +
+ <% if ipapi_data['asn'].present? %> +
+
ASN (IPAPI)
+
+ AS<%= ipapi_data['asn']['asn'] %> + <% if ipapi_data['asn']['org'].present? %> +
<%= ipapi_data['asn']['org'] %>
+ <% end %> + <% if ipapi_data['asn']['route'].present? %> +
<%= ipapi_data['asn']['route'] %>
+ <% end %> +
+
+ <% end %> + + <% if ipapi_data['location'].present? %> +
+
Location
+
+ <%= [ipapi_data['location']['city'], ipapi_data['location']['state'], ipapi_data['location']['country']].compact.join(', ') %> + <% if ipapi_data['location']['country_code'].present? %> + <%= country_flag(ipapi_data['location']['country_code']) %> + <% end %> +
+
+ <% end %> + + <% if ipapi_data['company'].present? %> +
+
Company (IPAPI)
+
+ <%= ipapi_data['company']['name'] %> + <% if ipapi_data['company']['type'].present? %> +
<%= ipapi_data['company']['type'].humanize %>
+ <% end %> +
+
+ <% end %> + + <% if ipapi_data['is_datacenter'] || ipapi_data['is_vpn'] || ipapi_data['is_proxy'] || ipapi_data['is_tor'] %> +
+
IPAPI Flags
+
+ <% if ipapi_data['is_datacenter'] %> + Datacenter + <% end %> + <% if ipapi_data['is_vpn'] %> + VPN + <% end %> + <% if ipapi_data['is_proxy'] %> + Proxy + <% end %> + <% if ipapi_data['is_tor'] %> + Tor + <% end %> + <% if ipapi_data['is_abuser'] %> + Abuser + <% end %> + <% if ipapi_data['is_bogon'] %> + Bogon + <% end %> +
+
+ <% end %> +
+ + +
+ + Show Raw IPAPI Data + +
+
<%= JSON.pretty_generate(ipapi_data) %>
+
+
+
+ <% else %> +
+ + + +

No IPAPI data available

+

Enrichment data will be fetched automatically.

+
+ <% end %> +
diff --git a/app/views/network_ranges/show.html.erb b/app/views/network_ranges/show.html.erb index 29851b1..7345f94 100644 --- a/app/views/network_ranges/show.html.erb +++ b/app/views/network_ranges/show.html.erb @@ -1,5 +1,9 @@ <% content_for :title, "#{@network_range.cidr} - Network Range Details" %> +<% if @network_range.persisted? %> + <%= turbo_stream_from "network_range_#{@network_range.id}" %> +<% end %> +
@@ -48,6 +52,23 @@
+ + <% if @network_range.persisted? %> + <%= render partial: "network_ranges/ipapi_data", locals: { + ipapi_data: @ipapi_data, + network_range: @network_range, + parent_with_ipapi: @parent_with_ipapi, + ipapi_loading: @ipapi_loading || false + } %> + <% end %> + + + <% if @network_range.persisted? %> + <%= render partial: "network_ranges/geolite_data", locals: { + network_range: @network_range + } %> + <% end %> +
@@ -335,9 +356,12 @@
<%= form.label :expires_at, "Expires At (Optional)", class: "block text-sm font-medium text-gray-700" %> - <%= form.datetime_local_field :expires_at, - class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> -

Leave blank for permanent rule

+ <%= form.text_field :expires_at, + placeholder: "YYYY-MM-DD HH:MM (24-hour format, optional)", + class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", + data: { quick_create_rule_target: "expiresAtField" }, + autocomplete: "off" %> +

Leave blank for permanent rule. Format: YYYY-MM-DD HH:MM (e.g., 2024-12-31 23:59)

@@ -461,9 +485,9 @@
Created <%= time_ago_in_words(rule.created_at) %> ago by <%= rule.user&.email_address || 'System' %>
- <% if rule.metadata&.dig('reason').present? %> + <% if rule.metadata_hash['reason'].present? %>
- Reason: <%= rule.metadata['reason'] %> + Reason: <%= rule.metadata_hash['reason'] %>
<% end %>
diff --git a/app/views/waf_policies/edit.html.erb b/app/views/waf_policies/edit.html.erb index 229fda3..e651ba3 100644 --- a/app/views/waf_policies/edit.html.erb +++ b/app/views/waf_policies/edit.html.erb @@ -13,7 +13,7 @@
- <%= form_with(model: @waf_policy, local: true, class: "space-y-6") do |form| %> + <%= form_with(model: @waf_policy, local: true, class: "space-y-6", data: { controller: "waf-policy-form" }) do |form| %>
@@ -35,14 +35,14 @@ placeholder: "Explain why this policy is needed..." %>
- +
- <%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %> - <%= form.select :action, - options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.action), + <%= form.label :policy_action, "Policy Action", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :policy_action, + options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.policy_action), { prompt: "Select action" }, { class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm", - id: "action-select" } %> + data: { "waf-policy-form-target": "policyActionSelect", "action": "change->waf-policy-form#updateActionConfig" } } %>
@@ -164,7 +164,7 @@

⚙️ Additional Configuration

-
+
<%= label_tag "additional_data[redirect_url]", "Redirect URL", class: "block text-sm font-medium text-gray-700" %> <%= text_field_tag "additional_data[redirect_url]", @waf_policy.additional_data&.dig('redirect_url'), @@ -180,7 +180,7 @@
-
+
<%= label_tag "additional_data[challenge_type]", "Challenge Type", class: "block text-sm font-medium text-gray-700" %> <%= select_tag "additional_data[challenge_type]", @@ -205,36 +205,4 @@ class: "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" %>
<% end %> -
- - \ No newline at end of file +
\ No newline at end of file diff --git a/app/views/waf_policies/index.html.erb b/app/views/waf_policies/index.html.erb index 5cce23f..ef7208c 100644 --- a/app/views/waf_policies/index.html.erb +++ b/app/views/waf_policies/index.html.erb @@ -85,7 +85,7 @@
Deny Policies
- <%= number_with_delimiter(@waf_policies.where(action: 'deny').count) %> + <%= number_with_delimiter(@waf_policies.where(policy_action: 'deny').count) %>
@@ -137,15 +137,15 @@ <% end %> - + - <%= policy.action.upcase %> + <%= policy.policy_action.upcase %>
diff --git a/app/views/waf_policies/new.html.erb b/app/views/waf_policies/new.html.erb index 4266abc..c67cb92 100644 --- a/app/views/waf_policies/new.html.erb +++ b/app/views/waf_policies/new.html.erb @@ -13,7 +13,7 @@
- <%= form_with(model: @waf_policy, local: true, class: "space-y-6") do |form| %> + <%= form_with(model: @waf_policy, local: true, class: "space-y-6", data: { controller: "waf-policy-form" }) do |form| %>
@@ -42,17 +42,17 @@ options_for_select(@policy_types.map { |type| [type.humanize, type] }, @waf_policy.policy_type), { prompt: "Select policy type" }, { class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm", - id: "policy-type-select" } %> + data: { "waf-policy-form-target": "policyTypeSelect", "action": "change->waf-policy-form#updateTargetsVisibility" } } %>
- +
- <%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %> - <%= form.select :action, - options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.action), + <%= form.label :policy_action, "Policy Action", class: "block text-sm font-medium text-gray-700" %> + <%= form.select :policy_action, + options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.policy_action), { prompt: "Select action" }, { class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm", - id: "action-select" } %> + data: { "waf-policy-form-target": "policyActionSelect", "action": "change->waf-policy-form#updateActionConfig" } } %>
@@ -63,7 +63,7 @@

🎯 Targets Configuration

-