Add 'tags' to event model. Add a dataimport system - currently for MaxMind zip files

This commit is contained in:
Dan Milne
2025-11-11 10:31:36 +11:00
parent 772fae7e8b
commit 26216da9ca
34 changed files with 3580 additions and 14 deletions

View File

@@ -31,3 +31,35 @@
font-size: 0.875rem;
font-weight: 500;
}
/* Tom Select overrides for better visibility */
.ts-wrapper {
visibility: visible !important;
display: block !important;
}
.ts-wrapper .ts-control {
min-height: 38px;
padding: 6px 8px;
border: 1px solid #d1d5db;
border-radius: 6px;
background-color: white;
}
.ts-wrapper .ts-control input {
font-size: 14px;
}
.ts-wrapper.multi .ts-control > div {
background-color: #3b82f6;
color: white;
border-radius: 4px;
padding: 2px 6px;
margin: 2px;
font-size: 12px;
}
.ts-wrapper.multi .ts-control > div .remove {
color: white;
margin-left: 4px;
}

View File

@@ -0,0 +1,131 @@
class DataImportsController < ApplicationController
before_action :require_admin!
before_action :set_data_import, only: [:show, :destroy, :progress]
def index
@data_imports = DataImport.all
# Apply filters
@data_imports = @data_imports.where(import_type: params[:import_type]) if params[:import_type].present?
@data_imports = @data_imports.where(status: params[:status]) if params[:status].present?
@data_imports = @data_imports.where("filename ILIKE ?", "%#{params[:filename]}%") if params[:filename].present?
@pagy, @data_imports = pagy(@data_imports.order(created_at: :desc))
end
def new
@data_import = DataImport.new
end
def create
# Save uploaded file and queue import job
uploaded_file = params[:data_import][:file]
if uploaded_file.nil?
@data_import = DataImport.new
flash.now[:alert] = "Please select a file to import"
render :new, status: :unprocessable_entity
return
end
# Validate file type
unless valid_file?(uploaded_file)
@data_import = DataImport.new
flash.now[:alert] = "Invalid file type. Please upload a .csv or .zip file."
render :new, status: :unprocessable_entity
return
end
# Determine import type based on filename
import_type = detect_import_type_from_filename(uploaded_file.original_filename)
# Create the DataImport record with the attached file
@data_import = DataImport.create!(
import_type: import_type,
filename: uploaded_file.original_filename,
status: 'pending'
)
# Attach the file using Active Storage
@data_import.file.attach(uploaded_file)
# Queue appropriate import job - pass the entire DataImport object
if import_type == 'asn'
GeoliteAsnImportJob.perform_later(@data_import)
else
GeoliteCountryImportJob.perform_later(@data_import)
end
redirect_to @data_import, notice: "Import has been queued and will begin processing shortly."
rescue => e
Rails.logger.error "Error creating import: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
@data_import = DataImport.new if @data_import.nil?
flash.now[:alert] = "Error processing file: #{e.message}"
render :new, status: :unprocessable_entity
end
def show
# Show will display import details and progress
end
def progress
# JSON endpoint for real-time progress updates
render json: {
id: @data_import.id,
status: @data_import.status,
progress_percentage: @data_import.progress_percentage,
processed_records: @data_import.processed_records,
total_records: @data_import.total_records,
failed_records: @data_import.failed_records,
duration: @data_import.duration,
records_per_second: @data_import.records_per_second,
import_stats: @data_import.import_stats,
error_message: @data_import.error_message,
started_at: @data_import.started_at,
completed_at: @data_import.completed_at
}
end
def destroy
if @data_import.processing?
redirect_to @data_import, alert: "Cannot delete an import that is currently processing."
else
@data_import.destroy
redirect_to data_imports_path, notice: "Import was successfully deleted."
end
end
private
def set_data_import
@data_import = DataImport.find(params[:id])
end
def data_import_params
# No parameters needed since we detect everything automatically
{}
end
def valid_file?(uploaded_file)
return false unless uploaded_file.respond_to?(:original_filename)
filename = uploaded_file.original_filename.downcase
filename.end_with?('.csv', '.zip')
end
def detect_import_type_from_filename(filename)
# Try to detect based on filename first
if filename.downcase.include?('asn')
'asn'
elsif filename.downcase.include?('country')
'country'
else
'country' # Default fallback
end
end
def require_admin!
redirect_to root_path, alert: "Access denied. Admin privileges required." unless current_user&.admin?
end
end

View File

@@ -9,7 +9,8 @@ class EventsController < ApplicationController
# Apply filters
@events = @events.by_ip(params[:ip]) if params[:ip].present?
@events = @events.by_waf_action(params[:waf_action]) if params[:waf_action].present?
@events = @events.where(country_code: params[:country]) if params[:country].present?
@events = @events.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.where("network_ranges.country = ?", params[:country]) if params[:country].present?
# Network-based filters
@events = @events.by_company(params[:company]) if params[:company].present?

View File

@@ -1,3 +1,4 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
import "tom-select"

View File

@@ -0,0 +1,145 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "select" ]
static values = {
options: Array,
placeholder: String
}
connect() {
// Check if the element is visible, if not, wait for it to become visible
if (this.isHidden()) {
// Element is hidden, set up a MutationObserver to watch for visibility changes
this.observer = new MutationObserver(() => {
if (!this.isHidden()) {
this.initializeTomSelect()
this.observer.disconnect()
}
})
this.observer.observe(this.element, {
attributes: true,
attributeFilter: ['class']
})
// Also check periodically as a fallback
this.checkInterval = setInterval(() => {
if (!this.isHidden()) {
this.initializeTomSelect()
this.cleanup()
}
}, 500)
} else {
// Element is already visible, initialize immediately
this.initializeTomSelect()
}
}
isHidden() {
return this.element.offsetParent === null || this.element.classList.contains('hidden')
}
cleanup() {
if (this.observer) {
this.observer.disconnect()
this.observer = null
}
if (this.checkInterval) {
clearInterval(this.checkInterval)
this.checkInterval = null
}
}
initializeTomSelect() {
if (!this.hasSelectTarget) {
console.log('No select target found')
return
}
// Check if Tom Select is available
if (typeof TomSelect === 'undefined') {
console.log('Tom Select is not loaded')
return
}
// If TomSelect is already initialized, destroy it first
if (this.tomSelect) {
this.tomSelect.destroy()
}
console.log('Initializing Tom Select with options:', this.optionsValue.length, 'countries')
console.log('First few country options:', this.optionsValue.slice(0, 3))
// Prepare options for Tom Select
const options = this.optionsValue.map(([display, value]) => ({
value: value,
text: display,
// Add searchable fields for better search
search: display + ' ' + value
}))
// Get currently selected values from the hidden select
const selectedValues = Array.from(this.selectTarget.selectedOptions).map(option => option.value)
try {
// Initialize Tom Select
this.tomSelect = new TomSelect(this.selectTarget, {
options: options,
items: selectedValues,
plugins: ['remove_button'],
maxItems: null,
maxOptions: 1000,
create: false,
placeholder: this.placeholderValue || "Search and select countries...",
searchField: ['text', 'search'],
searchConjunction: 'or',
onItemAdd: function() {
// Clear the search input after selecting an item
this.setTextboxValue('');
this.refreshOptions();
},
render: {
option: function(data, escape) {
return `<div class="flex items-center p-2">
<span>${escape(data.text)}</span>
</div>`
},
item: function(data, escape) {
return `<div class="flex items-center text-sm">
<span>${escape(data.text)}</span>
</div>`
}
},
dropdownParent: 'body',
copyClassesToDropdown: false
})
console.log('Tom Select successfully initialized for country selector')
// Make sure the wrapper is visible
setTimeout(() => {
if (this.tomSelect && this.tomSelect.wrapper) {
this.tomSelect.wrapper.style.visibility = 'visible'
this.tomSelect.wrapper.style.display = 'block'
console.log('Tom Select wrapper made visible')
}
}, 100)
} catch (error) {
console.error('Error initializing Tom Select:', error)
}
}
// Public method to reinitialize if needed
reinitialize() {
this.initializeTomSelect()
}
disconnect() {
this.cleanup()
if (this.tomSelect) {
this.tomSelect.destroy()
}
}
}

View File

@@ -0,0 +1,76 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["progressBar", "totalRecords", "processedRecords", "failedRecords", "recordsPerSecond"]
static values = {
importId: Number,
refreshInterval: { type: Number, default: 2000 }
}
connect() {
if (this.hasImportIdValue) {
this.startUpdating()
}
}
disconnect() {
this.stopUpdating()
}
startUpdating() {
this.updateProgress()
this.interval = setInterval(() => {
this.updateProgress()
}, this.refreshIntervalValue)
}
stopUpdating() {
if (this.interval) {
clearInterval(this.interval)
}
}
async updateProgress() {
try {
const response = await fetch(`/data_imports/${this.importIdValue}/progress`)
const data = await response.json()
this.updateProgressBar(data.progress_percentage)
this.updateStats(data)
// If completed or failed, reload the page
if (data.status === 'completed' || data.status === 'failed') {
setTimeout(() => {
window.location.reload()
}, 2000)
this.stopUpdating()
}
} catch (error) {
console.error('Error updating progress:', error)
}
}
updateProgressBar(percentage) {
if (this.hasProgressBarTarget) {
this.progressBarTarget.style.width = `${percentage}%`
}
}
updateStats(data) {
if (this.hasTotalRecordsTarget) {
this.totalRecordsTarget.textContent = data.total_records.toLocaleString()
}
if (this.hasProcessedRecordsTarget) {
this.processedRecordsTarget.textContent = data.processed_records.toLocaleString()
}
if (this.hasFailedRecordsTarget) {
this.failedRecordsTarget.textContent = data.failed_records.toLocaleString()
}
if (this.hasRecordsPerSecondTarget) {
this.recordsPerSecondTarget.textContent = data.records_per_second.toLocaleString()
}
}
}

View File

@@ -0,0 +1,101 @@
class GeoliteAsnImportJob < ApplicationJob
queue_as :default
# No retry needed for CSV processing - either works or fails immediately
def perform(data_import)
Rails.logger.info "Starting GeoLite ASN import job for DataImport #{data_import.id}"
# Check if file is attached
unless data_import.file.attached?
Rails.logger.error "No file attached to DataImport #{data_import.id}"
data_import.fail!("No file attached")
return
end
# Download the file to a temporary location
temp_file = download_to_temp_file(data_import.file.blob)
if temp_file.nil?
Rails.logger.error "Failed to download file from storage"
data_import.fail!("Failed to download file from storage")
return
end
Rails.logger.info "File downloaded to: #{temp_file}"
Rails.logger.info "File exists: #{File.exist?(temp_file)}"
Rails.logger.info "File size: #{File.size(temp_file)} bytes" if File.exist?(temp_file)
# Mark as processing
data_import.start_processing!
importer = nil
begin
Rails.logger.info "Creating GeoliteAsnImporter"
importer = GeoliteAsnImporter.new(temp_file, data_import: data_import)
Rails.logger.info "Calling importer.import"
result = importer.import
# Update final stats
data_import.update_progress(
processed: result[:processed_records],
failed: result[:failed_records],
stats: {
total_records: result[:total_records],
errors: result[:errors].last(10), # Keep last 10 errors
completed_at: Time.current
}
)
data_import.complete!
# Log completion
Rails.logger.info "GeoLite ASN import completed: #{result[:processed_records]} processed, #{result[:failed_records]} failed"
rescue => e
Rails.logger.error "GeoLite ASN import failed: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
# Update final stats even on failure
if importer
data_import.update_progress(
processed: importer.instance_variable_get(:@processed_records),
failed: importer.instance_variable_get(:@failed_records),
stats: {
total_records: importer.instance_variable_get(:@total_records),
current_file: File.basename(temp_file),
errors: importer.instance_variable_get(:@errors).last(10),
failed_at: Time.current
}
)
end
data_import.fail!(e.message)
raise
ensure
# Cleanup temporary files
File.delete(temp_file) if temp_file && File.exist?(temp_file)
end
end
private
def download_to_temp_file(blob)
# Create a temporary file with the original filename
temp_file = Tempfile.new([blob.filename.to_s])
temp_file.binmode
# Download the blob content
blob.open do |file|
temp_file.write(file.read)
end
temp_file.close
temp_file.path
rescue => e
Rails.logger.error "Error downloading file: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
temp_file&.close
temp_file&.unlink
nil
end
end

View File

@@ -0,0 +1,101 @@
class GeoliteCountryImportJob < ApplicationJob
queue_as :default
# No retry needed for CSV processing - either works or fails immediately
def perform(data_import)
Rails.logger.info "Starting GeoLite Country import job for DataImport #{data_import.id}"
# Check if file is attached
unless data_import.file.attached?
Rails.logger.error "No file attached to DataImport #{data_import.id}"
data_import.fail!("No file attached")
return
end
# Download the file to a temporary location
temp_file = download_to_temp_file(data_import.file.blob)
if temp_file.nil?
Rails.logger.error "Failed to download file from storage"
data_import.fail!("Failed to download file from storage")
return
end
Rails.logger.info "File downloaded to: #{temp_file}"
Rails.logger.info "File exists: #{File.exist?(temp_file)}"
Rails.logger.info "File size: #{File.size(temp_file)} bytes" if File.exist?(temp_file)
# Mark as processing
data_import.start_processing!
importer = nil
begin
Rails.logger.info "Creating GeoliteCountryImporter"
importer = GeoliteCountryImporter.new(temp_file, data_import: data_import)
Rails.logger.info "Calling importer.import"
result = importer.import
# Update final stats
data_import.update_progress(
processed: result[:processed_records],
failed: result[:failed_records],
stats: {
total_records: result[:total_records],
errors: result[:errors].last(10), # Keep last 10 errors
completed_at: Time.current
}
)
data_import.complete!
# Log completion
Rails.logger.info "GeoLite Country import completed: #{result[:processed_records]} processed, #{result[:failed_records]} failed"
rescue => e
Rails.logger.error "GeoLite Country import failed: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
# Update final stats even on failure
if importer
data_import.update_progress(
processed: importer.instance_variable_get(:@processed_records),
failed: importer.instance_variable_get(:@failed_records),
stats: {
total_records: importer.instance_variable_get(:@total_records),
current_file: File.basename(temp_file),
errors: importer.instance_variable_get(:@errors).last(10),
failed_at: Time.current
}
)
end
data_import.fail!(e.message)
raise
ensure
# Cleanup temporary files
File.delete(temp_file) if temp_file && File.exist?(temp_file)
end
end
private
def download_to_temp_file(blob)
# Create a temporary file with the original filename
temp_file = Tempfile.new([blob.filename.to_s])
temp_file.binmode
# Download the blob content
blob.open do |file|
temp_file.write(file.read)
end
temp_file.close
temp_file.path
rescue => e
Rails.logger.error "Error downloading file: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
temp_file&.close
temp_file&.unlink
nil
end
end

View File

@@ -44,16 +44,20 @@ class ProcessWafAnalyticsJob < ApplicationJob
end
def analyze_geographic_distribution(event)
return unless event.country_code.present?
return unless event.has_geo_data?
# Check if this country is unusual globally
country_code = event.lookup_country
return unless country_code.present?
# Check if this country is unusual globally by joining through network ranges
country_events = Event
.where(country_code: event.country_code)
.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
.where("network_ranges.country = ?", country_code)
.where(timestamp: 1.hour.ago..Time.current)
# If this is the first event from this country or unusual spike
if country_events.count == 1 || country_events.count > 100
Rails.logger.info "Unusual geographic activity from #{event.country_code}"
Rails.logger.info "Unusual geographic activity from #{country_code}"
end
end

View File

@@ -26,12 +26,14 @@ class ProcessWafEventJob < ApplicationJob
# Create the WAF event record
event = Event.create_from_waf_payload!(event_id, single_event_data)
# Enrich with geo-location data if missing
if event.ip_address.present? && event.country_code.blank?
# Log geo-location data status (uses NetworkRange delegation)
if event.ip_address.present?
begin
event.enrich_geo_location!
unless event.has_geo_data?
Rails.logger.debug "No geo data available for event #{event.id} with IP #{event.ip_address}"
end
rescue => e
Rails.logger.warn "Failed to enrich geo location for event #{event.id}: #{e.message}"
Rails.logger.warn "Failed to check geo data for event #{event.id}: #{e.message}"
end
end

View File

@@ -7,7 +7,7 @@
class ProcessWafPoliciesJob < ApplicationJob
queue_as :waf_policies
retry_on StandardError, wait: :exponentially_longer, attempts: 3
retry_on StandardError, wait: 5.seconds, attempts: 3
def perform(network_range_id:, event_id: nil)
# Find the network range

96
app/models/data_import.rb Normal file
View File

@@ -0,0 +1,96 @@
class DataImport < ApplicationRecord
has_one_attached :file
validates :import_type, presence: true, inclusion: { in: %w[asn country] }
validates :status, presence: true, inclusion: { in: %w[pending processing completed failed] }
validates :filename, presence: true
attribute :import_stats, default: -> { {} }
# Scopes
scope :recent, -> { order(created_at: :desc) }
scope :by_type, ->(type) { where(import_type: type) }
scope :by_status, ->(status) { where(status: status) }
scope :completed, -> { where(status: 'completed') }
scope :failed, -> { where(status: 'failed') }
scope :processing, -> { where(status: 'processing') }
scope :pending, -> { where(status: 'pending') }
# State management
def pending?
status == 'pending'
end
def processing?
status == 'processing'
end
def completed?
status == 'completed'
end
def failed?
status == 'failed'
end
def start_processing!
update!(
status: 'processing',
started_at: Time.current
)
end
def complete!
updates = {
status: 'completed',
completed_at: Time.current
}
updates[:total_records] = processed_records if total_records.zero?
update!(updates)
end
def fail!(error_message = nil)
update!(
status: 'failed',
completed_at: Time.current,
error_message: error_message
)
end
def progress_percentage
if total_records.zero?
processing? ? 0.1 : 0 # Show minimal progress for processing jobs
else
(processed_records.to_f / total_records * 100).round(2)
end
end
def duration
return 0 unless started_at
end_time = completed_at || Time.current
duration_seconds = (end_time - started_at).round(2)
duration_seconds.negative? ? 0 : duration_seconds
end
def records_per_second
# Handle very fast imports that complete in less than 1 second
if duration.zero?
# Use time since started if no duration available yet
time_elapsed = started_at ? (Time.current - started_at) : 0
return 0 if time_elapsed < 1
(processed_records.to_f / time_elapsed).round(2)
else
(processed_records.to_f / duration).round(2)
end
end
def update_progress(processed: nil, failed: nil, total_records: nil, stats: nil)
updates = {}
updates[:processed_records] = processed if processed
updates[:failed_records] = failed if failed
updates[:total_records] = total_records if total_records
updates[:import_stats] = stats if stats
update!(updates) if updates.any?
end
end

View File

@@ -25,6 +25,10 @@ class Event < ApplicationRecord
# Serialize segment IDs as array for easy manipulation in Railssqit
serialize :request_segment_ids, type: Array, coder: JSON
# Tags are stored as JSON arrays with PostgreSQL jsonb type
# This provides direct array access and efficient indexing
attribute :tags, :json, default: -> { [] }
validates :event_id, presence: true, uniqueness: true
validates :timestamp, presence: true
@@ -36,6 +40,21 @@ class Event < ApplicationRecord
scope :allowed, -> { where(waf_action: :allow) }
scope :rate_limited, -> { where(waf_action: 'rate_limit') }
# Tag-based filtering scopes using PostgreSQL array operators
scope :with_tag, ->(tag) { where("tags @> ARRAY[?]", tag.to_s) }
scope :with_any_tags, ->(tags) {
return none if tags.blank?
tag_array = Array(tags).map(&:to_s)
where("tags && ARRAY[?]", tag_array)
}
scope :with_all_tags, ->(tags) {
return none if tags.blank?
tag_array = Array(tags).map(&:to_s)
where("tags @> ARRAY[?]", tag_array)
}
# Network-based filtering scopes
scope :by_company, ->(company) {
joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
@@ -234,7 +253,8 @@ class Event < ApplicationRecord
end
def tags
payload&.dig("tags") || {}
# Use the dedicated tags column (array), fallback to payload during transition
super.presence || (payload&.dig("tags") || [])
end
def headers
@@ -281,6 +301,25 @@ class Event < ApplicationRecord
URI.parse(request_url).hostname rescue nil
end
# Tag helper methods
def add_tag(tag)
tag_str = tag.to_s
self.tags = (tags + [tag_str]).uniq unless tags.include?(tag_str)
end
def remove_tag(tag)
tag_str = tag.to_s
self.tags = tags - [tag_str] if tags.include?(tag_str)
end
def has_tag?(tag)
tags.include?(tag.to_s)
end
def tag_list
tags.join(', ')
end
# Normalize headers to lower case keys during import phase
def normalize_headers(headers)
return {} unless headers.is_a?(Hash)

View File

@@ -7,7 +7,7 @@
# and classification flags (datacenter, proxy, VPN).
class NetworkRange < ApplicationRecord
# Sources for network range creation
SOURCES = %w[api_imported user_created manual auto_generated inherited].freeze
SOURCES = %w[api_imported user_created manual auto_generated inherited geolite_asn geolite_country].freeze
# Associations
has_many :rules, dependent: :destroy
@@ -29,6 +29,9 @@ class NetworkRange < ApplicationRecord
scope :vpn, -> { where(is_vpn: true) }
scope :user_created, -> { where(source: 'user_created') }
scope :api_imported, -> { where(source: 'api_imported') }
scope :geolite_imported, -> { where(source: ['geolite_asn', 'geolite_country']) }
scope :geolite_asn, -> { where(source: 'geolite_asn') }
scope :geolite_country, -> { where(source: 'geolite_country') }
scope :with_events, -> { where("events_count > 0") }
scope :most_active, -> { order(events_count: :desc) }
@@ -295,4 +298,44 @@ class NetworkRange < ApplicationRecord
# The inherited_intelligence method will pick up the new parent data
end
end
# Import-related class methods
def self.import_stats_by_source
group(:source)
.select(:source, 'COUNT(*) as count', 'MIN(created_at) as first_import', 'MAX(updated_at) as last_update')
.order(:source)
end
def self.geolite_coverage_stats
{
total_networks: geolite_imported.count,
asn_networks: geolite_asn.count,
country_networks: geolite_country.count,
with_asn_data: geolite_imported.where.not(asn: nil).count,
with_country_data: geolite_imported.where.not(country: nil).count,
with_proxy_data: geolite_imported.where(is_proxy: true).count,
unique_countries: geolite_imported.distinct.count(:country),
unique_asns: geolite_imported.distinct.count(:asn),
ipv4_networks: geolite_imported.ipv4.count,
ipv6_networks: geolite_imported.ipv6.count
}
end
def self.find_by_ip_or_network(query)
return none if query.blank?
begin
# Try to parse as IP address first
ip = IPAddr.new(query)
where("network >>= ?", ip.to_s)
rescue IPAddr::InvalidAddressError
# Try to parse as network
begin
network = IPAddr.new(query)
where(network: network.to_s)
rescue IPAddr::InvalidAddressError
none
end
end
end
end

View File

@@ -122,7 +122,7 @@ validate :targets_must_be_array
network_range: network_range,
waf_policy: self,
user: user,
source: "policy:#{name}",
source: "policy",
metadata: build_rule_metadata(network_range),
priority: network_range.prefix_length
)

View File

@@ -0,0 +1,58 @@
# frozen_string_literal: true
class WafPolicyPolicy < ApplicationPolicy
def index?
true # All authenticated users can view policies
end
def show?
true # All authenticated users can view policy details
end
def new?
user.admin? || user.editor?
end
def create?
user.admin? || user.editor?
end
def edit?
user.admin? || (user.editor? && record.user == user)
end
def update?
user.admin? || (user.editor? && record.user == user)
end
def destroy?
user.admin? || (user.editor? && record.user == user)
end
def activate?
user.admin? || (user.editor? && record.user == user)
end
def deactivate?
user.admin? || (user.editor? && record.user == user)
end
def new_country?
create?
end
def create_country?
create?
end
class Scope < ApplicationPolicy::Scope
def resolve
if user.admin?
scope.all
else
# Non-admin users can only see their own policies
scope.where(user: user)
end
end
end
end

View File

@@ -0,0 +1,151 @@
# frozen_string_literal: true
# CountryHelper - Service for country display utilities
#
# Provides methods to convert ISO country codes to display names,
# generate country flags, and format country data for UI components.
class CountryHelper
# Convert ISO code to display name
def self.display_name(iso_code)
return iso_code if iso_code.blank?
country = ISO3166::Country[iso_code]
country.local_name
rescue
iso_code
end
# Convert ISO code to flag emoji
def self.flag_emoji(iso_code)
return "" if iso_code.blank? || iso_code.length != 2
# Convert each letter to regional indicator symbol (A + 0x1F1E5)
iso_code.upcase.codepoints.map { |code| (code + 0x1F1E5).chr }.join
rescue
""
end
# Display name with flag
def self.display_with_flag(iso_code)
return iso_code if iso_code.blank?
"#{display_name(iso_code)} (#{iso_code})"
end
# Check if ISO code is valid
def self.valid_iso_code?(iso_code)
return false if iso_code.blank?
ISO3166::Country[iso_code].present?
rescue
false
end
# Get all countries for select dropdowns
# Returns array of [display_name, iso_code] pairs
def self.all_for_select
ISO3166::Country.all.map do |country|
# Try different name sources in order of preference
# Use the proper countries gem methods
name = country.local_name.presence ||
country.iso_short_name.presence ||
country.common_name.presence ||
country.alpha2
display_name = "#{name} (#{country.alpha2})"
[display_name, country.alpha2]
end.sort_by { |name, _| name }
rescue => e
puts "Error in CountryHelper.all_for_select: #{e.message}"
puts e.backtrace
[]
end
# Get countries by common regions for quick selection
def self.by_region
{
'Americas' => [
'US', 'CA', 'MX', 'BR', 'AR', 'CL', 'CO', 'PE', 'VE'
],
'Europe' => [
'GB', 'DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'CH', 'AT', 'SE',
'NO', 'DK', 'FI', 'PL', 'CZ', 'HU', 'RO', 'GR', 'PT'
],
'Asia Pacific' => [
'CN', 'JP', 'KR', 'IN', 'SG', 'AU', 'NZ', 'TH', 'MY', 'ID',
'PH', 'VN', 'HK', 'TW'
],
'Middle East & Africa' => [
'ZA', 'EG', 'NG', 'KE', 'SA', 'AE', 'IL', 'TR', 'IR'
]
}
rescue
{}
end
# Get countries for specific region with display names
def self.countries_for_region(region_name)
country_codes = by_region[region_name] || []
country_codes.map do |code|
{
code: code,
name: display_name(code),
display: display_with_flag(code)
}
end
end
# Format multiple country targets for display
def self.format_targets(targets)
return [] if targets.blank?
targets.map do |target|
{
code: target,
name: display_name(target),
display: display_with_flag(target)
}
end
end
# Get popular countries for quick blocking (common threat sources)
def self.popular_for_blocking
[
{ code: 'CN', name: 'China', display: '🇨🇳 China', reason: 'High bot/scanner activity' },
{ code: 'RU', name: 'Russia', display: '🇷🇺 Russia', reason: 'State-sponsored attacks' },
{ code: 'IN', name: 'India', display: '🇮🇳 India', reason: 'High spam volume' },
{ code: 'BR', name: 'Brazil', display: '🇧🇷 Brazil', reason: 'Scanner activity' },
{ code: 'IR', name: 'Iran', display: '🇮🇷 Iran', reason: 'Attacks on critical infrastructure' },
{ code: 'KP', name: 'North Korea', display: '🇰🇵 North Korea', reason: 'State-sponsored hacking' }
]
end
# Search countries by name or code
def self.search(query)
return [] if query.blank?
query = query.downcase
ISO3166::Country.all.select do |country|
country.alpha2.downcase.include?(query) ||
country.local_name.downcase.include?(query)
end.first(20).map { |c| [display_with_flag(c.alpha2), c.alpha2] }
rescue
[]
end
# Country statistics for analytics
def self.usage_statistics(country_codes)
return {} if country_codes.blank?
stats = {}
country_codes.each do |code|
stats[code] = {
name: display_name(code),
flag: flag_emoji(code),
display: display_with_flag(code)
}
end
stats
end
end

View File

@@ -0,0 +1,182 @@
require 'csv'
class GeoliteAsnImporter
BATCH_SIZE = 1000
def initialize(file_path, data_import:)
@file_path = file_path
@data_import = data_import
@total_records = 0
@processed_records = 0
@failed_records = 0
@errors = []
end
def import
Rails.logger.info "Starting import for file: #{@file_path}"
Rails.logger.info "File exists: #{File.exist?(@file_path)}"
Rails.logger.info "File size: #{File.size(@file_path)} bytes" if File.exist?(@file_path)
# Check if file is actually a zip by reading the magic bytes
is_zip_file = check_if_zip_file
Rails.logger.info "File is zip: #{is_zip_file}"
if is_zip_file
import_from_zip
else
import_csv_file(@file_path)
end
{
total_records: @total_records,
processed_records: @processed_records,
failed_records: @failed_records,
errors: @errors
}
end
private
def check_if_zip_file
# Check if the file starts with ZIP magic bytes (PK\x03\x04)
File.open(@file_path, 'rb') do |file|
header = file.read(4)
return header == "PK\x03\x04"
end
rescue => e
Rails.logger.error "Error checking if file is zip: #{e.message}"
false
end
def import_from_zip
require 'zip'
require 'stringio'
Rails.logger.info "Processing zip file directly: #{@file_path}"
# Read the entire ZIP file content into memory first
zip_content = File.binread(@file_path)
Zip::File.open_buffer(StringIO.new(zip_content)) do |zip_file|
zip_file.each do |entry|
if entry.name.include?('Blocks') && entry.name.end_with?('.csv')
Rails.logger.info "Processing ASN block file from zip: #{entry.name}"
process_csv_from_zip(zip_file, entry)
end
end
end
rescue => e
Rails.logger.error "Error processing ZIP file: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
raise
end
def process_csv_from_zip(zip_file, entry)
zip_file.get_input_stream(entry) do |io|
# Read the entire content from the stream
content = io.read
CSV.parse(content, headers: true, header_converters: :symbol, encoding: 'UTF-8') do |row|
@total_records += 1
begin
import_record(row)
@processed_records += 1
rescue => e
@failed_records += 1
@errors << "Row #{@total_records}: #{e.message} - Data: #{row.to_h}"
end
update_progress_if_needed
end
end
end
def csv_files
if @file_path.end_with?('.zip')
# Look for extracted CSV files in the same directory
base_dir = File.dirname(@file_path)
base_name = File.basename(@file_path, '.zip')
[
File.join(base_dir, "#{base_name}-Blocks-IPv4.csv"),
File.join(base_dir, "#{base_name}-Blocks-IPv6.csv")
].select { |file| File.exist?(file) }
else
[@file_path]
end
end
def import_csv_file(csv_file)
CSV.foreach(csv_file, headers: true, header_converters: :symbol, encoding: 'UTF-8') do |row|
@total_records += 1
begin
import_record(row)
@processed_records += 1
rescue => e
@failed_records += 1
@errors << "Row #{@total_records}: #{e.message} - Data: #{row.to_h}"
# Update progress every 100 records or on error
update_progress_if_needed
end
update_progress_if_needed
end
end
def import_record(row)
network = row[:network]
asn = row[:autonomous_system_number]&.to_i
asn_org = row[:autonomous_system_organization]&.strip
unless network && asn && asn_org
raise "Missing required fields: network=#{network}, asn=#{asn}, asn_org=#{asn_org}"
end
# Validate network format
IPAddr.new(network) # This will raise if invalid
NetworkRange.upsert(
{
network: network,
asn: asn,
asn_org: asn_org,
source: 'geolite_asn',
updated_at: Time.current
},
unique_by: :index_network_ranges_on_network_unique
)
end
def update_progress_if_needed
if (@processed_records + @failed_records) % 100 == 0
@data_import.update_progress(
processed: @processed_records,
failed: @failed_records,
total_records: @total_records,
stats: {
total_records: @total_records,
current_file: File.basename(@file_path),
recent_errors: @errors.last(5)
}
)
end
end
def extract_if_zipfile
return unless @file_path.end_with?('.zip')
require 'zip'
Zip::File.open(@file_path) do |zip_file|
zip_file.each do |entry|
if entry.name.end_with?('.csv')
extract_path = File.join(File.dirname(@file_path), entry.name)
entry.extract(extract_path)
end
end
end
end
end

View File

@@ -0,0 +1,288 @@
require 'csv'
class GeoliteCountryImporter
BATCH_SIZE = 1000
def initialize(file_path, data_import:)
@file_path = file_path
@data_import = data_import
@total_records = 0
@processed_records = 0
@failed_records = 0
@errors = []
@locations_cache = {}
end
def import
Rails.logger.info "Starting import for file: #{@file_path}"
Rails.logger.info "File exists: #{File.exist?(@file_path)}"
Rails.logger.info "File size: #{File.size(@file_path)} bytes" if File.exist?(@file_path)
# Check if file is actually a zip by reading the magic bytes
is_zip_file = check_if_zip_file
Rails.logger.info "File is zip: #{is_zip_file}"
if is_zip_file
Rails.logger.info "Calling import_from_zip"
import_from_zip
else
Rails.logger.info "Calling regular import (not zip)"
load_locations_data
import_csv_file(@file_path)
end
{
total_records: @total_records,
processed_records: @processed_records,
failed_records: @failed_records,
errors: @errors
}
end
private
def check_if_zip_file
# Check if the file starts with ZIP magic bytes (PK\x03\x04)
File.open(@file_path, 'rb') do |file|
header = file.read(4)
return header == "PK\x03\x04"
end
rescue => e
Rails.logger.error "Error checking if file is zip: #{e.message}"
false
end
def import_from_zip
require 'zip'
require 'stringio'
Rails.logger.info "Processing zip file directly: #{@file_path}"
# Read the entire ZIP file content into memory first
zip_content = File.binread(@file_path)
Zip::File.open_buffer(StringIO.new(zip_content)) do |zip_file|
# First, see what's in the zip
Rails.logger.info "Files in zip:"
zip_file.each do |entry|
Rails.logger.info " - #{entry.name} (#{entry.size} bytes)"
end
# First, load location data from zip
load_locations_data_from_zip(zip_file)
# Then process block files from zip
zip_file.each do |entry|
if entry.name.include?('Blocks') && entry.name.end_with?('.csv')
Rails.logger.info "Processing block file from zip: #{entry.name}"
process_csv_from_zip(zip_file, entry)
end
end
end
rescue => e
Rails.logger.error "Error processing ZIP file: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
raise
end
def process_csv_from_zip(zip_file, entry)
zip_file.get_input_stream(entry) do |io|
# Read the entire content from the stream
content = io.read
# Try different encodings if UTF-8 fails
encodings = ['UTF-8', 'ISO-8859-1', 'Windows-1252']
encoding_used = nil
encodings.each do |encoding|
begin
# Parse the CSV content from the string
CSV.parse(content, headers: true, header_converters: :symbol, encoding: encoding) do |row|
@total_records += 1
begin
import_record(row)
@processed_records += 1
rescue => e
@failed_records += 1
@errors << "Row #{@total_records}: #{e.message} - Data: #{row.to_h}"
end
update_progress_if_needed
end
encoding_used = encoding
Rails.logger.info "Successfully processed #{entry.name} with #{encoding} encoding"
break
rescue CSV::InvalidEncodingError => e
Rails.logger.warn "Failed to process #{entry.name} with #{encoding} encoding: #{e.message}"
next if encoding != encodings.last
raise e if encoding == encodings.last
end
end
unless encoding_used
@errors << "Failed to process #{entry.name} with any supported encoding"
end
end
end
def load_locations_data_from_zip(zip_file)
require 'zip'
# Find all location files and prioritize English
location_entries = zip_file.select { |entry| entry.name.include?('Locations') && entry.name.end_with?('.csv') }
# Sort to prioritize English locations file
location_entries.sort_by! { |entry| entry.name.include?('Locations-en') ? 0 : 1 }
location_entries.each do |entry|
Rails.logger.info "Loading locations from: #{entry.name}"
zip_file.get_input_stream(entry) do |io|
# Read the entire content from the stream
content = io.read
# Try different encodings if UTF-8 fails
encodings = ['UTF-8', 'ISO-8859-1', 'Windows-1252']
encodings.each do |encoding|
begin
# Parse the CSV content from the string
CSV.parse(content, headers: true, header_converters: :symbol, encoding: encoding) do |row|
geoname_id = row[:geoname_id]
next unless geoname_id
@locations_cache[geoname_id] = {
country_iso_code: row[:country_iso_code],
country_name: row[:country_name],
continent_code: row[:continent_code],
continent_name: row[:continent_name],
is_in_european_union: row[:is_in_european_union]
}
end
Rails.logger.info "Loaded locations from #{entry.name} with #{encoding} encoding"
break
rescue CSV::InvalidEncodingError => e
Rails.logger.warn "Failed to load locations from #{entry.name} with #{encoding} encoding: #{e.message}"
next if encoding != encodings.last
raise e if encoding == encodings.last
end
end
end
end
Rails.logger.info "Loaded #{@locations_cache.size} location records"
end
def import_csv_file(csv_file)
CSV.foreach(csv_file, headers: true, header_converters: :symbol, encoding: 'UTF-8') do |row|
@total_records += 1
begin
import_record(row)
@processed_records += 1
rescue => e
@failed_records += 1
@errors << "Row #{@total_records}: #{e.message} - Data: #{row.to_h}"
# Update progress every 100 records or on error
update_progress_if_needed
end
update_progress_if_needed
end
end
def import_record(row)
network = row[:network]
geoname_id = row[:geoname_id]
registered_country_geoname_id = row[:registered_country_geoname_id]
is_anonymous_proxy = row[:is_anonymous_proxy] == '1'
is_satellite_provider = row[:is_satellite_provider] == '1'
is_anycast = row[:is_anycast] == '1'
unless network
raise "Missing required field: network"
end
# Validate network format
IPAddr.new(network) # This will raise if invalid
# Get location data - prefer geoname_id, then registered_country_geoname_id
location_data = @locations_cache[geoname_id] || @locations_cache[registered_country_geoname_id] || {}
additional_data = {
geoname_id: geoname_id,
registered_country_geoname_id: registered_country_geoname_id,
represented_country_geoname_id: row[:represented_country_geoname_id],
continent_code: location_data[:continent_code],
continent_name: location_data[:continent_name],
country_name: location_data[:country_name],
is_in_european_union: location_data[:is_in_european_union],
is_satellite_provider: is_satellite_provider,
is_anycast: is_anycast
}.compact
NetworkRange.upsert(
{
network: network,
country: location_data[:country_iso_code],
is_proxy: is_anonymous_proxy,
source: 'geolite_country',
additional_data: additional_data,
updated_at: Time.current
},
unique_by: :index_network_ranges_on_network_unique
)
end
def update_progress_if_needed
if (@processed_records + @failed_records) % 100 == 0
@data_import.update_progress(
processed: @processed_records,
failed: @failed_records,
total_records: @total_records,
stats: {
total_records: @total_records,
current_file: File.basename(@file_path),
locations_loaded: @locations_cache.size,
recent_errors: @errors.last(5)
}
)
end
end
def load_locations_data
locations_files = find_locations_files
locations_files.each do |locations_file|
CSV.foreach(locations_file, headers: true, header_converters: :symbol, encoding: 'UTF-8') do |row|
geoname_id = row[:geoname_id]
next unless geoname_id
@locations_cache[geoname_id] = {
country_iso_code: row[:country_iso_code],
country_name: row[:country_name],
continent_code: row[:continent_code],
continent_name: row[:continent_name],
is_in_european_union: row[:is_in_european_union]
}
end
end
end
def find_locations_files
if @file_path.end_with?('.zip')
base_dir = File.dirname(@file_path)
base_name = File.basename(@file_path, '.zip')
# Look for English locations file first, then any locations file
[
File.join(base_dir, "#{base_name}-Locations-en.csv"),
Dir[File.join(base_dir, "#{base_name}-Locations-*.csv")].first
].compact.select { |file| File.exist?(file) }
else
base_dir = File.dirname(@file_path)
Dir[File.join(base_dir, "*Locations*.csv")].select { |file| File.exist?(file) }
end
end
end

View File

@@ -0,0 +1,159 @@
# frozen_string_literal: true
# Service for automatically creating network ranges for unmatched IPs
class NetworkRangeGenerator
include ActiveModel::Model
include ActiveModel::Attributes
# Minimum network sizes for different IP types
IPV4_MIN_SIZE = 24 # /24 = 256 IPs
IPV6_MIN_SIZE = 64 # /64 = 2^64 IPs (standard IPv6 allocation)
# Special network ranges to avoid
RESERVED_RANGES = [
IPAddr.new('10.0.0.0/8'), # Private
IPAddr.new('172.16.0.0/12'), # Private
IPAddr.new('192.168.0.0/16'), # Private
IPAddr.new('127.0.0.0/8'), # Loopback
IPAddr.new('169.254.0.0/16'), # Link-local
IPAddr.new('224.0.0.0/4'), # Multicast
IPAddr.new('240.0.0.0/4'), # Reserved
IPAddr.new('::1/128'), # IPv6 loopback
IPAddr.new('fc00::/7'), # IPv6 private
IPAddr.new('fe80::/10'), # IPv6 link-local
IPAddr.new('ff00::/8') # IPv6 multicast
].freeze
# Special network ranges to avoid
RESERVED_RANGES = [
IPAddr.new('10.0.0.0/8'), # Private
IPAddr.new('172.16.0.0/12'), # Private
IPAddr.new('192.168.0.0/16'), # Private
IPAddr.new('127.0.0.0/8'), # Loopback
IPAddr.new('169.254.0.0/16'), # Link-local
IPAddr.new('224.0.0.0/4'), # Multicast
IPAddr.new('240.0.0.0/4'), # Reserved
IPAddr.new('::1/128'), # IPv6 loopback
IPAddr.new('fc00::/7'), # IPv6 private
IPAddr.new('fe80::/10'), # IPv6 link-local
IPAddr.new('ff00::/8') # IPv6 multicast
].freeze
class << self
# Find or create a network range for the given IP address
def find_or_create_for_ip(ip_address, user: nil)
ip_str = ip_address.to_s
ip_obj = ip_address.is_a?(IPAddr) ? ip_address : IPAddr.new(ip_str)
# Check if IP already matches existing ranges
existing_range = NetworkRange.contains_ip(ip_str).first
if existing_range
# If we have an existing range and it's a /32 (single IP),
# create a larger network range instead for better analytics
if existing_range.masklen == 32
# Don't overwrite manually created or imported ranges
unless %w[manual user_created api_imported].include?(existing_range.source)
return create_appropriate_network(ip_obj, user: user)
end
end
return existing_range
end
# Create the appropriate network range for this IP
create_appropriate_network(ip_obj, user: user)
end
# Get the appropriate minimum network size for an IP
def minimum_network_size(ip_address)
return IPV6_MIN_SIZE if ip_address.ipv6?
# For IPv4, use larger networks for known datacenter/ranges
if datacenter_ip?(ip_address)
20 # /20 = 4096 IPs for large providers
else
IPV4_MIN_SIZE # /24 = 256 IPs for general use
end
end
# Check if IP is in a datacenter range
def datacenter_ip?(ip_address)
# Known major cloud provider ranges
cloud_ranges = [
IPAddr.new('3.0.0.0/8'), # AWS
IPAddr.new('52.0.0.0/8'), # AWS
IPAddr.new('54.0.0.0/8'), # AWS
IPAddr.new('13.0.0.0/8'), # AWS
IPAddr.new('104.16.0.0/12'), # Cloudflare
IPAddr.new('172.64.0.0/13'), # Cloudflare
IPAddr.new('104.24.0.0/14'), # Cloudflare
IPAddr.new('172.68.0.0/14'), # Cloudflare
IPAddr.new('108.170.0.0/16'), # Google
IPAddr.new('173.194.0.0/16'), # Google
IPAddr.new('209.85.0.0/16'), # Google
IPAddr.new('157.240.0.0/16'), # Facebook/Meta
IPAddr.new('31.13.0.0/16'), # Facebook/Meta
IPAddr.new('69.63.0.0/16'), # Facebook/Meta
IPAddr.new('173.252.0.0/16'), # Facebook/Meta
IPAddr.new('20.0.0.0/8'), # Microsoft Azure
IPAddr.new('40.64.0.0/10'), # Microsoft Azure
IPAddr.new('40.96.0.0/11'), # Microsoft Azure
IPAddr.new('40.112.0.0/12'), # Microsoft Azure
IPAddr.new('40.123.0.0/16'), # Microsoft Azure
IPAddr.new('40.124.0.0/14'), # Microsoft Azure
IPAddr.new('40.126.0.0/15'), # Microsoft Azure
]
cloud_ranges.any? { |range| range.include?(ip_address) }
end
private
# Create the appropriate network range containing the IP
def create_appropriate_network(ip_address, user: nil)
prefix_length = minimum_network_size(ip_address)
# Create the network range with the IP at the center if possible
network_cidr = create_network_with_ip(ip_address, prefix_length)
# Check if network already exists
existing = NetworkRange.find_by(network: network_cidr)
return existing if existing
# Create new network range
NetworkRange.create!(
network: network_cidr,
source: 'auto_generated',
creation_reason: "auto-generated for unmatched IP traffic",
user: user,
company: nil, # Will be filled by enrichment job
asn: nil,
country: nil,
is_datacenter: datacenter_ip?(ip_address),
is_vpn: false,
is_proxy: false
)
end
# Create a network CIDR that contains the given IP with specified prefix length
def create_network_with_ip(ip_address, prefix_length)
# Convert IP to integer and apply mask
ip_int = ip_address.to_i
if ip_address.ipv6?
# For IPv6, mask to prefix length
mask = (2**128 - 1) ^ ((2**(128 - prefix_length)) - 1)
network_int = ip_int & mask
result = IPAddr.new(network_int, Socket::AF_INET6).mask(prefix_length)
else
# For IPv4, mask to prefix length
mask = (2**32 - 1) ^ ((2**(32 - prefix_length)) - 1)
network_int = ip_int & mask
result = IPAddr.new(network_int, Socket::AF_INET).mask(prefix_length)
end
# Return the CIDR notation
result.to_s
end
end
end

View File

@@ -0,0 +1,177 @@
# frozen_string_literal: true
# WafPolicyMatcher - Service to match NetworkRanges against active WafPolicies
#
# This service provides efficient matching of network ranges against firewall policies
# and can generate rules when matches are found.
class WafPolicyMatcher
include ActiveModel::Model
include ActiveModel::Attributes
attr_accessor :network_range
attr_reader :matching_policies, :generated_rules
def initialize(network_range:)
@network_range = network_range
@matching_policies = []
@generated_rules = []
end
# Find all active policies that match the given network range
def find_matching_policies
return [] unless network_range.present?
@matching_policies = active_policies.select do |policy|
policy.matches_network_range?(network_range)
end
# Sort by priority: country > asn > company > network_type, then by creation date
@matching_policies.sort_by do |policy|
priority_score = case policy.policy_type
when 'country'
1
when 'asn'
2
when 'company'
3
when 'network_type'
4
else
99
end
[priority_score, policy.created_at]
end
end
# Generate rules from matching policies
def generate_rules
return [] if matching_policies.empty?
@generated_rules = matching_policies.map do |policy|
# Check if rule already exists for this network range and policy
existing_rule = Rule.find_by(
network_range: network_range,
waf_policy: policy,
enabled: true
)
if existing_rule
Rails.logger.debug "Rule already exists for network_range #{network_range.cidr} and policy #{policy.name}"
existing_rule
else
rule = policy.create_rule_for_network_range(network_range)
if rule
Rails.logger.info "Generated rule for network_range #{network_range.cidr} from policy #{policy.name}"
end
rule
end
end.compact
end
# Find and generate rules in one step
def match_and_generate_rules
find_matching_policies
generate_rules
end
# Class methods for batch processing
def self.process_network_range(network_range)
matcher = new(network_range: network_range)
matcher.match_and_generate_rules
end
def self.batch_process_network_ranges(network_ranges)
results = []
network_ranges.each do |network_range|
matcher = new(network_range: network_range)
result = matcher.match_and_generate_rules
results << {
network_range: network_range,
matching_policies: matcher.matching_policies,
generated_rules: matcher.generated_rules
}
end
results
end
# Process network ranges that need policy evaluation
def self.process_ranges_without_policy_rules(limit: 100)
# Find network ranges that don't have policy-generated rules
# but have intelligence data that could match policies
ranges_needing_evaluation = NetworkRange
.left_joins(:rules)
.where("rules.id IS NULL OR rules.waf_policy_id IS NULL")
.where("(country IS NOT NULL OR asn IS NOT NULL OR company IS NOT NULL OR is_datacenter = true OR is_proxy = true OR is_vpn = true)")
.limit(limit)
.includes(:rules)
batch_process_network_ranges(ranges_needing_evaluation)
end
# Re-evaluate all network ranges for policy changes
def self.reprocess_all_for_policy(waf_policy)
# Find all network ranges that could potentially match this policy
potential_ranges = case waf_policy.policy_type
when 'country'
NetworkRange.where(country: waf_policy.targets)
when 'asn'
NetworkRange.where(asn: waf_policy.targets)
when 'network_type'
NetworkRange.where(
"is_datacenter = ? OR is_proxy = ? OR is_vpn = ?",
waf_policy.targets.include?('datacenter'),
waf_policy.targets.include?('proxy'),
waf_policy.targets.include?('vpn')
)
when 'company'
# For company matching, we need to do text matching
NetworkRange.where("company ILIKE ANY (array[?])",
waf_policy.targets.map { |c| "%#{c}%" })
else
NetworkRange.none
end
results = []
potential_ranges.find_each do |network_range|
matcher = new(network_range: network_range)
if waf_policy.matches_network_range?(network_range)
rule = waf_policy.create_rule_for_network_range(network_range)
results << { network_range: network_range, generated_rule: rule } if rule
end
end
results
end
# Statistics and reporting
def self.matching_policies_for_network_range(network_range)
matcher = new(network_range: network_range)
matcher.find_matching_policies
end
def self.policy_effectiveness_stats(waf_policy, days: 30)
cutoff_date = days.days.ago
rules = waf_policy.generated_rules.where('created_at > ?', cutoff_date)
{
policy_name: waf_policy.name,
policy_type: waf_policy.policy_type,
action: waf_policy.action,
rules_generated: rules.count,
active_rules: rules.active.count,
networks_protected: rules.joins(:network_range).count('distinct network_ranges.id'),
period_days: days,
generation_rate: rules.count.to_f / days
}
end
private
def active_policies
@active_policies ||= WafPolicy.active
end
end

View File

@@ -0,0 +1,80 @@
<div class="bg-white shadow-sm rounded-lg mb-6">
<div class="px-6 py-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">Import Progress</h2>
<%# Reuse the status_badge helper - need to define it here since it's a partial %>
<% def status_badge(status) %>
<% case status %>
<% when 'pending' %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<%= status.capitalize %>
</span>
<% when 'processing' %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<%= status.capitalize %>
</span>
<% when 'completed' %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<%= status.capitalize %>
</span>
<% when 'failed' %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<%= status.capitalize %>
</span>
<% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<%= status.capitalize %>
</span>
<% end %>
<% end %>
<%= status_badge(@data_import.status) %>
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="flex items-center justify-between text-sm text-gray-600 mb-1">
<span><%= number_with_delimiter(@data_import.processed_records) %> of <%= number_with_delimiter(@data_import.total_records) %> records</span>
<span><%= @data_import.progress_percentage %>%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: <%= @data_import.progress_percentage %>%"></div>
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-2xl font-semibold text-gray-900">
<%= number_with_delimiter(@data_import.total_records) %>
</div>
<div class="text-sm text-gray-600">Total Records</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-semibold text-green-900">
<%= number_with_delimiter(@data_import.processed_records) %>
</div>
<div class="text-sm text-green-600">Processed</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-semibold text-red-900">
<%= number_with_delimiter(@data_import.failed_records) %>
</div>
<div class="text-sm text-red-600">Failed</div>
</div>
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-semibold text-blue-900">
<%= number_with_delimiter(@data_import.records_per_second) %>
</div>
<div class="text-sm text-blue-600">Records/Sec</div>
</div>
</div>
</div>
</div>
<%# Auto-refresh logic for completed/failed imports %>
<% if @data_import.completed? || @data_import.failed? %>
<script>
setTimeout(() => window.location.reload(), 2000);
</script>
<% end %>

View File

@@ -0,0 +1,273 @@
<%# Helper methods %>
<% def status_badge_class(status) %>
<% case status %>
<% when 'pending' %>
bg-gray-100 text-gray-800
<% when 'processing' %>
bg-blue-100 text-blue-800
<% when 'completed' %>
bg-green-100 text-green-800
<% when 'failed' %>
bg-red-100 text-red-800
<% else %>
bg-gray-100 text-gray-800
<% end %>
<% end %>
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="bg-white shadow-sm rounded-lg mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">GeoLite2 Data Imports</h1>
<p class="mt-1 text-sm text-gray-600">
Manage and monitor your GeoLite2 database imports.
</p>
</div>
<%= link_to "New Import", new_data_import_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
</div>
</div>
</div>
<!-- Filters -->
<div class="bg-white shadow-sm rounded-lg mb-6">
<div class="px-6 py-4">
<%= form_with(url: data_imports_path, method: :get, local: true) do |f| %>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<%= f.label :import_type, "Import Type", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= f.select :import_type,
options_for_select([['All Types', ''], ['ASN', 'asn'], ['Country', 'country']], params[:import_type]),
{ }, { class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
</div>
<div>
<%= f.label :status, "Status", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= f.select :status,
options_for_select([['All Statuses', ''], ['Pending', 'pending'], ['Processing', 'processing'], ['Completed', 'completed'], ['Failed', 'failed']], params[:status]),
{ }, { class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
</div>
<div>
<%= f.label :filename, "Filename", class: "block text-sm font-medium text-gray-700 mb-1" %>
<%= f.text_field :filename, value: params[:filename], class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Search filename..." %>
</div>
<div class="flex items-end">
<%= f.submit "Filter", class: "w-full flex justify-center py-2 px-4 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>
</div>
<% end %>
</div>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white shadow-sm rounded-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
</div>
</div>
<div class="ml-4">
<div class="text-2xl font-semibold text-gray-900"><%= DataImport.count %></div>
<div class="text-sm text-gray-600">Total Imports</div>
</div>
</div>
</div>
<div class="bg-white shadow-sm rounded-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
<div class="ml-4">
<div class="text-2xl font-semibold text-gray-900"><%= DataImport.completed.count %></div>
<div class="text-sm text-gray-600">Completed</div>
</div>
</div>
</div>
<div class="bg-white shadow-sm rounded-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</div>
</div>
<div class="ml-4">
<div class="text-2xl font-semibold text-gray-900"><%= DataImport.processing.count %></div>
<div class="text-sm text-gray-600">Processing</div>
</div>
</div>
</div>
<div class="bg-white shadow-sm rounded-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
<div class="ml-4">
<div class="text-2xl font-semibold text-gray-900"><%= DataImport.failed.count %></div>
<div class="text-sm text-gray-600">Failed</div>
</div>
</div>
</div>
</div>
<!-- Imports Table -->
<div class="bg-white shadow-sm rounded-lg">
<div class="overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Filename
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Progress
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
<th class="relative px-6 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% if @data_imports.any? %>
<% @data_imports.each do |data_import| %>
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<%= link_to data_import, class: "flex items-center text-blue-600 hover:text-blue-900 hover:underline" do %>
<svg class="w-4 h-4 text-gray-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<%= truncate(data_import.filename, length: 40) %>
<% end %>
</td>
<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 <%= data_import.import_type == 'asn' ? 'bg-purple-100 text-purple-800' : 'bg-indigo-100 text-indigo-800' %>">
<%= data_import.import_type.upcase %>
</span>
</td>
<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 <%= status_badge_class(data_import.status) %>">
<%= data_import.status.capitalize %>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<%= link_to data_import, class: "block hover:bg-gray-50 -mx-2 px-2 py-1 rounded" do %>
<% if data_import.processing? || data_import.total_records > 0 %>
<div class="flex items-center">
<div class="flex-1 mr-2">
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full" style="width: <%= data_import.progress_percentage %>%"></div>
</div>
</div>
<span class="text-xs text-gray-600">
<% if data_import.processed_records > 0 %>
<% if data_import.total_records > 0 && data_import.processed_records >= data_import.total_records %>
<%= number_with_delimiter(data_import.processed_records) %> total
<% else %>
<%= number_with_delimiter(data_import.processed_records) %> imported
<% end %>
<% else %>
Initializing...
<% end %>
</span>
</div>
<% else %>
<span class="text-gray-400">Not started</span>
<% end %>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= data_import.created_at.strftime('%Y-%m-%d %H:%M') %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<% if data_import.duration > 0 %>
<%= distance_of_time_in_words(data_import.duration) %>
<% else %>
-
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<% unless data_import.processing? %>
<%= link_to "Delete", data_import, method: :delete,
data: {
confirm: "Are you sure you want to delete this import?"
},
class: "text-red-600 hover:text-red-900" %>
<% else %>
<span class="text-gray-400">Processing...</span>
<% end %>
</td>
</tr>
<% end %>
<% else %>
<tr>
<td colspan="7" class="px-6 py-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No imports found</h3>
<p class="mt-1 text-sm text-gray-500">
<% if params[:import_type].present? || params[:status].present? || params[:filename].present? %>
Try adjusting your search filters or
<% else %>
Get started by uploading your first
<% end %>
<%= link_to "GeoLite2 import", new_data_import_path, class: "text-blue-600 hover:text-blue-500" %>.
</p>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<!-- Pagination -->
<% if @pagy.pages > 1 %>
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700">
Showing
<span class="font-medium"><%= @pagy.from %></span>
to
<span class="font-medium"><%= @pagy.to %></span>
of
<span class="font-medium"><%= @pagy.count %></span>
results
</p>
</div>
<div>
<%= pagy_nav_tailwind(@pagy) %>
</div>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,162 @@
<div class="max-w-2xl mx-auto">
<div class="bg-white shadow-sm rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h1 class="text-2xl font-semibold text-gray-900">Import GeoLite2 Data</h1>
<p class="mt-1 text-sm text-gray-600">
Upload GeoLite2-ASN-CSV or GeoLite2-Country-CSV files to import network range data.
</p>
</div>
<div class="px-6 py-4">
<%= form_with(model: @data_import, local: true, class: "space-y-6") do |form| %>
<% if @data_import.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
There were <%= pluralize(@data_import.errors.count, "error") %> with your submission:
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc list-inside space-y-1">
<% @data_import.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<!-- File Upload Section -->
<div class="space-y-2">
<%= form.label :file, "Select File", class: "block text-sm font-medium text-gray-700" %>
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-gray-400 transition-colors">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="flex text-sm text-gray-600">
<label for="data_import_file" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
<span>Upload a file</span>
<%= form.file_field :file, id: "data_import_file", class: "sr-only", accept: ".csv,.zip", required: true %>
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500">
CSV or ZIP files up to 500MB
</p>
</div>
</div>
</div>
<!-- File Type Detection -->
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Automatic Detection</h3>
<div class="mt-2 text-sm text-blue-700">
<p>The system will automatically detect whether your file contains ASN or Country data based on:</p>
<ul class="mt-1 list-disc list-inside">
<li>Filename (containing "ASN" or "Country")</li>
<li>Column headers in the CSV file</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Supported Formats -->
<div class="bg-gray-50 border border-gray-200 rounded-md p-4">
<h3 class="text-sm font-medium text-gray-800 mb-3">Supported File Formats</h3>
<div class="space-y-3">
<div>
<h4 class="text-sm font-medium text-gray-700">GeoLite2-ASN-CSV</h4>
<p class="text-xs text-gray-600">Contains network ranges with ASN and organization information</p>
<p class="text-xs text-gray-500">Expected files: GeoLite2-ASN-Blocks-IPv4.csv, GeoLite2-ASN-Blocks-IPv6.csv</p>
</div>
<div>
<h4 class="text-sm font-medium text-gray-700">GeoLite2-Country-CSV</h4>
<p class="text-xs text-gray-600">Contains network ranges with country geolocation data</p>
<p class="text-xs text-gray-500">Expected files: GeoLite2-Country-Blocks-IPv4.csv, GeoLite2-Country-Blocks-IPv6.csv, GeoLite2-Country-Locations-*.csv</p>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="pt-4">
<%= form.submit "Start Import", class: "w-full flex justify-center py-2 px-4 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 cursor-pointer transition-colors" %>
</div>
<% end %>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const fileInput = document.getElementById('data_import_file');
const dropZone = fileInput.closest('.border-dashed');
// Handle drag and drop
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, unhighlight, false);
});
function highlight(e) {
dropZone.classList.add('border-blue-400', 'bg-blue-50');
}
function unhighlight(e) {
dropZone.classList.remove('border-blue-400', 'bg-blue-50');
}
dropZone.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) {
fileInput.files = files;
updateFileName(files[0].name);
}
}
fileInput.addEventListener('change', function(e) {
if (e.target.files.length > 0) {
updateFileName(e.target.files[0].name);
}
});
function updateFileName(fileName) {
const textElement = dropZone.querySelector('p.text-xs');
textElement.textContent = `Selected: ${fileName}`;
textElement.classList.add('text-green-600', 'font-medium');
}
});
</script>

View File

@@ -0,0 +1,222 @@
<%# Helper methods %>
<% def status_badge(status) %>
<% case status %>
<% when 'pending' %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<%= status.capitalize %>
</span>
<% when 'processing' %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<%= status.capitalize %>
</span>
<% when 'completed' %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<%= status.capitalize %>
</span>
<% when 'failed' %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<%= status.capitalize %>
</span>
<% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<%= status.capitalize %>
</span>
<% end %>
<% end %>
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="bg-white shadow-sm rounded-lg mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-semibold text-gray-900">Import Details</h1>
<p class="mt-1 text-sm text-gray-600">
<%= @data_import.filename %>
</p>
</div>
<div class="flex items-center space-x-2">
<%= link_to "← Back to Imports", data_imports_path, class: "inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
<% unless @data_import.processing? %>
<%= link_to "Delete", @data_import, method: :delete,
data: {
confirm: "Are you sure you want to delete this import record?"
},
class: "inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
<% end %>
</div>
</div>
</div>
</div>
<!-- Progress Card -->
<div data-controller="data-import-progress"
data-data-import-progress-import-id-value="<%= @data_import.id %>"
class="bg-white shadow-sm rounded-lg mb-6">
<div class="px-6 py-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-medium text-gray-900">Import Progress</h2>
<%= status_badge(@data_import.status) %>
</div>
<!-- Progress Bar -->
<div class="mb-4">
<div class="flex items-center justify-between text-sm text-gray-600 mb-1">
<span>
<% if @data_import.total_records > 0 && @data_import.processed_records >= @data_import.total_records %>
<%= number_with_delimiter(@data_import.processed_records) %> total records
<% elsif @data_import.total_records > 0 %>
<%= number_with_delimiter(@data_import.processed_records) %> records processed
<% else %>
Initializing...
<% end %>
</span>
<span><%= @data_import.progress_percentage %>%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-2">
<div data-data-import-progress-target="progressBar"
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
style="width: <%= @data_import.progress_percentage %>%"></div>
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-2xl font-semibold text-gray-900" data-data-import-progress-target="totalRecords">
<%= number_with_delimiter(@data_import.total_records) %>
</div>
<div class="text-sm text-gray-600">Total Records</div>
</div>
<div class="bg-green-50 rounded-lg p-4">
<div class="text-2xl font-semibold text-green-900" data-data-import-progress-target="processedRecords">
<%= number_with_delimiter(@data_import.processed_records) %>
</div>
<div class="text-sm text-green-600">Processed</div>
</div>
<div class="bg-red-50 rounded-lg p-4">
<div class="text-2xl font-semibold text-red-900" data-data-import-progress-target="failedRecords">
<%= number_with_delimiter(@data_import.failed_records) %>
</div>
<div class="text-sm text-red-600">Failed</div>
</div>
<div class="bg-blue-50 rounded-lg p-4">
<div class="text-2xl font-semibold text-blue-900" data-data-import-progress-target="recordsPerSecond">
<%= number_with_delimiter(@data_import.records_per_second) %>
</div>
<div class="text-sm text-blue-600">Records/Sec</div>
</div>
</div>
</div>
</div>
<!-- Import Details -->
<div class="bg-white shadow-sm rounded-lg mb-6">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Import Information</h2>
</div>
<div class="px-6 py-4">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">Import Type</dt>
<dd class="mt-1 text-sm text-gray-900 capitalize"><%= @data_import.import_type %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Filename</dt>
<dd class="mt-1 text-sm text-gray-900"><%= @data_import.filename %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Started</dt>
<dd class="mt-1 text-sm text-gray-900">
<% if @data_import.processing? && @data_import.started_at %>
<%= time_ago_in_words(@data_import.started_at) %> ago
(<%= @data_import.started_at.strftime('%Y-%m-%d %H:%M:%S') %>)
<% elsif @data_import.processing? %>
Initializing...
<% elsif @data_import.started_at %>
<%= time_ago_in_words(@data_import.started_at) %> ago
(<%= @data_import.started_at.strftime('%Y-%m-%d %H:%M:%S') %>)
<% else %>
Not started
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Duration</dt>
<dd class="mt-1 text-sm text-gray-900">
<% if @data_import.duration > 0 %>
<%= distance_of_time_in_words(@data_import.duration) %>
<% else %>
N/A
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">Completed</dt>
<dd class="mt-1 text-sm text-gray-900">
<% if @data_import.completed? && @data_import.completed_at %>
<%= time_ago_in_words(@data_import.completed_at) %> ago
(<%= @data_import.completed_at.strftime('%Y-%m-%d %H:%M:%S') %>)
<% elsif @data_import.completed? %>
Just now
<% elsif @data_import.processing? %>
In progress...
<% else %>
Not completed
<% end %>
</dd>
</div>
</dl>
</div>
</div>
<!-- Error Details (if any) -->
<% if @data_import.error_message.present? || @data_import.import_stats['errors']&.any? %>
<div class="bg-red-50 border border-red-200 rounded-lg mb-6">
<div class="px-6 py-4 border-b border-red-200">
<h2 class="text-lg font-medium text-red-900">Error Details</h2>
</div>
<div class="px-6 py-4">
<% if @data_import.error_message.present? %>
<div class="mb-4">
<h3 class="text-sm font-medium text-red-800 mb-2">General Error</h3>
<p class="text-sm text-red-700"><%= @data_import.error_message %></p>
</div>
<% end %>
<% if @data_import.import_stats['errors']&.any? %>
<div>
<h3 class="text-sm font-medium text-red-800 mb-2">Recent Errors (<%= @data_import.import_stats['errors'].size %>)</h3>
<div class="bg-white rounded border border-red-200 p-3 max-h-48 overflow-y-auto">
<ul class="space-y-2">
<% @data_import.import_stats['errors'].each do |error| %>
<li class="text-xs text-red-700 font-mono"><%= error %></li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<!-- Additional Stats (if available) -->
<% if @data_import.import_stats&.any? && (@data_import.import_stats.except('errors', 'completed_at')).any? %>
<div class="bg-white shadow-sm rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Additional Statistics</h2>
</div>
<div class="px-6 py-4">
<dl class="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<% @data_import.import_stats.except('errors', 'completed_at').each do |key, value| %>
<div>
<dt class="text-sm font-medium text-gray-500"><%= key.to_s.humanize %></dt>
<dd class="mt-1 text-sm text-gray-900"><%= value.is_a?(Hash) ? value.inspect : value %></dd>
</div>
<% end %>
</dl>
</div>
</div>
<% end %>
</div>

View File

@@ -20,6 +20,10 @@
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%# Tom Select CSS for enhanced multi-select %>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.css" data-turbo-track="reload">
<%= javascript_importmap_tags %>
<style>
@@ -74,6 +78,8 @@
class: nav_link_class(network_ranges_path) %>
<% if user_signed_in? && current_user_admin? %>
<%= link_to "📊 Data Imports", data_imports_path,
class: nav_link_class(data_imports_path) %>
<%= link_to "🔗 DSNs", dsns_path,
class: nav_link_class(dsns_path) %>
<% end %>
@@ -165,6 +171,8 @@
class: mobile_nav_link_class(network_ranges_path) %>
<% if user_signed_in? && current_user_admin? %>
<%= link_to "📊 Data Imports", data_imports_path,
class: mobile_nav_link_class(data_imports_path) %>
<%= link_to "🔗 DSNs", dsns_path,
class: mobile_nav_link_class(dsns_path) %>
<% end %>

View File

@@ -0,0 +1,240 @@
<% content_for :title, "Edit WAF Policy" %>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">Edit WAF Policy</h1>
<p class="mt-2 text-gray-600">Modify the firewall policy settings</p>
</div>
<div class="flex space-x-3">
<%= link_to "← Back to Policy", waf_policy_path(@waf_policy),
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
</div>
</div>
<%= form_with(model: @waf_policy, local: true, class: "space-y-6") do |form| %>
<!-- Basic Information -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6 space-y-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">📋 Basic Information</h3>
<!-- Name -->
<div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "e.g., Block Brazil" %>
</div>
<!-- Description -->
<div>
<%= form.label :description, "Description", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 3,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "Explain why this policy is needed..." %>
</div>
<!-- Action -->
<div>
<%= 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),
{ 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" } %>
</div>
<!-- Status -->
<div class="flex items-center">
<%= form.check_box :enabled, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" %>
<%= form.label :enabled, "Enable this policy", class: "ml-2 text-sm text-gray-700" %>
</div>
<!-- Expiration -->
<div>
<%= form.label :expires_at, "Expires At (optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.datetime_local_field :expires_at,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" %>
<p class="text-xs text-gray-500 mt-1">Leave blank for permanent policy</p>
</div>
</div>
</div>
<!-- Targets Configuration -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6 space-y-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">🎯 Targets Configuration</h3>
<p class="text-sm text-gray-600">
<strong>Policy Type:</strong> <%= @waf_policy.policy_type.humanize %>
<% unless @waf_policy.new_record? %>
<span class="text-xs text-gray-500">(Cannot change policy type after creation)</span>
<% end %>
</p>
<% if @waf_policy.new_record? %>
<!-- Policy Type (only for new records) -->
<div>
<%= form.label :policy_type, "Policy Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :policy_type,
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", disabled: !@waf_policy.new_record? } %>
</div>
<% else %>
<!-- Display policy type for existing records -->
<div>
<%= form.label :policy_type, "Policy Type", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-1">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<%= @waf_policy.policy_type.humanize %>
</span>
</div>
<%= form.hidden_field :policy_type %>
</div>
<% end %>
<!-- Country Policy Targets -->
<% if @waf_policy.country_policy? %>
<div id="country-targets">
<%= form.label :targets, "Countries", class: "block text-sm font-medium text-gray-700 mb-2" %>
<div data-controller="country-selector"
data-country-selector-options-value="<%= CountryHelper.all_for_select.to_json %>"
data-country-selector-placeholder-value="Search and select countries...">
<%= select_tag "waf_policy[targets][]",
options_for_select(@waf_policy.targets.map { |code| [CountryHelper.display_with_flag(code), code] }, @waf_policy.targets),
{
multiple: true,
class: "hidden",
data: { "country-selector-target": "select" }
} %>
</div>
</div>
<% end %>
<!-- ASN Policy Targets -->
<% if @waf_policy.asn_policy? %>
<div id="asn-targets">
<%= form.label :targets, "ASN Numbers", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag "waf_policy[targets][]", @waf_policy.targets.join(', '),
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "e.g., 12345, 67890" %>
<p class="text-xs text-gray-500 mt-1">Enter ASNs separated by commas</p>
</div>
<% end %>
<!-- Company Policy Targets -->
<% if @waf_policy.company_policy? %>
<div id="company-targets">
<%= form.label :targets, "Companies", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag "waf_policy[targets][]", @waf_policy.targets.join(', '),
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "e.g., AWS, Digital Ocean, Google Cloud" %>
<p class="text-xs text-gray-500 mt-1">Enter company names separated by commas</p>
</div>
<% end %>
<!-- Network Type Targets -->
<% if @waf_policy.network_type_policy? %>
<div id="network-type-targets">
<%= form.label :targets, "Network Types", class: "block text-sm font-medium text-gray-700" %>
<div class="space-y-2">
<label class="flex items-center">
<%= check_box_tag "waf_policy[targets][]", "datacenter", @waf_policy.targets.include?("datacenter"), class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
<span class="text-sm text-gray-700">Datacenter IPs</span>
</label>
<label class="flex items-center">
<%= check_box_tag "waf_policy[targets][]", "proxy", @waf_policy.targets.include?("proxy"), class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
<span class="text-sm text-gray-700">Proxy/VPN IPs</span>
</label>
<label class="flex items-center">
<%= check_box_tag "waf_policy[targets][]", "standard", @waf_policy.targets.include?("standard"), class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
<span class="text-sm text-gray-700">Standard ISPs</span>
</label>
</div>
</div>
<% end %>
</div>
</div>
<!-- Additional Configuration -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6 space-y-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">⚙️ Additional Configuration</h3>
<!-- Redirect Settings (for redirect action) -->
<div id="redirect-config" class="space-y-3 <%= 'hidden' unless @waf_policy.redirect_action? %>">
<div>
<%= 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'),
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "https://example.com/compliance" %>
</div>
<div>
<%= label_tag "additional_data[redirect_status]", "HTTP Status", class: "block text-sm font-medium text-gray-700" %>
<%= select_tag "additional_data[redirect_status]",
options_for_select([["301 Moved Permanently", 301], ["302 Found", 302], ["307 Temporary Redirect", 307]], @waf_policy.additional_data&.dig('redirect_status')),
{ include_blank: true, class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
</div>
</div>
<!-- Challenge Settings (for challenge action) -->
<div id="challenge-config" class="space-y-3 <%= 'hidden' unless @waf_policy.challenge_action? %>">
<div>
<%= label_tag "additional_data[challenge_type]", "Challenge Type", class: "block text-sm font-medium text-gray-700" %>
<%= select_tag "additional_data[challenge_type]",
options_for_select([["CAPTCHA", "captcha"], ["JavaScript", "javascript"], ["Proof of Work", "proof_of_work"]], @waf_policy.additional_data&.dig('challenge_type')),
{ include_blank: true, class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
</div>
<div>
<%= label_tag "additional_data[challenge_message]", "Challenge Message", class: "block text-sm font-medium text-gray-700" %>
<%= text_area_tag "additional_data[challenge_message]", @waf_policy.additional_data&.dig('challenge_message'), rows: 2,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "Please verify you are human to continue..." %>
</div>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="flex justify-end space-x-3">
<%= link_to "Cancel", waf_policy_path(@waf_policy),
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
<%= form.submit "Update Policy",
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" %>
</div>
<% end %>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const actionSelect = document.getElementById('action-select');
const redirectConfig = document.getElementById('redirect-config');
const challengeConfig = document.getElementById('challenge-config');
function updateActionConfig() {
const selectedAction = actionSelect.value;
// Hide all config sections
redirectConfig.classList.add('hidden');
challengeConfig.classList.add('hidden');
// Show relevant config section
switch(selectedAction) {
case 'redirect':
redirectConfig.classList.remove('hidden');
break;
case 'challenge':
challengeConfig.classList.remove('hidden');
break;
}
}
// Add event listener
actionSelect.addEventListener('change', updateActionConfig);
// Initial update
updateActionConfig();
});
</script>

View File

@@ -150,7 +150,13 @@
</div>
<div class="mt-1 text-sm text-gray-500">
<%= policy.policy_type.humanize %> policy targeting
<% if policy.country_policy? && policy.targets.any? %>
<% if policy.targets.length > 3 %>
<%= policy.targets.length %> countries
<% else %>
<%= policy.targets.map { |code| CountryHelper.display_with_flag(code) }.join(', ') %>
<% end %>
<% elsif policy.targets.length > 3 %>
<%= policy.targets.length %> items
<% else %>
<%= policy.targets.join(', ') %>

View File

@@ -0,0 +1,240 @@
<% content_for :title, "New WAF Policy" %>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">New WAF Policy</h1>
<p class="mt-2 text-gray-600">Create a new firewall policy to automatically generate rules</p>
</div>
<div class="flex space-x-3">
<%= link_to "← Back to Policies", waf_policies_path,
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
</div>
</div>
<%= form_with(model: @waf_policy, local: true, class: "space-y-6") do |form| %>
<!-- Basic Information -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6 space-y-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">📋 Basic Information</h3>
<!-- Name -->
<div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "e.g., Block Brazil" %>
</div>
<!-- Description -->
<div>
<%= form.label :description, "Description", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 3,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "Explain why this policy is needed..." %>
</div>
<!-- Policy Type -->
<div>
<%= form.label :policy_type, "Policy Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :policy_type,
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" } %>
</div>
<!-- Action -->
<div>
<%= 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),
{ 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" } %>
</div>
</div>
</div>
<!-- Targets Configuration -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6 space-y-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">🎯 Targets Configuration</h3>
<!-- Country Policy Targets -->
<div id="country-targets" class="policy-targets hidden">
<%= form.label :targets, "Countries", class: "block text-sm font-medium text-gray-700 mb-2" %>
<div data-controller="country-selector"
data-country-selector-options-value="<%= CountryHelper.all_for_select.to_json %>"
data-country-selector-placeholder-value="Search and select countries...">
<%= select_tag "waf_policy[targets][]",
options_for_select([]),
{
multiple: true,
class: "hidden",
data: { "country-selector-target": "select" }
} %>
</div>
</div>
<!-- ASN Policy Targets -->
<div id="asn-targets" class="policy-targets hidden">
<%= form.label :targets, "ASN Numbers", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag "waf_policy[targets][]", nil,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "e.g., 12345, 67890" %>
<p class="text-xs text-gray-500 mt-1">Enter ASNs separated by commas</p>
</div>
<!-- Company Policy Targets -->
<div id="company-targets" class="policy-targets hidden">
<%= form.label :targets, "Companies", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag "waf_policy[targets][]", nil,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "e.g., AWS, Digital Ocean, Google Cloud" %>
<p class="text-xs text-gray-500 mt-1">Enter company names separated by commas</p>
</div>
<!-- Network Type Targets -->
<div id="network-type-targets" class="policy-targets hidden">
<%= form.label :targets, "Network Types", class: "block text-sm font-medium text-gray-700" %>
<div class="space-y-2">
<label class="flex items-center">
<%= check_box_tag "waf_policy[targets][]", "datacenter", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
<span class="text-sm text-gray-700">Datacenter IPs</span>
</label>
<label class="flex items-center">
<%= check_box_tag "waf_policy[targets][]", "proxy", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
<span class="text-sm text-gray-700">Proxy/VPN IPs</span>
</label>
<label class="flex items-center">
<%= check_box_tag "waf_policy[targets][]", "standard", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
<span class="text-sm text-gray-700">Standard ISPs</span>
</label>
</div>
</div>
</div>
</div>
<!-- Additional Configuration -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6 space-y-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">⚙️ Additional Configuration</h3>
<!-- Redirect Settings (for redirect action) -->
<div id="redirect-config" class="hidden space-y-3">
<div>
<%= label_tag "additional_data[redirect_url]", "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag "additional_data[redirect_url]", nil,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "https://example.com/compliance" %>
</div>
<div>
<%= label_tag "additional_data[redirect_status]", "HTTP Status", class: "block text-sm font-medium text-gray-700" %>
<%= select_tag "additional_data[redirect_status]",
options_for_select([["301 Moved Permanently", 301], ["302 Found", 302], ["307 Temporary Redirect", 307]]),
{ include_blank: true, class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
</div>
</div>
<!-- Challenge Settings (for challenge action) -->
<div id="challenge-config" class="hidden space-y-3">
<div>
<%= label_tag "additional_data[challenge_type]", "Challenge Type", class: "block text-sm font-medium text-gray-700" %>
<%= select_tag "additional_data[challenge_type]",
options_for_select([["CAPTCHA", "captcha"], ["JavaScript", "javascript"], ["Proof of Work", "proof_of_work"]]),
{ include_blank: true, class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
</div>
<div>
<%= label_tag "additional_data[challenge_message]", "Challenge Message", class: "block text-sm font-medium text-gray-700" %>
<%= text_area_tag "additional_data[challenge_message]", nil, rows: 2,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
placeholder: "Please verify you are human to continue..." %>
</div>
</div>
<!-- Expiration -->
<div>
<%= form.label :expires_at, "Expires At (optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.datetime_local_field :expires_at,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" %>
<p class="text-xs text-gray-500 mt-1">Leave blank for permanent policy</p>
</div>
<!-- Enabled -->
<div class="flex items-center">
<%= form.check_box :enabled, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" %>
<%= form.label :enabled, "Enable this policy immediately", class: "ml-2 text-sm text-gray-700" %>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="flex justify-end space-x-3">
<%= link_to "Cancel", waf_policies_path,
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
<%= form.submit "Create Policy",
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" %>
</div>
<% end %>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const policyTypeSelect = document.getElementById('policy-type-select');
const actionSelect = document.getElementById('action-select');
const allTargets = document.querySelectorAll('.policy-targets');
const redirectConfig = document.getElementById('redirect-config');
const challengeConfig = document.getElementById('challenge-config');
function updateTargetsVisibility() {
const selectedType = policyTypeSelect.value;
// Hide all target sections
allTargets.forEach(target => target.classList.add('hidden'));
// Show relevant target section
switch(selectedType) {
case 'country':
document.getElementById('country-targets').classList.remove('hidden');
break;
case 'asn':
document.getElementById('asn-targets').classList.remove('hidden');
break;
case 'company':
document.getElementById('company-targets').classList.remove('hidden');
break;
case 'network_type':
document.getElementById('network-type-targets').classList.remove('hidden');
break;
}
}
function updateActionConfig() {
const selectedAction = actionSelect.value;
// Hide all config sections
redirectConfig.classList.add('hidden');
challengeConfig.classList.add('hidden');
// Show relevant config section
switch(selectedAction) {
case 'redirect':
redirectConfig.classList.remove('hidden');
break;
case 'challenge':
challengeConfig.classList.remove('hidden');
break;
}
}
// Add event listeners
policyTypeSelect.addEventListener('change', updateTargetsVisibility);
actionSelect.addEventListener('change', updateActionConfig);
// Initial update
updateTargetsVisibility();
updateActionConfig();
});
</script>

View File

@@ -0,0 +1,194 @@
<% content_for :title, "Block Countries" %>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">🌍 Block Countries</h1>
<p class="mt-2 text-gray-600">Create country-based firewall policies to block or redirect traffic from specific countries</p>
</div>
<div class="flex space-x-3">
<%= link_to "← Back to Policies", waf_policies_path,
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
</div>
</div>
<%= form_with(url: create_country_waf_policies_path, method: :post, local: true, class: "space-y-6") do |form| %>
<!-- Popular Countries Quick Selection -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">🚨 Popular Countries for Blocking</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<% CountryHelper.popular_for_blocking.each do |country| %>
<label class="relative flex items-start p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
<div class="flex items-center h-5">
<%= check_box_tag "countries[]", country[:code], false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" %>
</div>
<div class="ml-3 text-sm">
<div class="font-medium text-gray-900">
<%= country[:display] %>
</div>
<div class="text-gray-500">
<%= country[:reason] %>
</div>
</div>
</label>
<% end %>
</div>
</div>
</div>
<!-- Regional Selection -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">🗺️ Select by Region</h3>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<% CountryHelper.by_region.each do |region_name, country_codes| %>
<div>
<h4 class="font-medium text-gray-900 mb-2"><%= region_name %></h4>
<div class="grid grid-cols-2 gap-2">
<% CountryHelper.countries_for_region(region_name).each do |country| %>
<label class="flex items-center text-sm">
<%= check_box_tag "countries[]", country[:code], false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mr-2" %>
<%= country[:display] %>
</label>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
</div>
<!-- Policy Settings -->
<div class="bg-white shadow rounded-lg">
<div class="px-4 py-5 sm:p-6 space-y-4">
<h3 class="text-lg leading-6 font-medium text-gray-900">⚙️ Policy Settings</h3>
<!-- Action Selection -->
<div>
<%= form.label :action, "What should happen to traffic from selected countries?", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-2 space-y-2">
<label class="flex items-center">
<%= radio_button_tag "action", "deny", true, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
<span class="ml-2 text-sm text-gray-700">
<span class="font-medium">🚫 Block (Deny)</span> - Show 403 Forbidden error
</span>
</label>
<label class="flex items-center">
<%= radio_button_tag "action", "challenge", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
<span class="ml-2 text-sm text-gray-700">
<span class="font-medium">🛡️ Challenge</span> - Present CAPTCHA challenge
</span>
</label>
<label class="flex items-center">
<%= radio_button_tag "action", "redirect", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
<span class="ml-2 text-sm text-gray-700">
<span class="font-medium">🔄 Redirect</span> - Redirect to compliance page
</span>
</label>
<label class="flex items-center">
<%= radio_button_tag "action", "allow", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
<span class="ml-2 text-sm text-gray-700">
<span class="font-medium">✅ Allow</span> - Explicitly allow traffic
</span>
</label>
</div>
</div>
<!-- Redirect Settings (conditional) -->
<div id="redirect-settings" class="hidden space-y-3">
<%= form.label :redirect_url, "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag "additional_data[redirect_url]", nil,
placeholder: "https://example.com/compliance",
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" %>
<%= form.label :redirect_status, "HTTP Status", class: "block text-sm font-medium text-gray-700 mt-2" %>
<%= select_tag "additional_data[redirect_status]",
options_for_select([["301 Moved Permanently", 301], ["302 Found", 302], ["307 Temporary Redirect", 307]], 302),
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" } %>
</div>
<!-- Description -->
<div>
<%= form.label :description, "Description (optional)", class: "block text-sm font-medium text-gray-700" %>
<%= text_area_tag "description", nil, rows: 3,
placeholder: "e.g., Block countries with high scanner activity",
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm" %>
</div>
<!-- Preview -->
<div id="policy-preview" class="border-t pt-4">
<h4 class="text-sm font-medium text-gray-900 mb-2">📋 Policy Preview</h4>
<div class="bg-gray-50 rounded-md p-3 text-sm text-gray-600">
<span id="preview-text">Select countries above to see policy preview...</span>
</div>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="flex justify-end space-x-3">
<%= link_to "Cancel", waf_policies_path,
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
<%= submit_tag "Create Country Policy",
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %>
</div>
<% end %>
</div>
<script>
// Show/hide redirect settings based on action selection
document.addEventListener('DOMContentLoaded', function() {
const actionRadios = document.querySelectorAll('input[name="action"]');
const redirectSettings = document.getElementById('redirect-settings');
const previewText = document.getElementById('preview-text');
function updateVisibility() {
const selectedAction = document.querySelector('input[name="action"]:checked')?.value;
if (selectedAction === 'redirect') {
redirectSettings.classList.remove('hidden');
} else {
redirectSettings.classList.add('hidden');
}
updatePreview();
}
function updatePreview() {
const selectedCountries = document.querySelectorAll('input[name="countries[]"]:checked');
const selectedAction = document.querySelector('input[name="action"]:checked')?.value || 'deny';
const actionText = {
'deny': '🚫 Block',
'challenge': '🛡️ Challenge',
'redirect': '🔄 Redirect',
'allow': '✅ Allow'
}[selectedAction];
if (selectedCountries.length > 0) {
const countryNames = Array.from(selectedCountries).map(cb => {
const label = cb.closest('label');
const countryName = label.querySelector('.font-medium')?.textContent || cb.value;
return countryName;
}).join(', ');
previewText.textContent = `${actionText} ${selectedCountries.length} countries: ${countryNames}`;
} else {
previewText.textContent = 'Select countries above to see policy preview...';
}
}
// Add event listeners
actionRadios.forEach(radio => {
radio.addEventListener('change', updateVisibility);
});
document.querySelectorAll('input[name="countries[]"]').forEach(checkbox => {
checkbox.addEventListener('change', updatePreview);
});
// Initial update
updateVisibility();
});
</script>

View File

@@ -0,0 +1,270 @@
<% content_for :title, @waf_policy.name %>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900"><%= @waf_policy.name %></h1>
<p class="mt-2 text-gray-600"><%= @waf_policy.description %></p>
</div>
<div class="flex space-x-3">
<%= link_to "← Back to Policies", waf_policies_path,
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
<%= link_to "Edit", edit_waf_policy_path(@waf_policy),
class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
</div>
</div>
<!-- Policy Details -->
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">📋 Policy Details</h3>
</div>
<div class="border-t border-gray-200">
<dl>
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Policy Type</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<%= @waf_policy.policy_type.humanize %>
</span>
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Action</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
<%= case @waf_policy.action
when 'deny' then 'bg-red-100 text-red-800'
when 'allow' then 'bg-green-100 text-green-800'
when 'redirect' then 'bg-yellow-100 text-yellow-800'
when 'challenge' then 'bg-purple-100 text-purple-800'
end %>">
<%= @waf_policy.action.upcase %>
</span>
</dd>
</div>
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Targets</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<% if @waf_policy.targets.any? %>
<div class="flex flex-wrap gap-2">
<% @waf_policy.targets.each do |target| %>
<% if @waf_policy.country_policy? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<%= CountryHelper.display_with_flag(target) %>
</span>
<% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<%= target %>
</span>
<% end %>
<% end %>
</div>
<% else %>
<span class="text-gray-400">No targets configured</span>
<% end %>
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Status</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<div class="flex items-center space-x-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
<%= @waf_policy.active? ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800' %>">
<%= @waf_policy.active? ? 'Active' : 'Inactive' %>
</span>
<% if @waf_policy.active? %>
<%= link_to "Deactivate", deactivate_waf_policy_path(@waf_policy), method: :post,
data: { confirm: "Are you sure you want to deactivate this policy?" },
class: "text-sm text-red-600 hover:text-red-900" %>
<% else %>
<%= link_to "Activate", activate_waf_policy_path(@waf_policy), method: :post,
class: "text-sm text-green-600 hover:text-green-900" %>
<% end %>
</div>
</dd>
</div>
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Expires At</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<% if @waf_policy.expires_at.present? %>
<%= @waf_policy.expires_at.strftime("%B %d, %Y at %I:%M %p") %>
<% if @waf_policy.expired? %>
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Expired
</span>
<% end %>
<% else %>
<span class="text-gray-400">Never expires</span>
<% end %>
</dd>
</div>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Created By</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<%= @waf_policy.user.email_address %>
</dd>
</div>
<div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Created</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<%= @waf_policy.created_at.strftime("%B %d, %Y at %I:%M %p") %>
</dd>
</div>
<!-- Additional Configuration -->
<% if @waf_policy.additional_data && @waf_policy.additional_data.any? %>
<div class="bg-white px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500">Additional Config</dt>
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
<% @waf_policy.additional_data.each do |key, value| %>
<div class="mb-2">
<span class="font-medium"><%= key.humanize %>:</span>
<span class="text-gray-600"><%= value %></span>
</div>
<% end %>
</dd>
</div>
<% end %>
</dl>
</div>
</div>
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Generated Rules</dt>
<dd class="text-lg font-medium text-gray-900"><%= @waf_policy.generated_rules_count %></dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Active Rules</dt>
<dd class="text-lg font-medium text-gray-900"><%= @waf_policy.active_rules_count %></dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Rules Last 7 Days</dt>
<dd class="text-lg font-medium text-gray-900"><%= @waf_policy.effectiveness_stats[:rules_last_7_days] %></dd>
</dl>
</div>
</div>
</div>
</div>
<div class="bg-white overflow-hidden shadow rounded-lg">
<div class="p-5">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Efficiency Rate</dt>
<dd class="text-lg font-medium text-gray-900">
<%= @waf_policy.active_rules_count.to_f / [@waf_policy.generated_rules_count, 1].max * 100 %>%
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
<!-- Generated Rules -->
<% if @generated_rules.any? %>
<div class="bg-white shadow overflow-hidden sm:rounded-md">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">🔧 Generated Rules</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500">
Specific rules created by this policy that are enforced by baffle-agents.
</p>
</div>
<div class="border-t border-gray-200">
<ul class="divide-y divide-gray-200">
<% @generated_rules.each do |rule| %>
<li class="hover:bg-gray-50">
<div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<%= link_to "📋", rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900">
Rule #<%= rule.id %> - <%= rule.network_range&.cidr || "Unknown" %>
</div>
<div class="text-sm text-gray-500">
<%= rule.action.upcase %> • Created <%= time_ago_in_words(rule.created_at) %> ago
<% if rule.redirect_action? %>
• Redirect to <%= rule.redirect_url %>
<% elsif rule.challenge_action? %>
• <%= rule.challenge_type.humanize %> challenge
<% end %>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<% if rule.active? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Active
</span>
<% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Inactive
</span>
<% end %>
</div>
</div>
</div>
</li>
<% end %>
</ul>
</div>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,21 @@
class CreateDataImports < ActiveRecord::Migration[8.1]
def change
create_table :data_imports do |t|
t.string :import_type, null: false, comment: "ASN or Country import"
t.string :status, null: false, default: "pending", comment: "pending, processing, completed, failed"
t.string :filename, null: false
t.integer :total_records, default: 0
t.integer :processed_records, default: 0
t.integer :failed_records, default: 0
t.text :error_message
t.json :import_stats, default: {}, comment: "Detailed import statistics"
t.datetime :started_at
t.datetime :completed_at
t.timestamps
end
add_index :data_imports, :status
add_index :data_imports, :import_type
add_index :data_imports, :created_at
end
end

View File

@@ -0,0 +1,57 @@
# This migration comes from active_storage (originally 20170806125915)
class CreateActiveStorageTables < ActiveRecord::Migration[7.0]
def change
# Use Active Record's configured type for primary and foreign keys
primary_key_type, foreign_key_type = primary_and_foreign_key_types
create_table :active_storage_blobs, id: primary_key_type do |t|
t.string :key, null: false
t.string :filename, null: false
t.string :content_type
t.text :metadata
t.string :service_name, null: false
t.bigint :byte_size, null: false
t.string :checksum
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :key ], unique: true
end
create_table :active_storage_attachments, id: primary_key_type do |t|
t.string :name, null: false
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.references :blob, null: false, type: foreign_key_type
if connection.supports_datetime_with_precision?
t.datetime :created_at, precision: 6, null: false
else
t.datetime :created_at, null: false
end
t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
create_table :active_storage_variant_records, id: primary_key_type do |t|
t.belongs_to :blob, null: false, index: false, type: foreign_key_type
t.string :variation_digest, null: false
t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true
t.foreign_key :active_storage_blobs, column: :blob_id
end
end
private
def primary_and_foreign_key_types
config = Rails.configuration.generators
setting = config.options[config.orm][:primary_key_type]
primary_key_type = setting || :primary_key
foreign_key_type = setting || :bigint
[ primary_key_type, foreign_key_type ]
end
end

View File

@@ -0,0 +1,6 @@
class AddTagsToEvents < ActiveRecord::Migration[8.1]
def change
add_column :events, :tags, :jsonb, default: [], null: false
add_index :events, :tags, using: :gin
end
end