Migrate to Postgresql for better network handling. Add more user functionality.
This commit is contained in:
8
Gemfile
8
Gemfile
@@ -4,8 +4,11 @@ source "https://rubygems.org"
|
|||||||
gem "rails", "~> 8.1.1"
|
gem "rails", "~> 8.1.1"
|
||||||
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
|
||||||
gem "propshaft"
|
gem "propshaft"
|
||||||
# Use sqlite3 as the database for Active Record
|
# Use sqlite3 as the database for Active Record (for cache/queue/cable)
|
||||||
gem "sqlite3", ">= 2.1"
|
gem "sqlite3", ">= 2.1"
|
||||||
|
|
||||||
|
# Use PostgreSQL as the primary database
|
||||||
|
gem "pg", ">= 1.1"
|
||||||
# Use the Puma web server [https://github.com/puma/puma]
|
# Use the Puma web server [https://github.com/puma/puma]
|
||||||
gem "puma", ">= 5.0"
|
gem "puma", ">= 5.0"
|
||||||
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
|
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
|
||||||
@@ -56,6 +59,9 @@ gem "maxmind-db"
|
|||||||
# HTTP client for database downloads
|
# HTTP client for database downloads
|
||||||
gem "httparty"
|
gem "httparty"
|
||||||
|
|
||||||
|
# Authorization library
|
||||||
|
gem "pundit"
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||||
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
|
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
|
||||||
|
|||||||
10
Gemfile.lock
10
Gemfile.lock
@@ -261,6 +261,12 @@ GEM
|
|||||||
parser (3.3.10.0)
|
parser (3.3.10.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
|
pg (1.6.2)
|
||||||
|
pg (1.6.2-aarch64-linux)
|
||||||
|
pg (1.6.2-aarch64-linux-musl)
|
||||||
|
pg (1.6.2-arm64-darwin)
|
||||||
|
pg (1.6.2-x86_64-linux)
|
||||||
|
pg (1.6.2-x86_64-linux-musl)
|
||||||
pp (0.6.3)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
@@ -275,6 +281,8 @@ GEM
|
|||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (7.1.0)
|
puma (7.1.0)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
|
pundit (2.5.2)
|
||||||
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.3)
|
rack (3.2.3)
|
||||||
@@ -489,8 +497,10 @@ DEPENDENCIES
|
|||||||
omniauth_openid_connect (~> 0.8)
|
omniauth_openid_connect (~> 0.8)
|
||||||
openid_connect (~> 2.2)
|
openid_connect (~> 2.2)
|
||||||
pagy
|
pagy
|
||||||
|
pg (>= 1.1)
|
||||||
propshaft
|
propshaft
|
||||||
puma (>= 5.0)
|
puma (>= 5.0)
|
||||||
|
pundit
|
||||||
rails (~> 8.1.1)
|
rails (~> 8.1.1)
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Rails 8 WAF analytics and automated rule management system** ⚠️ **Experimental**
|
**Rails 8 WAF analytics and automated rule management system** ⚠️ **Experimental**
|
||||||
|
|
||||||
Baffle Hub provides intelligent Web Application Firewall (WAF) analytics with automated rule generation. It combines real-time threat detection with SQLite-based local storage for ultra-fast request filtering.
|
Baffle Hub provides intelligent Web Application Firewall (WAF) analytics with automated rule generation. It combines real-time threat detection with PostgreSQL-based database for ultra-fast request filtering.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,26 @@
|
|||||||
*
|
*
|
||||||
* Consider organizing styles into separate files for maintainability.
|
* Consider organizing styles into separate files for maintainability.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* JSON Validator Styles */
|
||||||
|
.json-valid {
|
||||||
|
border-color: #10b981 !important;
|
||||||
|
box-shadow: 0 0 0 1px #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-invalid {
|
||||||
|
border-color: #ef4444 !important;
|
||||||
|
box-shadow: 0 0 0 1px #ef4444 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-valid-status {
|
||||||
|
color: #10b981;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-invalid-status {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
class Api::EventsController < ApplicationController
|
class Api::EventsController < ApplicationController
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
allow_unauthenticated_access # Skip normal session auth, use DSN auth instead
|
||||||
|
|
||||||
# POST /api/:project_id/events
|
# POST /api/:project_id/events
|
||||||
def create
|
def create
|
||||||
@@ -27,8 +28,8 @@ class Api::EventsController < ApplicationController
|
|||||||
response.headers['X-Sample-Rate'] = current_sampling[:allowed_requests].to_s
|
response.headers['X-Sample-Rate'] = current_sampling[:allowed_requests].to_s
|
||||||
response.headers['X-Sample-Until'] = current_sampling[:effective_until]
|
response.headers['X-Sample-Until'] = current_sampling[:effective_until]
|
||||||
|
|
||||||
# Check if agent sent a rule version to compare against
|
# Check if agent sent a rule version in the JSON body to compare against
|
||||||
client_version = request.headers['X-Rule-Version']&.to_i
|
client_version = event_data.dig('last_rule_sync')&.to_i
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -40,8 +41,7 @@ class Api::EventsController < ApplicationController
|
|||||||
if client_version.blank? || client_version != rule_version
|
if client_version.blank? || client_version != rule_version
|
||||||
# Get rules updated since client version
|
# Get rules updated since client version
|
||||||
if client_version.present?
|
if client_version.present?
|
||||||
since_time = Time.at(client_version / 1_000_000, client_version % 1_000_000)
|
rules = Rule.since(client_version).enabled
|
||||||
rules = Rule.where("updated_at > ?", since_time).enabled.sync_order
|
|
||||||
else
|
else
|
||||||
# Full sync for new agents
|
# Full sync for new agents
|
||||||
rules = Rule.active.sync_order
|
rules = Rule.active.sync_order
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ module Api
|
|||||||
# These endpoints are kept for administrative/debugging purposes only
|
# These endpoints are kept for administrative/debugging purposes only
|
||||||
|
|
||||||
skip_before_action :verify_authenticity_token
|
skip_before_action :verify_authenticity_token
|
||||||
|
allow_unauthenticated_access # Skip normal session auth, use project key auth instead
|
||||||
before_action :authenticate_project!
|
before_action :authenticate_project!
|
||||||
before_action :check_project_enabled
|
before_action :check_project_enabled
|
||||||
|
|
||||||
@@ -23,8 +24,8 @@ module Api
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /api/:public_key/rules?since=1730646186272060
|
# GET /api/:public_key/rules?since=1730646186
|
||||||
# Incremental sync - returns rules updated since timestamp (microsecond Unix timestamp)
|
# Incremental sync - returns rules updated since timestamp (Unix timestamp in seconds)
|
||||||
# GET /api/:public_key/rules
|
# GET /api/:public_key/rules
|
||||||
# Full sync - returns all active rules
|
# Full sync - returns all active rules
|
||||||
def index
|
def index
|
||||||
@@ -69,17 +70,14 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def parse_timestamp(timestamp_str)
|
def parse_timestamp(timestamp_str)
|
||||||
# Parse microsecond Unix timestamp
|
# Parse Unix timestamp in seconds
|
||||||
unless timestamp_str.match?(/^\d+$/)
|
unless timestamp_str.match?(/^\d+$/)
|
||||||
raise ArgumentError, "Invalid timestamp format. Expected microsecond Unix timestamp (e.g., 1730646186272060)"
|
raise ArgumentError, "Invalid timestamp format. Expected Unix timestamp in seconds (e.g., 1730646186)"
|
||||||
end
|
end
|
||||||
|
|
||||||
total_microseconds = timestamp_str.to_i
|
Time.at(timestamp_str.to_i)
|
||||||
seconds = total_microseconds / 1_000_000
|
|
||||||
microseconds = total_microseconds % 1_000_000
|
|
||||||
Time.at(seconds, microseconds)
|
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
raise ArgumentError, "Invalid timestamp format: #{e.message}. Use microsecond Unix timestamp (e.g., 1730646186272060)"
|
raise ArgumentError, "Invalid timestamp format: #{e.message}. Use Unix timestamp in seconds (e.g., 1730646186)"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ class ApplicationController < ActionController::Base
|
|||||||
stale_when_importmap_changes
|
stale_when_importmap_changes
|
||||||
|
|
||||||
include Pagy::Backend
|
include Pagy::Backend
|
||||||
|
include Pagy::Frontend
|
||||||
|
include Pundit::Authorization
|
||||||
|
|
||||||
helper_method :current_user, :user_signed_in?, :current_user_admin?, :current_user_viewer?
|
helper_method :current_user, :user_signed_in?, :current_user_admin?, :current_user_viewer?
|
||||||
|
|
||||||
|
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def current_user
|
def current_user
|
||||||
@@ -43,4 +47,12 @@ class ApplicationController < ActionController::Base
|
|||||||
def after_authentication_url
|
def after_authentication_url
|
||||||
session.delete(:return_to_after_authenticating) || root_url
|
session.delete(:return_to_after_authenticating) || root_url
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def user_not_authorized
|
||||||
|
if user_signed_in?
|
||||||
|
redirect_to root_path, alert: "You don't have permission to perform this action."
|
||||||
|
else
|
||||||
|
redirect_to new_session_path, alert: "Please sign in to continue."
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
221
app/controllers/network_ranges_controller.rb
Normal file
221
app/controllers/network_ranges_controller.rb
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# NetworkRangesController - Browse and manage network ranges
|
||||||
|
#
|
||||||
|
# Provides interface for viewing, searching, and managing network ranges
|
||||||
|
# with their intelligence data and associated rules.
|
||||||
|
class NetworkRangesController < ApplicationController
|
||||||
|
# Follow proper before_action order:
|
||||||
|
# 1. Authentication/Authorization
|
||||||
|
allow_unauthenticated_access only: [:index, :show, :lookup]
|
||||||
|
|
||||||
|
# 2. Resource loading
|
||||||
|
before_action :set_network_range, only: [:show, :edit, :update, :destroy, :enrich]
|
||||||
|
before_action :set_project, only: [:index, :show]
|
||||||
|
|
||||||
|
# GET /network_ranges
|
||||||
|
def index
|
||||||
|
@pagy, @network_ranges = pagy(policy_scope(NetworkRange.includes(:rules))
|
||||||
|
.order(updated_at: :desc))
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
@network_ranges = apply_filters(@network_ranges)
|
||||||
|
|
||||||
|
# Apply search
|
||||||
|
if params[:search].present?
|
||||||
|
@network_ranges = search_network_ranges(@network_ranges, params[:search])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
@total_ranges = NetworkRange.count
|
||||||
|
@ranges_with_intelligence = NetworkRange.where.not(asn: nil).or(NetworkRange.where.not(company: nil)).count
|
||||||
|
@datacenter_ranges = NetworkRange.where(is_datacenter: true).count
|
||||||
|
@vpn_ranges = NetworkRange.where(is_vpn: true).count
|
||||||
|
@proxy_ranges = NetworkRange.where(is_proxy: true).count
|
||||||
|
|
||||||
|
# Top countries, companies, ASNs
|
||||||
|
@top_countries = NetworkRange.where.not(country: nil).group(:country).count.sort_by { |_, c| -c }.first(10)
|
||||||
|
@top_companies = NetworkRange.where.not(company: nil).group(:company).count.sort_by { |_, c| -c }.first(10)
|
||||||
|
@top_asns = NetworkRange.where.not(asn: nil).group(:asn, :asn_org).count.sort_by { |_, c| -c }.first(10)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /network_ranges/:id
|
||||||
|
def show
|
||||||
|
authorize @network_range
|
||||||
|
@related_events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
|
.where("network_ranges.id = ?", @network_range.id)
|
||||||
|
.recent
|
||||||
|
.limit(100)
|
||||||
|
|
||||||
|
@child_ranges = @network_range.child_ranges.limit(20)
|
||||||
|
@parent_ranges = @network_range.parent_ranges.limit(10)
|
||||||
|
@associated_rules = @network_range.rules.includes(:user).order(created_at: :desc)
|
||||||
|
|
||||||
|
# Traffic analytics (if we have events)
|
||||||
|
@traffic_stats = calculate_traffic_stats(@network_range)
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /network_ranges/new
|
||||||
|
def new
|
||||||
|
authorize NetworkRange
|
||||||
|
@network_range = NetworkRange.new
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /network_ranges
|
||||||
|
def create
|
||||||
|
authorize NetworkRange
|
||||||
|
@network_range = NetworkRange.new(network_range_params)
|
||||||
|
@network_range.user = Current.user
|
||||||
|
@network_range.source = 'user_created'
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
if @network_range.save
|
||||||
|
format.html { redirect_to @network_range, notice: 'Network range was successfully created.' }
|
||||||
|
format.json { render json: @network_range.as_json(only: [:id, :network, :company, :asn, :asn_org, :country, :is_datacenter, :is_vpn, :is_proxy]) }
|
||||||
|
else
|
||||||
|
format.html { render :new, status: :unprocessable_entity }
|
||||||
|
format.json { render json: { error: @network_range.errors.full_messages.join(', ') }, status: :unprocessable_entity }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /network_ranges/:id/edit
|
||||||
|
def edit
|
||||||
|
authorize @network_range
|
||||||
|
end
|
||||||
|
|
||||||
|
# PATCH/PUT /network_ranges/:id
|
||||||
|
def update
|
||||||
|
authorize @network_range
|
||||||
|
if @network_range.update(network_range_params)
|
||||||
|
redirect_to @network_range, notice: 'Network range was successfully updated.'
|
||||||
|
else
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# DELETE /network_ranges/:id
|
||||||
|
def destroy
|
||||||
|
authorize @network_range
|
||||||
|
@network_range.destroy
|
||||||
|
redirect_to network_ranges_url, notice: 'Network range was successfully deleted.'
|
||||||
|
end
|
||||||
|
|
||||||
|
# POST /network_ranges/:id/enrich
|
||||||
|
def enrich
|
||||||
|
authorize @network_range, :enrich?
|
||||||
|
# Attempt to enrich this network range with API data
|
||||||
|
# This would integrate with external IP intelligence services
|
||||||
|
enrichment_service = NetworkEnrichmentService.new(@network_range)
|
||||||
|
result = enrichment_service.enrich!
|
||||||
|
|
||||||
|
if result[:success]
|
||||||
|
redirect_to @network_range, notice: "Network range enriched with #{result[:fields_added]} new fields."
|
||||||
|
else
|
||||||
|
redirect_to @network_range, alert: "Failed to enrich network range: #{result[:error]}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /network_ranges/lookup
|
||||||
|
def lookup
|
||||||
|
authorize NetworkRange, :lookup?
|
||||||
|
ip_address = params[:ip]
|
||||||
|
return render json: { error: 'IP address required' }, status: :bad_request if ip_address.blank?
|
||||||
|
|
||||||
|
@ranges = NetworkRange.contains_ip(ip_address).includes(:rules)
|
||||||
|
@ip_intelligence = IpRangeResolver.get_ip_intelligence(ip_address)
|
||||||
|
@suggested_blocks = IpRangeResolver.suggest_blocking_ranges(ip_address)
|
||||||
|
|
||||||
|
render :lookup
|
||||||
|
end
|
||||||
|
|
||||||
|
# GET /network_ranges/search
|
||||||
|
def search
|
||||||
|
authorize NetworkRange, :index?
|
||||||
|
query = params[:q]
|
||||||
|
|
||||||
|
if query.blank?
|
||||||
|
render json: []
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Search by network CIDR (cast inet to text for ILIKE), company, ASN org, or country
|
||||||
|
@network_ranges = NetworkRange.where(
|
||||||
|
"network::text ILIKE ? OR company ILIKE ? OR asn_org ILIKE ? OR country ILIKE ? OR asn::text ILIKE ?",
|
||||||
|
"%#{query}%", "%#{query}%", "%#{query}%", "%#{query}%", "%#{query}%"
|
||||||
|
).limit(20)
|
||||||
|
|
||||||
|
render json: @network_ranges.as_json(
|
||||||
|
only: [:id, :network, :company, :asn, :asn_org, :country, :is_datacenter, :is_vpn, :is_proxy]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_network_range
|
||||||
|
# Handle CIDR slugs (e.g., "40.77.167.100_32" -> "40.77.167.100/32")
|
||||||
|
cidr = params[:id].gsub('_', '/')
|
||||||
|
@network_range = NetworkRange.find_by!(network: cidr)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_project
|
||||||
|
# For now, use the first project or create a default one
|
||||||
|
@project = Project.first || Project.create!(
|
||||||
|
name: 'Default Project',
|
||||||
|
slug: 'default',
|
||||||
|
public_key: SecureRandom.hex(32)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def network_range_params
|
||||||
|
params.require(:network_range).permit(
|
||||||
|
:network,
|
||||||
|
:source,
|
||||||
|
:creation_reason,
|
||||||
|
:asn,
|
||||||
|
:asn_org,
|
||||||
|
:company,
|
||||||
|
:country,
|
||||||
|
:is_datacenter,
|
||||||
|
:is_proxy,
|
||||||
|
:is_vpn,
|
||||||
|
:abuser_scores,
|
||||||
|
:additional_data
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_filters(scope)
|
||||||
|
scope = scope.where(country: params[:country]) if params[:country].present?
|
||||||
|
scope = scope.where(company: params[:company]) if params[:company].present?
|
||||||
|
scope = scope.where(asn: params[:asn].to_i) if params[:asn].present?
|
||||||
|
scope = scope.where(is_datacenter: true) if params[:datacenter] == 'true'
|
||||||
|
scope = scope.where(is_vpn: true) if params[:vpn] == 'true'
|
||||||
|
scope = scope.where(is_proxy: true) if params[:proxy] == 'true'
|
||||||
|
scope = scope.where(source: params[:source]) if params[:source].present?
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_network_ranges(scope, search_term)
|
||||||
|
# Search by network CIDR, company, ASN, or country
|
||||||
|
scope.where(
|
||||||
|
"network ILIKE ? OR company ILIKE ? OR asn_org ILIKE ? OR country ILIKE ?",
|
||||||
|
"%#{search_term}%", "%#{search_term}%", "%#{search_term}%", "%#{search_term}%"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_traffic_stats(network_range)
|
||||||
|
# Calculate traffic statistics for this network range
|
||||||
|
events = Event.joins("JOIN network_ranges ON events.ip_address <<= network_ranges.network")
|
||||||
|
.where("network_ranges.id = ?", network_range.id)
|
||||||
|
|
||||||
|
{
|
||||||
|
total_requests: events.count,
|
||||||
|
unique_ips: events.distinct.count(:ip_address),
|
||||||
|
blocked_requests: events.blocked.count,
|
||||||
|
allowed_requests: events.allowed.count,
|
||||||
|
top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10),
|
||||||
|
top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
|
||||||
|
recent_activity: events.recent.limit(20)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,35 +1,24 @@
|
|||||||
class PasswordsController < ApplicationController
|
class PasswordsController < ApplicationController
|
||||||
allow_unauthenticated_access
|
before_action :require_authentication
|
||||||
before_action :set_user_by_token, only: %i[ edit update ]
|
|
||||||
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." }
|
|
||||||
|
|
||||||
def new
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
if user = User.find_by(email_address: params[:email_address])
|
|
||||||
PasswordsMailer.reset(user).deliver_later
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
|
|
||||||
end
|
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
|
@user = Current.user
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
if @user.update(params.permit(:password, :password_confirmation))
|
@user = Current.user
|
||||||
@user.sessions.destroy_all
|
|
||||||
redirect_to new_session_path, notice: "Password has been reset."
|
|
||||||
else
|
|
||||||
redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
if @user.authenticate(params[:current_password])
|
||||||
def set_user_by_token
|
if @user.update(params.permit(:password, :password_confirmation))
|
||||||
@user = User.find_by_password_reset_token!(params[:token])
|
@user.sessions.where.not(id: Current.session.id).destroy_all
|
||||||
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
redirect_to root_path, notice: "Password updated successfully."
|
||||||
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
|
else
|
||||||
|
flash.now[:alert] = "New password confirmation didn't match."
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
else
|
||||||
|
flash.now[:alert] = "Current password is incorrect."
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class RuleSetsController < ApplicationController
|
|
||||||
before_action :set_rule_set, only: [:show, :edit, :update, :push_to_agents]
|
|
||||||
|
|
||||||
def index
|
|
||||||
@rule_sets = RuleSet.includes(:rules).by_priority
|
|
||||||
end
|
|
||||||
|
|
||||||
def show
|
|
||||||
@rules = @rule_set.rules.includes(:rule_set).by_priority
|
|
||||||
end
|
|
||||||
|
|
||||||
def new
|
|
||||||
@rule_set = RuleSet.new
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
@rule_set = RuleSet.new(rule_set_params)
|
|
||||||
|
|
||||||
if @rule_set.save
|
|
||||||
redirect_to @rule_set, notice: "Rule set was successfully created."
|
|
||||||
else
|
|
||||||
render :new, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def edit
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
if @rule_set.update(rule_set_params)
|
|
||||||
redirect_to @rule_set, notice: "Rule set was successfully updated."
|
|
||||||
else
|
|
||||||
render :edit, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def push_to_agents
|
|
||||||
@rule_set.push_to_agents!
|
|
||||||
redirect_to @rule_set, notice: "Rule set pushed to agents successfully."
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_rule_set
|
|
||||||
@rule_set = RuleSet.find_by(slug: params[:id]) || RuleSet.find(params[:id])
|
|
||||||
end
|
|
||||||
|
|
||||||
def rule_set_params
|
|
||||||
params.require(:rule_set).permit(:name, :description, :enabled, :priority)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,29 +1,58 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class RulesController < ApplicationController
|
class RulesController < ApplicationController
|
||||||
|
# Follow proper before_action order:
|
||||||
|
# 1. Authentication/Authorization
|
||||||
|
allow_unauthenticated_access only: [:index, :show]
|
||||||
|
|
||||||
|
# 2. Resource loading
|
||||||
before_action :set_rule, only: [:show, :edit, :update, :disable, :enable]
|
before_action :set_rule, only: [:show, :edit, :update, :disable, :enable]
|
||||||
before_action :authorize_rule
|
before_action :set_project, only: [:index, :show]
|
||||||
|
|
||||||
# GET /rules
|
# GET /rules
|
||||||
def index
|
def index
|
||||||
@rules = Rule.includes(:project).order(created_at: :desc)
|
@rules = policy_scope(Rule).includes(:user, :network_range).order(created_at: :desc)
|
||||||
@rule_types = Rule::RULE_TYPES
|
@rule_types = Rule::RULE_TYPES
|
||||||
@actions = Rule::ACTIONS
|
@actions = Rule::ACTIONS
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /rules/new
|
# GET /rules/new
|
||||||
def new
|
def new
|
||||||
|
authorize Rule
|
||||||
@rule = Rule.new
|
@rule = Rule.new
|
||||||
|
|
||||||
|
# Pre-fill from URL parameters
|
||||||
|
if params[:network_range_id].present?
|
||||||
|
network_range = NetworkRange.find_by(id: params[:network_range_id])
|
||||||
|
@rule.network_range = network_range if network_range
|
||||||
|
end
|
||||||
|
|
||||||
|
if params[:cidr].present?
|
||||||
|
@rule.rule_type = 'network'
|
||||||
|
end
|
||||||
|
|
||||||
@rule_types = Rule::RULE_TYPES
|
@rule_types = Rule::RULE_TYPES
|
||||||
@actions = Rule::ACTIONS
|
@actions = Rule::ACTIONS
|
||||||
end
|
end
|
||||||
|
|
||||||
# POST /rules
|
# POST /rules
|
||||||
def create
|
def create
|
||||||
|
authorize Rule
|
||||||
@rule = Rule.new(rule_params)
|
@rule = Rule.new(rule_params)
|
||||||
|
@rule.user = Current.user
|
||||||
@rule_types = Rule::RULE_TYPES
|
@rule_types = Rule::RULE_TYPES
|
||||||
@actions = Rule::ACTIONS
|
@actions = Rule::ACTIONS
|
||||||
|
|
||||||
|
# Handle network range creation if CIDR is provided
|
||||||
|
if params[:cidr].present? && @rule.network_rule?
|
||||||
|
network_range = NetworkRange.find_or_create_by(cidr: params[:cidr]) do |range|
|
||||||
|
range.user = Current.user
|
||||||
|
range.source = 'manual'
|
||||||
|
range.creation_reason = "Created for rule ##{@rule.id}"
|
||||||
|
end
|
||||||
|
@rule.network_range = network_range
|
||||||
|
end
|
||||||
|
|
||||||
if @rule.save
|
if @rule.save
|
||||||
redirect_to @rule, notice: 'Rule was successfully created.'
|
redirect_to @rule, notice: 'Rule was successfully created.'
|
||||||
else
|
else
|
||||||
@@ -33,16 +62,19 @@ class RulesController < ApplicationController
|
|||||||
|
|
||||||
# GET /rules/:id
|
# GET /rules/:id
|
||||||
def show
|
def show
|
||||||
|
authorize @rule
|
||||||
end
|
end
|
||||||
|
|
||||||
# GET /rules/:id/edit
|
# GET /rules/:id/edit
|
||||||
def edit
|
def edit
|
||||||
|
authorize @rule
|
||||||
@rule_types = Rule::RULE_TYPES
|
@rule_types = Rule::RULE_TYPES
|
||||||
@actions = Rule::ACTIONS
|
@actions = Rule::ACTIONS
|
||||||
end
|
end
|
||||||
|
|
||||||
# PATCH/PUT /rules/:id
|
# PATCH/PUT /rules/:id
|
||||||
def update
|
def update
|
||||||
|
authorize @rule
|
||||||
if @rule.update(rule_params)
|
if @rule.update(rule_params)
|
||||||
redirect_to @rule, notice: 'Rule was successfully updated.'
|
redirect_to @rule, notice: 'Rule was successfully updated.'
|
||||||
else
|
else
|
||||||
@@ -52,6 +84,7 @@ class RulesController < ApplicationController
|
|||||||
|
|
||||||
# POST /rules/:id/disable
|
# POST /rules/:id/disable
|
||||||
def disable
|
def disable
|
||||||
|
authorize @rule, :disable?
|
||||||
reason = params[:reason] || "Disabled manually"
|
reason = params[:reason] || "Disabled manually"
|
||||||
@rule.disable!(reason: reason)
|
@rule.disable!(reason: reason)
|
||||||
redirect_to @rule, notice: 'Rule was successfully disabled.'
|
redirect_to @rule, notice: 'Rule was successfully disabled.'
|
||||||
@@ -59,6 +92,7 @@ class RulesController < ApplicationController
|
|||||||
|
|
||||||
# POST /rules/:id/enable
|
# POST /rules/:id/enable
|
||||||
def enable
|
def enable
|
||||||
|
authorize @rule, :enable?
|
||||||
@rule.enable!
|
@rule.enable!
|
||||||
redirect_to @rule, notice: 'Rule was successfully enabled.'
|
redirect_to @rule, notice: 'Rule was successfully enabled.'
|
||||||
end
|
end
|
||||||
@@ -69,20 +103,32 @@ class RulesController < ApplicationController
|
|||||||
@rule = Rule.find(params[:id])
|
@rule = Rule.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def authorize_rule
|
|
||||||
# Add authorization logic here if needed
|
|
||||||
# For now, allow all authenticated users
|
|
||||||
end
|
|
||||||
|
|
||||||
def rule_params
|
def rule_params
|
||||||
params.require(:rule).permit(
|
permitted = [
|
||||||
:rule_type,
|
:rule_type,
|
||||||
:action,
|
:action,
|
||||||
:conditions,
|
|
||||||
:metadata,
|
:metadata,
|
||||||
:expires_at,
|
:expires_at,
|
||||||
:enabled,
|
:enabled,
|
||||||
:source
|
:source,
|
||||||
|
:network_range_id
|
||||||
|
]
|
||||||
|
|
||||||
|
# Only include conditions for non-network rules
|
||||||
|
if params[:rule][:rule_type] != 'network'
|
||||||
|
permitted << :conditions
|
||||||
|
end
|
||||||
|
|
||||||
|
params.require(:rule).permit(permitted)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_project
|
||||||
|
# For now, use the first project or create a default one
|
||||||
|
@project = Project.first || Project.create!(
|
||||||
|
name: 'Default Project',
|
||||||
|
slug: 'default',
|
||||||
|
public_key: SecureRandom.hex(32)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
end
|
||||||
81
app/javascript/controllers/json_validator_controller.js
Normal file
81
app/javascript/controllers/json_validator_controller.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["textarea", "status"]
|
||||||
|
static classes = ["valid", "invalid", "validStatus", "invalidStatus"]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.validate()
|
||||||
|
}
|
||||||
|
|
||||||
|
validate() {
|
||||||
|
const value = this.textareaTarget.value.trim()
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
this.clearStatus()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
JSON.parse(value)
|
||||||
|
this.showValid()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
this.showInvalid(error.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format() {
|
||||||
|
const value = this.textareaTarget.value.trim()
|
||||||
|
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value)
|
||||||
|
const formatted = JSON.stringify(parsed, null, 2)
|
||||||
|
this.textareaTarget.value = formatted
|
||||||
|
this.showValid()
|
||||||
|
} catch (error) {
|
||||||
|
this.showInvalid(error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearStatus() {
|
||||||
|
this.textareaTarget.classList.remove(...this.invalidClasses)
|
||||||
|
this.textareaTarget.classList.remove(...this.validClasses)
|
||||||
|
if (this.hasStatusTarget) {
|
||||||
|
this.statusTarget.textContent = ""
|
||||||
|
this.statusTarget.classList.remove(...this.validStatusClasses, ...this.invalidStatusClasses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showValid() {
|
||||||
|
this.textareaTarget.classList.remove(...this.invalidClasses)
|
||||||
|
this.textareaTarget.classList.add(...this.validClasses)
|
||||||
|
if (this.hasStatusTarget) {
|
||||||
|
this.statusTarget.textContent = "✓ Valid JSON"
|
||||||
|
this.statusTarget.classList.remove(...this.invalidStatusClasses)
|
||||||
|
this.statusTarget.classList.add(...this.validStatusClasses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showInvalid(errorMessage) {
|
||||||
|
this.textareaTarget.classList.remove(...this.validClasses)
|
||||||
|
this.textareaTarget.classList.add(...this.invalidClasses)
|
||||||
|
if (this.hasStatusTarget) {
|
||||||
|
this.statusTarget.textContent = `✗ Invalid JSON: ${errorMessage}`
|
||||||
|
this.statusTarget.classList.remove(...this.validStatusClasses)
|
||||||
|
this.statusTarget.classList.add(...this.invalidStatusClasses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
insertSample(event) {
|
||||||
|
event.preventDefault()
|
||||||
|
const sample = event.params.json || event.target.dataset.jsonSample
|
||||||
|
if (sample) {
|
||||||
|
this.textareaTarget.value = sample
|
||||||
|
this.format()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
class PasswordsMailer < ApplicationMailer
|
|
||||||
def reset(user)
|
|
||||||
@user = user
|
|
||||||
mail subject: "Reset your password", to: user.email_address
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
class Current < ActiveSupport::CurrentAttributes
|
class Current < ActiveSupport::CurrentAttributes
|
||||||
attribute :session
|
attribute :session
|
||||||
|
attribute :baffle_host
|
||||||
|
attribute :baffle_internal_host
|
||||||
delegate :user, to: :session, allow_nil: true
|
delegate :user, to: :session, allow_nil: true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -268,11 +268,114 @@ class Event < ApplicationRecord
|
|||||||
headers.transform_keys(&:downcase)
|
headers.transform_keys(&:downcase)
|
||||||
end
|
end
|
||||||
|
|
||||||
# GeoIP enrichment methods
|
# Network range resolution methods
|
||||||
|
def matching_network_ranges
|
||||||
|
return [] unless ip_address.present?
|
||||||
|
|
||||||
|
NetworkRange.contains_ip(ip_address).map do |range|
|
||||||
|
{
|
||||||
|
range: range,
|
||||||
|
cidr: range.cidr,
|
||||||
|
prefix_length: range.prefix_length,
|
||||||
|
specificity: range.prefix_length,
|
||||||
|
intelligence: range.inherited_intelligence
|
||||||
|
}
|
||||||
|
end.sort_by { |r| -r[:specificity] } # Most specific first
|
||||||
|
end
|
||||||
|
|
||||||
|
def most_specific_range
|
||||||
|
matching_network_ranges.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadest_range
|
||||||
|
matching_network_ranges.last
|
||||||
|
end
|
||||||
|
|
||||||
|
def network_intelligence
|
||||||
|
most_specific_range&.dig(:intelligence) || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def company
|
||||||
|
network_intelligence[:company]
|
||||||
|
end
|
||||||
|
|
||||||
|
def asn
|
||||||
|
network_intelligence[:asn]
|
||||||
|
end
|
||||||
|
|
||||||
|
def asn_org
|
||||||
|
network_intelligence[:asn_org]
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_datacenter?
|
||||||
|
network_intelligence[:is_datacenter] || false
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_proxy?
|
||||||
|
network_intelligence[:is_proxy] || false
|
||||||
|
end
|
||||||
|
|
||||||
|
def is_vpn?
|
||||||
|
network_intelligence[:is_vpn] || false
|
||||||
|
end
|
||||||
|
|
||||||
|
# IP validation
|
||||||
|
def valid_ipv4?
|
||||||
|
return false unless ip_address.present?
|
||||||
|
|
||||||
|
IPAddr.new(ip_address).ipv4?
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_ipv6?
|
||||||
|
return false unless ip_address.present?
|
||||||
|
|
||||||
|
IPAddr.new(ip_address).ipv6?
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_ip?
|
||||||
|
valid_ipv4? || valid_ipv6?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Rules affecting this IP
|
||||||
|
def matching_rules
|
||||||
|
return Rule.none unless ip_address.present?
|
||||||
|
|
||||||
|
# Get all network ranges that contain this IP
|
||||||
|
range_ids = matching_network_ranges.map { |r| r[:range].id }
|
||||||
|
|
||||||
|
# Find rules for those ranges, ordered by priority (most specific first)
|
||||||
|
Rule.network_rules
|
||||||
|
.where(network_range_id: range_ids)
|
||||||
|
.enabled
|
||||||
|
.includes(:network_range)
|
||||||
|
.order('masklen(network_ranges.network) DESC')
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_blocking_rules
|
||||||
|
matching_rules.where(action: 'deny')
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_blocking_rules?
|
||||||
|
active_blocking_rules.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
# GeoIP enrichment methods (now uses network range data when available)
|
||||||
def enrich_geo_location!
|
def enrich_geo_location!
|
||||||
return if ip_address.blank?
|
return if ip_address.blank?
|
||||||
return if country_code.present? # Already has geo data
|
return if country_code.present? # Already has geo data
|
||||||
|
|
||||||
|
# First try to get from network range
|
||||||
|
network_info = network_intelligence
|
||||||
|
if network_info[:country].present?
|
||||||
|
update!(country_code: network_info[:country])
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fallback to direct lookup
|
||||||
country = GeoIpService.lookup_country(ip_address)
|
country = GeoIpService.lookup_country(ip_address)
|
||||||
update!(country_code: country) if country.present?
|
update!(country_code: country) if country.present?
|
||||||
rescue => e
|
rescue => e
|
||||||
@@ -282,13 +385,21 @@ class Event < ApplicationRecord
|
|||||||
# Class method to enrich multiple events
|
# Class method to enrich multiple events
|
||||||
def self.enrich_geo_location_batch(events = nil)
|
def self.enrich_geo_location_batch(events = nil)
|
||||||
events ||= where(country_code: [nil, '']).where.not(ip_address: [nil, ''])
|
events ||= where(country_code: [nil, '']).where.not(ip_address: [nil, ''])
|
||||||
geo_service = GeoIpService.new
|
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
|
|
||||||
events.find_each do |event|
|
events.find_each do |event|
|
||||||
next if event.country_code.present?
|
next if event.country_code.present?
|
||||||
|
|
||||||
country = geo_service.lookup_country(event.ip_address)
|
# Try network range first
|
||||||
|
network_info = event.network_intelligence
|
||||||
|
if network_info[:country].present?
|
||||||
|
event.update!(country_code: network_info[:country])
|
||||||
|
updated_count += 1
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fallback to direct lookup
|
||||||
|
country = GeoIpService.lookup_country(event.ip_address)
|
||||||
if country.present?
|
if country.present?
|
||||||
event.update!(country_code: country)
|
event.update!(country_code: country)
|
||||||
updated_count += 1
|
updated_count += 1
|
||||||
@@ -303,6 +414,11 @@ class Event < ApplicationRecord
|
|||||||
return country_code if country_code.present?
|
return country_code if country_code.present?
|
||||||
return nil if ip_address.blank?
|
return nil if ip_address.blank?
|
||||||
|
|
||||||
|
# First try network range
|
||||||
|
network_info = network_intelligence
|
||||||
|
return network_info[:country] if network_info[:country].present?
|
||||||
|
|
||||||
|
# Fallback to direct lookup
|
||||||
GeoIpService.lookup_country(ip_address)
|
GeoIpService.lookup_country(ip_address)
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error "GeoIP lookup failed for #{ip_address}: #{e.message}"
|
Rails.logger.error "GeoIP lookup failed for #{ip_address}: #{e.message}"
|
||||||
@@ -311,16 +427,19 @@ class Event < ApplicationRecord
|
|||||||
|
|
||||||
# Check if event has valid geo location data
|
# Check if event has valid geo location data
|
||||||
def has_geo_data?
|
def has_geo_data?
|
||||||
country_code.present? || city.present?
|
country_code.present? || city.present? || network_intelligence[:country].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get full geo location details
|
# Get full geo location details
|
||||||
def geo_location
|
def geo_location
|
||||||
|
network_info = network_intelligence
|
||||||
|
|
||||||
{
|
{
|
||||||
country_code: country_code,
|
country_code: country_code || network_info[:country],
|
||||||
city: city,
|
city: city,
|
||||||
ip_address: ip_address,
|
ip_address: ip_address,
|
||||||
has_data: has_geo_data?
|
has_data: has_geo_data?,
|
||||||
|
network_intelligence: network_info
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
290
app/models/network_range.rb
Normal file
290
app/models/network_range.rb
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# NetworkRange - Unified IPv4/IPv6 network range management
|
||||||
|
#
|
||||||
|
# Uses PostgreSQL's inet type to handle both IPv4 and IPv4 networks seamlessly.
|
||||||
|
# Provides network intelligence data including ASN, company, geographic info,
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Associations
|
||||||
|
has_many :rules, dependent: :destroy
|
||||||
|
belongs_to :user, optional: true
|
||||||
|
|
||||||
|
# Validations
|
||||||
|
validates :network, presence: true, uniqueness: true
|
||||||
|
validates :source, inclusion: { in: SOURCES }
|
||||||
|
validates :asn, numericality: { greater_than: 0 }, allow_blank: true
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
scope :ipv4, -> { where("family(network) = 4") }
|
||||||
|
scope :ipv6, -> { where("family(network) = 6") }
|
||||||
|
scope :by_country, ->(country) { where(country: country) }
|
||||||
|
scope :by_company, ->(company) { where(company: company) }
|
||||||
|
scope :by_asn, ->(asn) { where(asn: asn) }
|
||||||
|
scope :datacenter, -> { where(is_datacenter: true) }
|
||||||
|
scope :proxy, -> { where(is_proxy: true) }
|
||||||
|
scope :vpn, -> { where(is_vpn: true) }
|
||||||
|
scope :user_created, -> { where(source: 'user_created') }
|
||||||
|
scope :api_imported, -> { where(source: 'api_imported') }
|
||||||
|
|
||||||
|
# Callbacks
|
||||||
|
before_validation :set_default_source
|
||||||
|
# after_save :update_children_inheritance!, if: :should_update_children_inheritance? # Disabled for now
|
||||||
|
|
||||||
|
# Virtual attribute for CIDR notation
|
||||||
|
def cidr
|
||||||
|
network.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def cidr=(new_cidr)
|
||||||
|
self.network = new_cidr
|
||||||
|
end
|
||||||
|
|
||||||
|
# Network properties
|
||||||
|
def prefix_length
|
||||||
|
# Get prefix length from IPAddr object
|
||||||
|
network.prefix
|
||||||
|
end
|
||||||
|
|
||||||
|
def network_address
|
||||||
|
# Use PostgreSQL's host function or get from IPAddr object
|
||||||
|
network.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def cidr
|
||||||
|
# Return full CIDR notation
|
||||||
|
"#{network_address}/#{prefix_length}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def broadcast_address
|
||||||
|
# Use PostgreSQL's broadcast function
|
||||||
|
result = self.class.connection.execute("SELECT broadcast('#{network.to_s}')").first
|
||||||
|
result&.values&.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def family
|
||||||
|
# Check if it's IPv4 or IPv6 by looking at the address
|
||||||
|
addr = network.to_s.split('/').first
|
||||||
|
addr.include?(':') ? 6 : 4
|
||||||
|
end
|
||||||
|
|
||||||
|
def ipv4?
|
||||||
|
family == 4
|
||||||
|
end
|
||||||
|
|
||||||
|
def ipv6?
|
||||||
|
family == 6
|
||||||
|
end
|
||||||
|
|
||||||
|
# Network containment and overlap operations
|
||||||
|
def contains_ip?(ip_string)
|
||||||
|
# Use Postgres >>= operator for containment
|
||||||
|
self.class.where("network >>= ?::inet", ip_string).exists?
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error checking IP containment: #{e.message}"
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def contains_network?(other_cidr)
|
||||||
|
other_network = IPAddr.new(other_cidr)
|
||||||
|
network_range = IPAddr.new(network)
|
||||||
|
network_range.include?(other_network)
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def overlaps?(other_cidr)
|
||||||
|
network_range = IPAddr.new(network)
|
||||||
|
other_network = IPAddr.new(other_cidr)
|
||||||
|
network_range.include?(other_network) || other_network.include?(network_range)
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parent/child relationships
|
||||||
|
def parent_ranges
|
||||||
|
NetworkRange.where("network << ?::inet AND masklen(network) < ?", network.to_s, prefix_length)
|
||||||
|
.order("masklen(network) DESC")
|
||||||
|
end
|
||||||
|
|
||||||
|
def child_ranges
|
||||||
|
NetworkRange.where("network >> ?::inet AND masklen(network) > ?", network.to_s, prefix_length)
|
||||||
|
.order("masklen(network) ASC")
|
||||||
|
end
|
||||||
|
|
||||||
|
def sibling_ranges
|
||||||
|
NetworkRange.where("masklen(network) = ?", prefix_length)
|
||||||
|
.where("network && ?::inet", network.to_s)
|
||||||
|
.where.not(id: id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find nearest parent with intelligence data
|
||||||
|
def parent_with_intelligence
|
||||||
|
# Use Postgres network operators to find parent ranges directly
|
||||||
|
cidr_str = network.to_s
|
||||||
|
if cidr_str.include?('/')
|
||||||
|
addr_parts = network_address.split('.')
|
||||||
|
case addr_parts.length
|
||||||
|
when 4 # IPv4
|
||||||
|
new_prefix = [prefix_length - 8, 16].max
|
||||||
|
parent_cidr = "#{addr_parts[0]}.#{addr_parts[1]}.#{addr_parts[2]}.0/#{new_prefix}"
|
||||||
|
else # IPv6 - skip for now
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
else
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil unless parent_cidr
|
||||||
|
|
||||||
|
NetworkRange.where("network <<= ?::inet AND masklen(network) < ?", parent_cidr, prefix_length)
|
||||||
|
.where.not(asn: nil)
|
||||||
|
.order("masklen(network) DESC")
|
||||||
|
.first
|
||||||
|
end
|
||||||
|
|
||||||
|
def inherited_intelligence
|
||||||
|
return own_intelligence if has_intelligence?
|
||||||
|
|
||||||
|
parent = parent_with_intelligence
|
||||||
|
parent ? parent.own_intelligence.merge(inherited: true, parent_cidr: parent.cidr) : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def has_intelligence?
|
||||||
|
asn.present? || company.present? || country.present? ||
|
||||||
|
is_datacenter? || is_proxy? || is_vpn?
|
||||||
|
end
|
||||||
|
|
||||||
|
def own_intelligence
|
||||||
|
{
|
||||||
|
asn: asn,
|
||||||
|
asn_org: asn_org,
|
||||||
|
company: company,
|
||||||
|
country: country,
|
||||||
|
is_datacenter: is_datacenter,
|
||||||
|
is_proxy: is_proxy,
|
||||||
|
is_vpn: is_vpn,
|
||||||
|
inherited: false,
|
||||||
|
source: source
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Geographic lookup
|
||||||
|
def geo_lookup_country!
|
||||||
|
return if country.present?
|
||||||
|
|
||||||
|
sample_ip = network_address
|
||||||
|
geo_country = GeoIpService.lookup_country(sample_ip)
|
||||||
|
update!(country: geo_country) if geo_country.present?
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to lookup geo location for network range #{cidr}: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Class methods for network operations
|
||||||
|
def self.contains_ip(ip_string)
|
||||||
|
where("network >>= ?", ip_string)
|
||||||
|
.order("masklen(network) DESC") # Most specific first
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.overlapping(range_cidr)
|
||||||
|
where("network && ?", range_cidr)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.find_or_create_by_cidr(cidr, user: nil, source: nil, reason: nil)
|
||||||
|
find_or_create_by(network: cidr) do |range|
|
||||||
|
range.user = user
|
||||||
|
range.source = source || 'user_created'
|
||||||
|
range.creation_reason = reason
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.import_from_cidr(cidr, **attributes)
|
||||||
|
find_or_create_by(network: cidr) do |range|
|
||||||
|
range.assign_attributes(attributes)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Convenience methods for JSON fields
|
||||||
|
def abuser_scores_hash
|
||||||
|
abuser_scores ? JSON.parse(abuser_scores) : {}
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def abuser_scores_hash=(hash)
|
||||||
|
self.abuser_scores = hash.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
def additional_data_hash
|
||||||
|
additional_data ? JSON.parse(additional_data) : {}
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def additional_data_hash=(hash)
|
||||||
|
self.additional_data = hash.to_json
|
||||||
|
end
|
||||||
|
|
||||||
|
# String representations
|
||||||
|
def to_s
|
||||||
|
cidr
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_param
|
||||||
|
cidr.to_s.gsub('/', '_')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Analytics methods
|
||||||
|
def events_count
|
||||||
|
Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address]).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def recent_events(limit: 100)
|
||||||
|
Event.where(ip_address: child_ranges.pluck(:network_address) + [network_address])
|
||||||
|
.recent
|
||||||
|
.limit(limit)
|
||||||
|
end
|
||||||
|
|
||||||
|
def blocking_rules
|
||||||
|
rules.where(action: 'deny', enabled: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_rules
|
||||||
|
rules.enabled.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_default_source
|
||||||
|
self.source ||= 'api_imported'
|
||||||
|
end
|
||||||
|
|
||||||
|
def should_update_children_inheritance?
|
||||||
|
saved_change_to_attribute?(:asn) ||
|
||||||
|
saved_change_to_attribute?(:company) ||
|
||||||
|
saved_change_to_attribute?(:country) ||
|
||||||
|
saved_change_to_attribute?(:is_datacenter) ||
|
||||||
|
saved_change_to_attribute?(:is_proxy) ||
|
||||||
|
saved_change_to_attribute?(:is_vpn)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_children_inheritance!
|
||||||
|
# Find child ranges that don't have their own intelligence
|
||||||
|
child_without_intelligence = child_ranges.where(
|
||||||
|
asn: nil,
|
||||||
|
company: nil,
|
||||||
|
country: nil,
|
||||||
|
is_datacenter: false,
|
||||||
|
is_proxy: false,
|
||||||
|
is_vpn: false
|
||||||
|
)
|
||||||
|
|
||||||
|
child_without_intelligence.find_each do |child|
|
||||||
|
Rails.logger.info "Child range #{child.cidr} can now inherit from parent #{cidr}"
|
||||||
|
# The inherited_intelligence method will pick up the new parent data
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,20 +1,31 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Rule - WAF rule management with NetworkRange integration
|
||||||
|
#
|
||||||
|
# Rules define actions to take for matching traffic conditions.
|
||||||
|
# Network rules are associated with NetworkRange objects for rich context.
|
||||||
class Rule < ApplicationRecord
|
class Rule < ApplicationRecord
|
||||||
# Rule types for the new architecture
|
# Rule types and actions
|
||||||
RULE_TYPES = %w[network_v4 network_v6 rate_limit path_pattern].freeze
|
RULE_TYPES = %w[network rate_limit path_pattern].freeze
|
||||||
ACTIONS = %w[allow deny rate_limit redirect log].freeze
|
ACTIONS = %w[allow deny rate_limit redirect log].freeze
|
||||||
SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default].freeze
|
SOURCES = %w[manual auto:scanner_detected auto:rate_limit_exceeded auto:bot_detected imported default manual:surgical_block manual:surgical_exception].freeze
|
||||||
|
|
||||||
|
# Associations
|
||||||
|
belongs_to :user
|
||||||
|
belongs_to :network_range, optional: true
|
||||||
|
|
||||||
# Validations
|
# Validations
|
||||||
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
|
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
|
||||||
validates :action, presence: true, inclusion: { in: ACTIONS }
|
validates :action, presence: true, inclusion: { in: ACTIONS }
|
||||||
validates :conditions, presence: true
|
validates :conditions, presence: true, unless: :network_rule?
|
||||||
validates :enabled, inclusion: { in: [true, false] }
|
validates :enabled, inclusion: { in: [true, false] }
|
||||||
|
validates :source, inclusion: { in: SOURCES }
|
||||||
|
|
||||||
# Custom validations based on rule type
|
# Custom validations
|
||||||
validate :validate_conditions_by_type
|
validate :validate_conditions_by_type
|
||||||
validate :validate_metadata_by_action
|
validate :validate_metadata_by_action
|
||||||
|
validate :network_range_required_for_network_rules
|
||||||
|
validate :validate_network_consistency, if: :network_rule?
|
||||||
|
|
||||||
# Scopes
|
# Scopes
|
||||||
scope :enabled, -> { where(enabled: true) }
|
scope :enabled, -> { where(enabled: true) }
|
||||||
@@ -22,20 +33,80 @@ class Rule < ApplicationRecord
|
|||||||
scope :active, -> { enabled.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
scope :active, -> { enabled.where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
||||||
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
|
scope :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
|
||||||
scope :by_type, ->(type) { where(rule_type: type) }
|
scope :by_type, ->(type) { where(rule_type: type) }
|
||||||
scope :network_rules, -> { where(rule_type: ["network_v4", "network_v6"]) }
|
scope :network_rules, -> { where(rule_type: "network") }
|
||||||
scope :rate_limit_rules, -> { where(rule_type: "rate_limit") }
|
scope :rate_limit_rules, -> { where(rule_type: "rate_limit") }
|
||||||
scope :path_pattern_rules, -> { where(rule_type: "path_pattern") }
|
scope :path_pattern_rules, -> { where(rule_type: "path_pattern") }
|
||||||
scope :by_source, ->(source) { where(source: source) }
|
scope :by_source, ->(source) { where(source: source) }
|
||||||
|
scope :surgical_blocks, -> { where(source: "manual:surgical_block") }
|
||||||
|
scope :surgical_exceptions, -> { where(source: "manual:surgical_exception") }
|
||||||
|
|
||||||
# Sync queries (ordered by updated_at for incremental sync)
|
# Sync queries
|
||||||
scope :since, ->(timestamp) { where("updated_at >= ?", timestamp - 0.5.seconds).order(:updated_at, :id) }
|
scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) }
|
||||||
scope :sync_order, -> { order(:updated_at, :id) }
|
scope :sync_order, -> { order(:updated_at, :id) }
|
||||||
|
|
||||||
# Callbacks
|
# Callbacks
|
||||||
before_validation :set_defaults
|
before_validation :set_defaults
|
||||||
before_save :calculate_priority_from_cidr
|
before_validation :parse_json_fields
|
||||||
|
before_save :calculate_priority_for_network_rules
|
||||||
|
|
||||||
# Check if rule is currently active
|
# Rule type checks
|
||||||
|
def network_rule?
|
||||||
|
rule_type == "network"
|
||||||
|
end
|
||||||
|
|
||||||
|
def rate_limit_rule?
|
||||||
|
rule_type == "rate_limit"
|
||||||
|
end
|
||||||
|
|
||||||
|
def path_pattern_rule?
|
||||||
|
rule_type == "path_pattern"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Network-specific methods
|
||||||
|
def cidr
|
||||||
|
network_rule? ? network_range&.cidr : conditions&.dig("cidr")
|
||||||
|
end
|
||||||
|
|
||||||
|
def prefix_length
|
||||||
|
network_rule? ? network_range&.prefix_length : cidr&.split("/")&.last&.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def network_intelligence
|
||||||
|
return {} unless network_rule? && network_range
|
||||||
|
|
||||||
|
network_range.inherited_intelligence
|
||||||
|
end
|
||||||
|
|
||||||
|
def network_address
|
||||||
|
network_rule? ? network_range&.network_address : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Surgical block methods
|
||||||
|
def surgical_block?
|
||||||
|
source == "manual:surgical_block"
|
||||||
|
end
|
||||||
|
|
||||||
|
def surgical_exception?
|
||||||
|
source == "manual:surgical_exception"
|
||||||
|
end
|
||||||
|
|
||||||
|
def related_surgical_rules
|
||||||
|
if surgical_block?
|
||||||
|
# Find the corresponding exception rule
|
||||||
|
surgical_exceptions.where(
|
||||||
|
conditions: { cidr: network_address ? "#{network_address}/32" : nil }
|
||||||
|
)
|
||||||
|
elsif surgical_exception?
|
||||||
|
# Find the parent block rule
|
||||||
|
surgical_blocks.joins(:network_range).where(
|
||||||
|
network_ranges: { network: parent_cidr }
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Rule.none
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Rule lifecycle
|
||||||
def active?
|
def active?
|
||||||
enabled? && !expired?
|
enabled? && !expired?
|
||||||
end
|
end
|
||||||
@@ -44,14 +115,37 @@ class Rule < ApplicationRecord
|
|||||||
expires_at.present? && expires_at <= Time.current
|
expires_at.present? && expires_at <= Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
# Convert to format for agent consumption
|
def activate!
|
||||||
|
update!(enabled: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def deactivate!
|
||||||
|
update!(enabled: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def disable!(reason: nil)
|
||||||
|
update!(
|
||||||
|
enabled: false,
|
||||||
|
metadata: metadata.merge(
|
||||||
|
disabled_at: Time.current.iso8601,
|
||||||
|
disabled_reason: reason
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def extend_expiry!(duration)
|
||||||
|
new_expiry = Time.current + duration
|
||||||
|
update!(expires_at: new_expiry)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Agent serialization
|
||||||
def to_agent_format
|
def to_agent_format
|
||||||
{
|
format = {
|
||||||
id: id,
|
id: id,
|
||||||
rule_type: rule_type,
|
rule_type: rule_type,
|
||||||
action: action,
|
action: action,
|
||||||
conditions: conditions || {},
|
conditions: agent_conditions,
|
||||||
priority: priority,
|
priority: agent_priority,
|
||||||
expires_at: expires_at&.iso8601,
|
expires_at: expires_at&.iso8601,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
source: source,
|
source: source,
|
||||||
@@ -59,50 +153,118 @@ class Rule < ApplicationRecord
|
|||||||
created_at: created_at.iso8601,
|
created_at: created_at.iso8601,
|
||||||
updated_at: updated_at.iso8601
|
updated_at: updated_at.iso8601
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add network intelligence for debugging (optional)
|
||||||
|
if network_rule? && network_range
|
||||||
|
format[:network_intelligence] = network_intelligence
|
||||||
end
|
end
|
||||||
|
|
||||||
# Class method to get latest version (for sync cursor)
|
format
|
||||||
# Returns microsecond Unix timestamp for efficient machine comparison
|
end
|
||||||
|
|
||||||
|
# Class methods for rule creation
|
||||||
|
def self.create_network_rule(cidr, action: 'deny', user: nil, **options)
|
||||||
|
network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created')
|
||||||
|
|
||||||
|
create!(
|
||||||
|
rule_type: 'network',
|
||||||
|
action: action,
|
||||||
|
network_range: network_range,
|
||||||
|
user: user,
|
||||||
|
**options
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.create_surgical_block(ip_address, parent_cidr, user: nil, reason: nil, **options)
|
||||||
|
# Create block rule for parent range
|
||||||
|
network_range = NetworkRange.find_or_create_by_cidr(parent_cidr, user: user, source: 'user_created')
|
||||||
|
|
||||||
|
block_rule = create!(
|
||||||
|
rule_type: 'network',
|
||||||
|
action: 'deny',
|
||||||
|
network_range: network_range,
|
||||||
|
source: 'manual:surgical_block',
|
||||||
|
user: user,
|
||||||
|
metadata: {
|
||||||
|
reason: reason,
|
||||||
|
surgical_block: true,
|
||||||
|
original_ip: ip_address,
|
||||||
|
**options[:metadata]
|
||||||
|
},
|
||||||
|
**options.except(:metadata)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create exception rule for specific IP
|
||||||
|
ip_network_range = NetworkRange.find_or_create_by_cidr("#{ip_address}/#{ip_address.include?(':') ? '128' : '32'}", user: user, source: 'user_created')
|
||||||
|
|
||||||
|
exception_rule = create!(
|
||||||
|
rule_type: 'network',
|
||||||
|
action: 'allow',
|
||||||
|
network_range: ip_network_range,
|
||||||
|
source: 'manual:surgical_exception',
|
||||||
|
user: user,
|
||||||
|
priority: ip_network_range.prefix_length, # Higher priority = more specific
|
||||||
|
metadata: {
|
||||||
|
reason: "Exception for #{ip_address} in surgical block of #{parent_cidr}",
|
||||||
|
surgical_exception: true,
|
||||||
|
parent_rule_id: block_rule.id,
|
||||||
|
**options[:metadata]
|
||||||
|
},
|
||||||
|
**options.except(:metadata)
|
||||||
|
)
|
||||||
|
|
||||||
|
[block_rule, exception_rule]
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.create_rate_limit_rule(cidr, limit:, window:, user: nil, **options)
|
||||||
|
network_range = NetworkRange.find_or_create_by_cidr(cidr, user: user, source: 'user_created')
|
||||||
|
|
||||||
|
create!(
|
||||||
|
rule_type: 'rate_limit',
|
||||||
|
action: 'rate_limit',
|
||||||
|
network_range: network_range,
|
||||||
|
conditions: { cidr: cidr, scope: 'ip' },
|
||||||
|
metadata: {
|
||||||
|
limit: limit,
|
||||||
|
window: window,
|
||||||
|
**options[:metadata]
|
||||||
|
},
|
||||||
|
user: user,
|
||||||
|
**options.except(:metadata)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sync and versioning
|
||||||
def self.latest_version
|
def self.latest_version
|
||||||
max_time = maximum(:updated_at)
|
max_time = maximum(:updated_at)
|
||||||
if max_time
|
max_time ? max_time.to_i : Time.current.to_i
|
||||||
# Convert to microseconds since epoch
|
|
||||||
(max_time.to_f * 1_000_000).to_i
|
|
||||||
else
|
|
||||||
(Time.current.to_f * 1_000_000).to_i
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Disable rule (soft delete)
|
def self.active_for_agent
|
||||||
def disable!(reason: nil)
|
active.sync_order.map(&:to_agent_format)
|
||||||
update!(
|
|
||||||
enabled: false,
|
|
||||||
metadata: (metadata || {}).merge(
|
|
||||||
disabled_at: Time.current.iso8601,
|
|
||||||
disabled_reason: reason
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Enable rule
|
# Analytics methods
|
||||||
def enable!
|
def matching_events(limit: 100)
|
||||||
update!(enabled: true)
|
return Event.none unless network_rule? && network_range
|
||||||
|
|
||||||
|
# This would need efficient IP range queries
|
||||||
|
# For now, simple IP match
|
||||||
|
Event.where(ip_address: network_range.network_address)
|
||||||
|
.recent
|
||||||
|
.limit(limit)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if this is a network rule
|
def effectiveness_stats
|
||||||
def network_rule?
|
return {} unless network_rule?
|
||||||
rule_type.in?(%w[network_v4 network_v6])
|
|
||||||
end
|
|
||||||
|
|
||||||
# Get CIDR from conditions (for network rules)
|
events = matching_events
|
||||||
def cidr
|
{
|
||||||
conditions&.dig("cidr") if network_rule?
|
total_events: events.count,
|
||||||
end
|
blocked_events: events.blocked.count,
|
||||||
|
allowed_events: events.allowed.count,
|
||||||
# Get prefix length from CIDR
|
block_rate: events.count > 0 ? (events.blocked.count.to_f / events.count * 100).round(2) : 0
|
||||||
def prefix_length
|
}
|
||||||
return nil unless cidr
|
|
||||||
cidr.split("/").last.to_i
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -112,19 +274,40 @@ class Rule < ApplicationRecord
|
|||||||
self.conditions ||= {}
|
self.conditions ||= {}
|
||||||
self.metadata ||= {}
|
self.metadata ||= {}
|
||||||
self.source ||= "manual"
|
self.source ||= "manual"
|
||||||
|
|
||||||
|
# Set system user for auto-generated rules if no user is set
|
||||||
|
if source&.start_with?('auto:') || source == 'default'
|
||||||
|
self.user ||= User.find_by(role: 1) # admin role
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def calculate_priority_from_cidr
|
def calculate_priority_for_network_rules
|
||||||
# For network rules, priority is the prefix length (more specific = higher priority)
|
if network_rule? && network_range
|
||||||
if network_rule? && cidr.present?
|
self.priority = network_range.prefix_length
|
||||||
self.priority = prefix_length
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def agent_conditions
|
||||||
|
if network_rule?
|
||||||
|
{ cidr: cidr }
|
||||||
|
else
|
||||||
|
conditions || {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def agent_priority
|
||||||
|
if network_rule?
|
||||||
|
prefix_length || 0
|
||||||
|
else
|
||||||
|
priority || 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_conditions_by_type
|
def validate_conditions_by_type
|
||||||
case rule_type
|
case rule_type
|
||||||
when "network_v4", "network_v6"
|
when "network"
|
||||||
validate_network_conditions
|
# Network rules don't need conditions in DB - stored in network_range
|
||||||
|
true
|
||||||
when "rate_limit"
|
when "rate_limit"
|
||||||
validate_rate_limit_conditions
|
validate_rate_limit_conditions
|
||||||
when "path_pattern"
|
when "path_pattern"
|
||||||
@@ -132,29 +315,6 @@ class Rule < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_network_conditions
|
|
||||||
cidr_value = conditions&.dig("cidr")
|
|
||||||
|
|
||||||
if cidr_value.blank?
|
|
||||||
errors.add(:conditions, "must include 'cidr' for network rules")
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Validate CIDR format
|
|
||||||
begin
|
|
||||||
addr = IPAddr.new(cidr_value)
|
|
||||||
|
|
||||||
# Check IPv4 vs IPv6 matches rule_type
|
|
||||||
if rule_type == "network_v4" && !addr.ipv4?
|
|
||||||
errors.add(:conditions, "cidr must be IPv4 for network_v4 rules")
|
|
||||||
elsif rule_type == "network_v6" && !addr.ipv6?
|
|
||||||
errors.add(:conditions, "cidr must be IPv6 for network_v6 rules")
|
|
||||||
end
|
|
||||||
rescue IPAddr::InvalidAddressError => e
|
|
||||||
errors.add(:conditions, "invalid CIDR format: #{e.message}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_rate_limit_conditions
|
def validate_rate_limit_conditions
|
||||||
scope = conditions&.dig("scope")
|
scope = conditions&.dig("scope")
|
||||||
cidr_value = conditions&.dig("cidr")
|
cidr_value = conditions&.dig("cidr")
|
||||||
@@ -163,11 +323,6 @@ class Rule < ApplicationRecord
|
|||||||
errors.add(:conditions, "must include 'scope' for rate_limit rules")
|
errors.add(:conditions, "must include 'scope' for rate_limit rules")
|
||||||
end
|
end
|
||||||
|
|
||||||
if cidr_value.blank?
|
|
||||||
errors.add(:conditions, "must include 'cidr' for rate_limit rules")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Validate metadata has rate limit config
|
|
||||||
unless metadata&.dig("limit").present? && metadata&.dig("window").present?
|
unless metadata&.dig("limit").present? && metadata&.dig("window").present?
|
||||||
errors.add(:metadata, "must include 'limit' and 'window' for rate_limit rules")
|
errors.add(:metadata, "must include 'limit' and 'window' for rate_limit rules")
|
||||||
end
|
end
|
||||||
@@ -193,4 +348,50 @@ class Rule < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
def network_range_required_for_network_rules
|
||||||
|
if network_rule? && network_range.nil?
|
||||||
|
errors.add(:network_range, "is required for network rules")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_network_consistency
|
||||||
|
return unless network_rule? && network_range
|
||||||
|
|
||||||
|
# For network rules, we don't use conditions - the network_range handles everything
|
||||||
|
# So we can skip this validation for now
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def parent_cidr
|
||||||
|
return nil unless network_range
|
||||||
|
|
||||||
|
# Find a broader network range that contains this one
|
||||||
|
network_range.parent_ranges.first&.cidr
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_json_fields
|
||||||
|
# Parse conditions if it's a string
|
||||||
|
if conditions.is_a?(String) && conditions.present?
|
||||||
|
begin
|
||||||
|
self.conditions = JSON.parse(conditions) if conditions != "{}"
|
||||||
|
rescue JSON::ParserError
|
||||||
|
self.conditions = {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parse metadata if it's a string
|
||||||
|
if metadata.is_a?(String) && metadata.present?
|
||||||
|
begin
|
||||||
|
self.metadata = JSON.parse(metadata) if metadata != "{}"
|
||||||
|
rescue JSON::ParserError
|
||||||
|
self.metadata = {}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ensure they are hashes
|
||||||
|
self.conditions ||= {}
|
||||||
|
self.metadata ||= {}
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class RuleSet < ApplicationRecord
|
|
||||||
has_many :rules, dependent: :destroy
|
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: true
|
|
||||||
validates :slug, presence: true, uniqueness: true
|
|
||||||
|
|
||||||
scope :enabled, -> { where(enabled: true) }
|
|
||||||
scope :by_priority, -> { order(priority: :desc, created_at: :desc) }
|
|
||||||
|
|
||||||
before_validation :generate_slug, if: :name?
|
|
||||||
before_validation :set_default_values
|
|
||||||
|
|
||||||
# Rule Types
|
|
||||||
RULE_TYPES = %w[ip cidr path user_agent parameter method rate_limit country].freeze
|
|
||||||
ACTIONS = %w[allow deny challenge rate_limit].freeze
|
|
||||||
|
|
||||||
def to_waf_rules
|
|
||||||
return [] unless enabled?
|
|
||||||
|
|
||||||
rules.enabled.by_priority.map do |rule|
|
|
||||||
{
|
|
||||||
id: rule.id,
|
|
||||||
type: rule.rule_type,
|
|
||||||
target: rule.target,
|
|
||||||
action: rule.action,
|
|
||||||
conditions: rule.conditions,
|
|
||||||
priority: rule.priority,
|
|
||||||
expires_at: rule.expires_at
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def add_rule(rule_type, target, action, conditions: {}, expires_at: nil, priority: 100)
|
|
||||||
rules.create!(
|
|
||||||
rule_type: rule_type,
|
|
||||||
target: target,
|
|
||||||
action: action,
|
|
||||||
conditions: conditions,
|
|
||||||
expires_at: expires_at,
|
|
||||||
priority: priority
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def remove_rule(rule_id)
|
|
||||||
rules.find(rule_id).destroy
|
|
||||||
end
|
|
||||||
|
|
||||||
def block_ip(ip_address, expires_at: nil, reason: nil)
|
|
||||||
add_rule('ip', ip_address, 'deny', expires_at: expires_at, priority: 1000)
|
|
||||||
end
|
|
||||||
|
|
||||||
def allow_ip(ip_address, expires_at: nil)
|
|
||||||
add_rule('ip', ip_address, 'allow', expires_at: expires_at, priority: 1000)
|
|
||||||
end
|
|
||||||
|
|
||||||
def block_cidr(cidr, expires_at: nil, reason: nil)
|
|
||||||
add_rule('cidr', cidr, 'deny', expires_at: expires_at, priority: 900)
|
|
||||||
end
|
|
||||||
|
|
||||||
def block_path(path, conditions: {}, expires_at: nil)
|
|
||||||
add_rule('path', path, 'deny', conditions: conditions, expires_at: expires_at, priority: 500)
|
|
||||||
end
|
|
||||||
|
|
||||||
def block_user_agent(user_agent_pattern, expires_at: nil)
|
|
||||||
add_rule('user_agent', user_agent_pattern, 'deny', expires_at: expires_at, priority: 600)
|
|
||||||
end
|
|
||||||
|
|
||||||
def push_to_agents!
|
|
||||||
# This would integrate with the agent distribution system
|
|
||||||
Rails.logger.info "Pushing rule set '#{name}' with #{rules.count} rules to agents"
|
|
||||||
|
|
||||||
# Broadcast update to connected projects
|
|
||||||
projects = Project.where(id: projects_subscription || [])
|
|
||||||
projects.each(&:broadcast_rules_refresh)
|
|
||||||
end
|
|
||||||
|
|
||||||
def active_projects
|
|
||||||
return Project.none unless projects_subscription.present?
|
|
||||||
|
|
||||||
Project.where(id: projects_subscription).enabled
|
|
||||||
end
|
|
||||||
|
|
||||||
def subscribe_project(project)
|
|
||||||
subscriptions = projects_subscription || []
|
|
||||||
subscriptions << project.id unless subscriptions.include?(project.id)
|
|
||||||
update(projects_subscription: subscriptions.uniq)
|
|
||||||
end
|
|
||||||
|
|
||||||
def unsubscribe_project(project)
|
|
||||||
subscriptions = projects_subscription || []
|
|
||||||
subscriptions.delete(project.id)
|
|
||||||
update(projects_subscription: subscriptions)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def generate_slug
|
|
||||||
self.slug = name&.parameterize&.downcase
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_default_values
|
|
||||||
self.enabled = true if enabled.nil?
|
|
||||||
self.priority = 100 if priority.nil?
|
|
||||||
self.projects_subscription = [] if projects_subscription.nil?
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -6,6 +6,10 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
enum :role, { admin: 0, user: 1, viewer: 2 }, default: :user
|
enum :role, { admin: 0, user: 1, viewer: 2 }, default: :user
|
||||||
|
|
||||||
|
generates_token_for :password_reset, expires_in: 1.hour do
|
||||||
|
updated_at
|
||||||
|
end
|
||||||
|
|
||||||
validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
||||||
validates :role, presence: true
|
validates :role, presence: true
|
||||||
|
|
||||||
@@ -18,13 +22,18 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
user = find_or_initialize_by(email_address: email)
|
user = find_or_initialize_by(email_address: email)
|
||||||
|
|
||||||
# Map OIDC groups to role
|
# Map OIDC groups to role for new users or update existing user's role
|
||||||
if auth_hash.dig('extra', 'raw_info', 'groups')
|
if auth_hash.dig('extra', 'raw_info', 'groups')
|
||||||
user.role = map_oidc_groups_to_role(auth_hash.dig('extra', 'raw_info', 'groups'))
|
user.role = map_oidc_groups_to_role(auth_hash.dig('extra', 'raw_info', 'groups'))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Don't override password for OIDC users
|
# For OIDC users, set a random password if they don't have one
|
||||||
user.save!(validate: false) if user.new_record?
|
if user.new_record? && !user.password_digest?
|
||||||
|
user.password = SecureRandom.hex(32) # OIDC users won't use this
|
||||||
|
end
|
||||||
|
|
||||||
|
# Save the user (skip password validation for OIDC users)
|
||||||
|
user.save!(validate: false) if user.changed?
|
||||||
user
|
user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
59
app/policies/application_policy.rb
Normal file
59
app/policies/application_policy.rb
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class ApplicationPolicy
|
||||||
|
attr_reader :user, :record
|
||||||
|
|
||||||
|
def initialize(user, record)
|
||||||
|
@user = user
|
||||||
|
@record = record
|
||||||
|
end
|
||||||
|
|
||||||
|
def index?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def show?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def create?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def new?
|
||||||
|
create?
|
||||||
|
end
|
||||||
|
|
||||||
|
def update?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit?
|
||||||
|
update?
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def current_user
|
||||||
|
@user || Current.user
|
||||||
|
end
|
||||||
|
|
||||||
|
class Scope
|
||||||
|
def initialize(user, scope)
|
||||||
|
@user = user
|
||||||
|
@scope = scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve
|
||||||
|
raise NoMethodError, "You must define #resolve in #{self.class}"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :user, :scope
|
||||||
|
end
|
||||||
|
end
|
||||||
62
app/policies/network_range_policy.rb
Normal file
62
app/policies/network_range_policy.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
class NetworkRangePolicy < ApplicationPolicy
|
||||||
|
# NOTE: Up to Pundit v2.3.1, the inheritance was declared as
|
||||||
|
# `Scope < Scope` rather than `Scope < ApplicationPolicy::Scope`.
|
||||||
|
# In most cases the behavior will be identical, but if updating existing
|
||||||
|
# code, beware of possible changes to the ancestors:
|
||||||
|
# https://gist.github.com/Burgestrand/4b4bc22f31c8a95c425fc0e30d7ef1f5
|
||||||
|
|
||||||
|
def index?
|
||||||
|
true # Anyone can browse network ranges
|
||||||
|
end
|
||||||
|
|
||||||
|
def show?
|
||||||
|
true # Anyone can view network range details
|
||||||
|
end
|
||||||
|
|
||||||
|
def lookup?
|
||||||
|
true # Anyone can lookup IP addresses
|
||||||
|
end
|
||||||
|
|
||||||
|
def new?
|
||||||
|
current_user.present? # Must be authenticated to create network ranges
|
||||||
|
end
|
||||||
|
|
||||||
|
def create?
|
||||||
|
current_user.present? # Must be authenticated to create network ranges
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit?
|
||||||
|
return false unless current_user.present?
|
||||||
|
return true if current_user.admin?
|
||||||
|
|
||||||
|
# Users can edit their own network ranges
|
||||||
|
record.user == current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def update?
|
||||||
|
return false unless current_user.present?
|
||||||
|
return true if current_user.admin?
|
||||||
|
|
||||||
|
# Users can update their own network ranges
|
||||||
|
record.user == current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy?
|
||||||
|
return false unless current_user.present?
|
||||||
|
return true if current_user.admin?
|
||||||
|
|
||||||
|
# Users can delete their own network ranges
|
||||||
|
record.user == current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def enrich?
|
||||||
|
update? # Same permissions as update
|
||||||
|
end
|
||||||
|
|
||||||
|
class Scope < ApplicationPolicy::Scope
|
||||||
|
def resolve
|
||||||
|
# All users can see all network ranges
|
||||||
|
scope.all
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
62
app/policies/rule_policy.rb
Normal file
62
app/policies/rule_policy.rb
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
class RulePolicy < ApplicationPolicy
|
||||||
|
# NOTE: Up to Pundit v2.3.1, the inheritance was declared as
|
||||||
|
# `Scope < Scope` rather than `Scope < ApplicationPolicy::Scope`.
|
||||||
|
# In most cases the behavior will be identical, but if updating existing
|
||||||
|
# code, beware of possible changes to the ancestors:
|
||||||
|
# https://gist.github.com/Burgestrand/4b4bc22f31c8a95c425fc0e30d7ef1f5
|
||||||
|
|
||||||
|
def index?
|
||||||
|
true # Anyone can browse rules
|
||||||
|
end
|
||||||
|
|
||||||
|
def show?
|
||||||
|
true # Anyone can view rule details
|
||||||
|
end
|
||||||
|
|
||||||
|
def new?
|
||||||
|
current_user.present? # Must be authenticated to create rules
|
||||||
|
end
|
||||||
|
|
||||||
|
def create?
|
||||||
|
current_user.present? # Must be authenticated to create rules
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit?
|
||||||
|
return false unless current_user.present?
|
||||||
|
return true if current_user.admin?
|
||||||
|
|
||||||
|
# Users can edit their own rules
|
||||||
|
record.user == current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def update?
|
||||||
|
return false unless current_user.present?
|
||||||
|
return true if current_user.admin?
|
||||||
|
|
||||||
|
# Users can update their own rules
|
||||||
|
record.user == current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy?
|
||||||
|
return false unless current_user.present?
|
||||||
|
return true if current_user.admin?
|
||||||
|
|
||||||
|
# Users can delete their own rules
|
||||||
|
record.user == current_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable?
|
||||||
|
update?
|
||||||
|
end
|
||||||
|
|
||||||
|
def disable?
|
||||||
|
update?
|
||||||
|
end
|
||||||
|
|
||||||
|
class Scope < ApplicationPolicy::Scope
|
||||||
|
def resolve
|
||||||
|
# All users can see all rules
|
||||||
|
scope.all
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
222
app/services/ip_range_resolver.rb
Normal file
222
app/services/ip_range_resolver.rb
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# IpRangeResolver - Service for resolving IP addresses to network ranges
|
||||||
|
#
|
||||||
|
# Provides methods to find matching network ranges for IP addresses,
|
||||||
|
# create surgical blocks, and analyze IP intelligence.
|
||||||
|
class IpRangeResolver
|
||||||
|
# Find all network ranges that contain the given IP address
|
||||||
|
# Returns array of hashes with range data, ordered by specificity (most specific first)
|
||||||
|
def self.resolve(ip_address)
|
||||||
|
return [] unless ip_address.present?
|
||||||
|
|
||||||
|
NetworkRange.contains_ip(ip_address).map do |range|
|
||||||
|
{
|
||||||
|
range: range,
|
||||||
|
cidr: range.cidr,
|
||||||
|
prefix_length: range.prefix_length,
|
||||||
|
specificity: range.prefix_length,
|
||||||
|
intelligence: range.inherited_intelligence
|
||||||
|
}
|
||||||
|
end.sort_by { |r| -r[:specificity] } # Most specific first
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find the most specific network range for an IP
|
||||||
|
def self.most_specific_range(ip_address)
|
||||||
|
resolve(ip_address).first
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find all network ranges that overlap with a given CIDR
|
||||||
|
def self.overlapping_ranges(cidr)
|
||||||
|
return [] unless cidr.present?
|
||||||
|
|
||||||
|
NetworkRange.overlapping(cidr).map do |range|
|
||||||
|
{
|
||||||
|
range: range,
|
||||||
|
cidr: range.cidr,
|
||||||
|
prefix_length: range.prefix_length,
|
||||||
|
specificity: range.prefix_length,
|
||||||
|
intelligence: range.inherited_intelligence
|
||||||
|
}
|
||||||
|
end.sort_by { |r| -r[:specificity] }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create network range if it doesn't exist
|
||||||
|
def self.find_or_create_range(cidr, user: nil, source: nil, reason: nil, **attributes)
|
||||||
|
return nil unless cidr.present?
|
||||||
|
|
||||||
|
NetworkRange.find_or_create_by_cidr(cidr, user: user, source: source, reason: reason) do |range|
|
||||||
|
# Try to inherit attributes from parent ranges
|
||||||
|
inherited_attrs = inherited_attributes(cidr)
|
||||||
|
range.assign_attributes(inherited_attrs.merge(attributes))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create surgical block (block parent range, allow specific IP)
|
||||||
|
def self.create_surgical_block(ip_address, parent_cidr, user: nil, reason: nil, **options)
|
||||||
|
return [nil, nil] unless ip_address.present? && parent_cidr.present?
|
||||||
|
|
||||||
|
Rule.create_surgical_block(ip_address, parent_cidr, user: user, reason: reason, **options)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get IP intelligence data
|
||||||
|
def self.get_ip_intelligence(ip_address)
|
||||||
|
ranges = resolve(ip_address)
|
||||||
|
|
||||||
|
{
|
||||||
|
ip_address: ip_address,
|
||||||
|
ranges: ranges,
|
||||||
|
most_specific_range: ranges.first,
|
||||||
|
intelligence: ranges.first&.dig(:intelligence) || {},
|
||||||
|
|
||||||
|
# Suggested blocking ranges
|
||||||
|
suggested_blocks: suggest_blocking_ranges(ip_address, ranges)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Suggest CIDR ranges for blocking based on network hierarchy
|
||||||
|
def self.suggest_blocking_ranges(ip_address, ranges = nil)
|
||||||
|
ranges ||= resolve(ip_address)
|
||||||
|
return [] if ranges.empty?
|
||||||
|
|
||||||
|
ip_obj = IPAddr.new(ip_address)
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
# Current /32 or /128 (single IP)
|
||||||
|
suggestions << {
|
||||||
|
cidr: "#{ip_address}/#{ip_obj.ipv4? ? '32' : '128'}",
|
||||||
|
type: 'single_ip',
|
||||||
|
description: 'Single IP address',
|
||||||
|
current_block: ranges.any? { |r| r[:prefix_length] == (ip_obj.ipv4? ? 32 : 128) }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Look for common network sizes
|
||||||
|
if ip_obj.ipv4?
|
||||||
|
[24, 23, 22, 21, 20, 19, 18, 16].each do |prefix|
|
||||||
|
network_cidr = calculate_network_cidr(ip_address, prefix)
|
||||||
|
next unless network_cidr
|
||||||
|
|
||||||
|
suggestions << {
|
||||||
|
cidr: network_cidr,
|
||||||
|
type: 'network_block',
|
||||||
|
description: "/#{prefix} network block",
|
||||||
|
current_block: ranges.any? { |r| r[:prefix_length] == prefix },
|
||||||
|
existing_range: ranges.find { |r| r[:prefix_length] <= prefix }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
suggestions
|
||||||
|
end
|
||||||
|
|
||||||
|
# Find related IPs from same network ranges
|
||||||
|
def self.find_related_ips(ip_address, limit_per_range: 100, total_limit: 500)
|
||||||
|
ranges = resolve(ip_address)
|
||||||
|
return [] if ranges.empty?
|
||||||
|
|
||||||
|
related_ips = {}
|
||||||
|
|
||||||
|
ranges.each do |range_data|
|
||||||
|
range = range_data[:range]
|
||||||
|
|
||||||
|
# Find events from this range (excluding the original IP)
|
||||||
|
events = Event.where("ip_address <<= ?", range.cidr) # Postgres <<= operator
|
||||||
|
.where.not(ip_address: ip_address)
|
||||||
|
.limit(limit_per_range)
|
||||||
|
.distinct(:ip_address)
|
||||||
|
.pluck(:ip_address)
|
||||||
|
|
||||||
|
related_ips[range.cidr] = events unless events.empty?
|
||||||
|
|
||||||
|
break if related_ips.values.flatten.size >= total_limit
|
||||||
|
end
|
||||||
|
|
||||||
|
related_ips
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if IP is currently blocked by any rule
|
||||||
|
def self.ip_blocked?(ip_address)
|
||||||
|
ranges = resolve(ip_address)
|
||||||
|
return false if ranges.empty?
|
||||||
|
|
||||||
|
range_ids = ranges.map { |r| r[:range].id }
|
||||||
|
|
||||||
|
Rule.network_rules
|
||||||
|
.where(network_range_id: range_ids)
|
||||||
|
.where(action: 'deny')
|
||||||
|
.enabled
|
||||||
|
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
||||||
|
.exists?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get blocking rules for an IP
|
||||||
|
def self.blocking_rules_for_ip(ip_address)
|
||||||
|
ranges = resolve(ip_address)
|
||||||
|
return Rule.none if ranges.empty?
|
||||||
|
|
||||||
|
range_ids = ranges.map { |r| r[:range].id }
|
||||||
|
|
||||||
|
Rule.network_rules
|
||||||
|
.where(network_range_id: range_ids)
|
||||||
|
.where(action: 'deny')
|
||||||
|
.enabled
|
||||||
|
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
||||||
|
.includes(:network_range)
|
||||||
|
.order('network_ranges.network_prefix DESC')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Analyze traffic patterns for a network range
|
||||||
|
def self.analyze_network_traffic(cidr, time_range: 1.week.ago..Time.current)
|
||||||
|
network_range = NetworkRange.find_by(network: cidr)
|
||||||
|
return nil unless network_range
|
||||||
|
|
||||||
|
events = Event.where("ip_address <<= ?", cidr) # Postgres <<= operator
|
||||||
|
.where(timestamp: time_range)
|
||||||
|
|
||||||
|
{
|
||||||
|
network_range: network_range,
|
||||||
|
total_requests: events.count,
|
||||||
|
unique_ips: events.distinct.count(:ip_address),
|
||||||
|
blocked_requests: events.blocked.count,
|
||||||
|
allowed_requests: events.allowed.count,
|
||||||
|
top_paths: events.group(:request_path).count.sort_by { |_, count| -count }.first(10),
|
||||||
|
top_user_agents: events.group(:user_agent).count.sort_by { |_, count| -count }.first(5),
|
||||||
|
time_distribution: events.group_by_hour(:timestamp).count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Inherit attributes from parent network ranges
|
||||||
|
def self.inherited_attributes(cidr)
|
||||||
|
ip_obj = IPAddr.new(cidr)
|
||||||
|
|
||||||
|
parent = NetworkRange.where("network <<= ? AND masklen(network) < ?", cidr, ip_obj.prefixlen)
|
||||||
|
.where.not(asn: nil)
|
||||||
|
.order("masklen(network) DESC")
|
||||||
|
.first
|
||||||
|
|
||||||
|
if parent
|
||||||
|
{
|
||||||
|
asn: parent.asn,
|
||||||
|
asn_org: parent.asn_org,
|
||||||
|
company: parent.company,
|
||||||
|
country: parent.country,
|
||||||
|
is_datacenter: parent.is_datacenter,
|
||||||
|
is_proxy: parent.is_proxy,
|
||||||
|
is_vpn: parent.is_vpn
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Calculate network CIDR for an IP and prefix length
|
||||||
|
def self.calculate_network_cidr(ip_address, prefix_length)
|
||||||
|
ip_obj = IPAddr.new(ip_address)
|
||||||
|
network = ip_obj.mask(prefix_length)
|
||||||
|
"#{network}/#{prefix_length}"
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
43
app/services/ipapi.rb
Normal file
43
app/services/ipapi.rb
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
class Ipapi
|
||||||
|
include BookoAgent
|
||||||
|
BASE_URL = "https://api.ipapi.is/"
|
||||||
|
API_KEY = Rails.application.credentials.ipapi_key
|
||||||
|
|
||||||
|
def lookup(ip) = json_at("#{BASE_URL}?q=#{ip}&key=#{API_KEY}")
|
||||||
|
|
||||||
|
def self.lookup(ip) = new.lookup(ip)
|
||||||
|
|
||||||
|
def multi_lookup(ips)
|
||||||
|
ips = Array(ips)
|
||||||
|
ips.each_slice(100).flat_map { |slice| post_data({ips: slice}) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def data(ip)
|
||||||
|
uri = URI.parse(BASE_URL)
|
||||||
|
|
||||||
|
if ip.is_a?(Array)
|
||||||
|
post_data(ip)
|
||||||
|
else
|
||||||
|
uri.query = "q=#{ip}"
|
||||||
|
JSON.parse(http.request(uri).body)
|
||||||
|
end
|
||||||
|
rescue JSON::ParserError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_data(ips)
|
||||||
|
url = URI.parse(BASE_URL + "?key=#{API_KEY}")
|
||||||
|
|
||||||
|
results = post_json(url, body: ips)
|
||||||
|
|
||||||
|
results["response"].map do |ip, data|
|
||||||
|
IPAddr.new(ip)
|
||||||
|
cidr = data.dig("asn", "route")
|
||||||
|
|
||||||
|
NetworkRange.add_network(cidr).tap { |acl| acl&.update(ip_api_data: data) }
|
||||||
|
rescue IPAddr::InvalidAddressError
|
||||||
|
puts "Skipping #{ip}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
201
app/services/network_data_importer.rb
Normal file
201
app/services/network_data_importer.rb
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# NetworkDataImporter - Service for importing production network data
|
||||||
|
#
|
||||||
|
# Imports network ranges from JSONL format with rich metadata.
|
||||||
|
# Optimized for bulk importing large datasets.
|
||||||
|
class NetworkDataImporter
|
||||||
|
def self.import_from_jsonl(file_path, limit: nil, batch_size: 1000)
|
||||||
|
puts "Starting import from #{file_path}"
|
||||||
|
|
||||||
|
imported_count = 0
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
File.foreach(file_path) do |line|
|
||||||
|
break if limit && imported_count >= limit
|
||||||
|
|
||||||
|
begin
|
||||||
|
data = JSON.parse(line)
|
||||||
|
batch << convert_to_network_range(data)
|
||||||
|
|
||||||
|
if batch.size >= batch_size
|
||||||
|
import_batch(batch)
|
||||||
|
imported_count += batch.size
|
||||||
|
puts "Imported #{imported_count} records..."
|
||||||
|
batch = []
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue JSON::ParserError => e
|
||||||
|
Rails.logger.error "Failed to parse line: #{e.message}"
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error processing record: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Import remaining records
|
||||||
|
if batch.any?
|
||||||
|
import_batch(batch)
|
||||||
|
imported_count += batch.size
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Import completed. Total records: #{imported_count}"
|
||||||
|
imported_count
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.import_sample(file_path, sample_size: 1000)
|
||||||
|
puts "Importing sample of #{sample_size} records from #{file_path}"
|
||||||
|
|
||||||
|
imported_count = 0
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
File.foreach(file_path) do |line|
|
||||||
|
break if imported_count >= sample_size
|
||||||
|
|
||||||
|
begin
|
||||||
|
data = JSON.parse(line)
|
||||||
|
batch << convert_to_network_range(data)
|
||||||
|
|
||||||
|
if batch.size >= 100
|
||||||
|
import_batch(batch)
|
||||||
|
imported_count += batch.size
|
||||||
|
batch = []
|
||||||
|
end
|
||||||
|
|
||||||
|
rescue JSON::ParserError => e
|
||||||
|
Rails.logger.error "Failed to parse line: #{e.message}"
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Error processing record: #{e.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Import remaining records
|
||||||
|
if batch.any?
|
||||||
|
import_batch(batch)
|
||||||
|
imported_count += batch.size
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Sample import completed. Total records: #{imported_count}"
|
||||||
|
imported_count
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.test_import_with_lookup(file_path, test_ips: ['8.8.8.8', '1.1.1.1', '192.168.1.100'])
|
||||||
|
puts "Importing sample data and testing IP lookups..."
|
||||||
|
|
||||||
|
# Import a small sample first
|
||||||
|
import_sample(file_path, sample_size: 10000)
|
||||||
|
|
||||||
|
# Test IP resolution
|
||||||
|
puts "\n=== Testing IP Resolution ==="
|
||||||
|
test_ips.each do |ip|
|
||||||
|
puts "\nTesting IP: #{ip}"
|
||||||
|
|
||||||
|
# Find matching ranges
|
||||||
|
ranges = NetworkRange.contains_ip(ip)
|
||||||
|
puts "Found #{ranges.count} matching ranges"
|
||||||
|
|
||||||
|
ranges.each_with_index do |range, index|
|
||||||
|
puts " #{index + 1}. #{range.cidr} (#{range.prefix_length})"
|
||||||
|
puts " Company: #{range.company || 'Unknown'}"
|
||||||
|
puts " ASN: #{range.asn || 'Unknown'}"
|
||||||
|
puts " Country: #{range.country || 'Unknown'}"
|
||||||
|
puts " Datacenter: #{range.is_datacenter? ? 'Yes' : 'No'}"
|
||||||
|
puts " VPN: #{range.is_vpn? ? 'Yes' : 'No'}"
|
||||||
|
puts " Proxy: #{range.is_proxy? ? 'Yes' : 'No'}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test IpRangeResolver
|
||||||
|
puts "\nUsing IpRangeResolver:"
|
||||||
|
resolved = IpRangeResolver.resolve(ip)
|
||||||
|
puts "Resolved #{resolved.count} ranges"
|
||||||
|
resolved.first(3).each_with_index do |range_data, index|
|
||||||
|
intel = range_data[:intelligence]
|
||||||
|
puts " #{index + 1}. #{range_data[:cidr]} (specificity: #{range_data[:specificity]})"
|
||||||
|
puts " Company: #{intel[:company] || 'Unknown'}"
|
||||||
|
puts " Inherited: #{intel[:inherited] ? 'Yes' : 'No'}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test rule creation
|
||||||
|
puts "\n=== Testing Rule Creation ==="
|
||||||
|
test_ip = test_ips.first
|
||||||
|
matching_range = NetworkRange.contains_ip(test_ip).first
|
||||||
|
|
||||||
|
if matching_range
|
||||||
|
puts "Creating rule for #{matching_range.cidr}"
|
||||||
|
user = User.first || User.create!(email_address: 'test@example.com', password: 'password123')
|
||||||
|
|
||||||
|
rule = Rule.create_network_rule(matching_range.cidr, action: 'deny', user: user)
|
||||||
|
puts "Rule created: #{rule.id} - #{rule.cidr}"
|
||||||
|
puts "Rule network intelligence: #{rule.network_intelligence[:company]}"
|
||||||
|
|
||||||
|
# Test surgical blocking
|
||||||
|
puts "\nTesting surgical blocking for IP #{test_ip}"
|
||||||
|
parent_cidr = matching_range.cidr
|
||||||
|
|
||||||
|
block_rule, exception_rule = Rule.create_surgical_block(
|
||||||
|
test_ip, parent_cidr, user: user, reason: 'Test surgical block'
|
||||||
|
)
|
||||||
|
puts "Block rule: #{block_rule.id} - #{block_rule.cidr}"
|
||||||
|
puts "Exception rule: #{exception_rule.id} - #{exception_rule.cidr}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def self.convert_to_network_range(data)
|
||||||
|
# Convert integer network_start to IP address
|
||||||
|
network_start_ip = integer_to_ip(data['network_start'], data['ip_version'])
|
||||||
|
network_end_ip = integer_to_ip(data['network_end'], data['ip_version'])
|
||||||
|
|
||||||
|
# Create CIDR notation
|
||||||
|
cidr = if data['ip_version'] == 4
|
||||||
|
"#{network_start_ip}/#{data['network_prefix']}"
|
||||||
|
else
|
||||||
|
"#{network_start_ip}/#{data['network_prefix']}"
|
||||||
|
end
|
||||||
|
|
||||||
|
metadata = data['metadata'] || {}
|
||||||
|
|
||||||
|
{
|
||||||
|
network: cidr,
|
||||||
|
source: 'production_import',
|
||||||
|
asn: metadata['asn'],
|
||||||
|
asn_org: metadata['org'],
|
||||||
|
company: metadata['company_name'],
|
||||||
|
country: metadata['country_code'],
|
||||||
|
is_datacenter: metadata['is_datacenter'] || false,
|
||||||
|
is_proxy: metadata['is_proxy'] || false,
|
||||||
|
is_vpn: metadata['is_vpn'] || false,
|
||||||
|
abuser_scores: metadata['abuser_score'] ? { score: metadata['abuser_score'] } : nil,
|
||||||
|
additional_data: metadata.except('asn', 'org', 'company_name', 'country_code',
|
||||||
|
'is_datacenter', 'is_proxy', 'is_vpn', 'abuser_score').to_json
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.integer_to_ip(integer, version)
|
||||||
|
if version == 4
|
||||||
|
IPAddr.new(integer, Socket::AF_INET).to_s
|
||||||
|
else
|
||||||
|
# For IPv6, convert 128-bit integer
|
||||||
|
IPAddr.new(integer, Socket::AF_INET6).to_s
|
||||||
|
end
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to convert integer #{integer} to IP: #{e.message}"
|
||||||
|
"0.0.0.0"
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.import_batch(batch_data)
|
||||||
|
NetworkRange.insert_all(batch_data)
|
||||||
|
rescue => e
|
||||||
|
Rails.logger.error "Failed to import batch: #{e.message}"
|
||||||
|
|
||||||
|
# Fallback to individual imports
|
||||||
|
batch_data.each do |data|
|
||||||
|
begin
|
||||||
|
NetworkRange.create!(data)
|
||||||
|
rescue => individual_error
|
||||||
|
Rails.logger.error "Failed to import individual record: #{individual_error.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -71,10 +71,12 @@
|
|||||||
<span class="badge bg-secondary ms-1"><%= current_user.role %></span>
|
<span class="badge bg-secondary ms-1"><%= current_user.role %></span>
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
|
<li><%= link_to "Account Settings", edit_password_path, class: "dropdown-item" %></li>
|
||||||
<% if current_user_admin? %>
|
<% if current_user_admin? %>
|
||||||
<li><%= link_to "Manage Users", users_path, class: "dropdown-item" %></li>
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><%= link_to "Manage Users", users_path, class: "dropdown-item" %></li>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li><%= link_to "Sign Out", session_path, data: { turbo_method: :delete }, class: "dropdown-item" %></li>
|
<li><%= link_to "Sign Out", session_path, data: { turbo_method: :delete }, class: "dropdown-item" %></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
313
app/views/network_ranges/index.html.erb
Normal file
313
app/views/network_ranges/index.html.erb
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<% content_for :title, "Network Ranges - #{@project.name}" %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Network Ranges</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Browse and manage network ranges with intelligence data</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "IP Lookup", lookup_network_ranges_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 "Add Range", new_network_range_path, 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Filters -->
|
||||||
|
<% if params[:asn].present? || params[:country].present? || params[:company].present? || params[:datacenter].present? || params[:vpn].present? || params[:proxy].present? || params[:source].present? || params[:search].present? %>
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-blue-900">Active Filters</h3>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<% if params[:asn].present? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
ASN: <%= params[:asn] %>
|
||||||
|
<%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% if params[:country].present? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
Country: <%= params[:country] %>
|
||||||
|
<%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% if params[:company].present? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
Company: <%= params[:company] %>
|
||||||
|
<%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% if params[:datacenter].present? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
Datacenter
|
||||||
|
<%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% if params[:vpn].present? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
VPN
|
||||||
|
<%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% if params[:proxy].present? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
Proxy
|
||||||
|
<%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% if params[:source].present? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
Source: <%= params[:source] %>
|
||||||
|
<%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% if params[:search].present? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
Search: <%= params[:search] %>
|
||||||
|
<%= link_to "×", network_ranges_path, class: "ml-2 text-blue-600 hover:text-blue-800" %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= link_to "Clear All", network_ranges_path, class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||||
|
<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-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Total Ranges</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@total_ranges) %></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-400" 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">With Intelligence</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@ranges_with_intelligence) %></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-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Datacenters</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@datacenter_ranges) %></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-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">VPNs</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@vpn_ranges) %></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-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Proxies</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@proxy_ranges) %></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters and Search -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="p-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Filters & Search</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<%= form_with url: network_ranges_path, method: :get, class: "space-y-4" do |form| %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<%= form.label :search, "Search", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :search, value: params[:search], placeholder: "CIDR, Company, ASN...", class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :country, "Country", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :country, options_for_select([["All Countries", ""]] + @top_countries.map { |c, _| [c, c] }), { selected: params[:country] }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :source, options_for_select([["All Sources", ""], "production_import", "user_created", "api_imported", "manual", "auto:scanner_detected"], params[:source]), { }, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :flags, "Flags", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="mt-2 space-x-4">
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<%= check_box_tag "datacenter", "true", params[:datacenter] == "true", class: "rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50" %>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">Datacenter</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<%= check_box_tag "vpn", "true", params[:vpn] == "true", class: "rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50" %>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">VPN</span>
|
||||||
|
</label>
|
||||||
|
<label class="inline-flex items-center">
|
||||||
|
<%= check_box_tag "proxy", "true", params[:proxy] == "true", class: "rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50" %>
|
||||||
|
<span class="ml-2 text-sm text-gray-700">Proxy</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<%= form.submit "Apply Filters", 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" %>
|
||||||
|
<%= link_to "Clear", network_ranges_path, class: "ml-3 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>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Ranges Table -->
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Network Ranges</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Showing <%= @network_ranges.count %> of <%= number_with_delimiter(@total_ranges) %> ranges</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-gray-200">
|
||||||
|
<% @network_ranges.each do |range| %>
|
||||||
|
<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 space-x-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-sm font-medium text-gray-900"><%= range.cidr %></span>
|
||||||
|
<% if range.ipv4? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">IPv4</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">IPv6</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 flex items-center space-x-4 text-sm text-gray-500">
|
||||||
|
<% if range.company.present? %>
|
||||||
|
<span><%= range.company %></span>
|
||||||
|
<% end %>
|
||||||
|
<% if range.asn.present? %>
|
||||||
|
<span>ASN <%= range.asn %></span>
|
||||||
|
<% end %>
|
||||||
|
<% if range.country.present? %>
|
||||||
|
<span class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<%= range.country %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<span class="text-gray-400">Source: <%= range.source %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<% if range.is_datacenter? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">DC</span>
|
||||||
|
<% end %>
|
||||||
|
<% if range.is_vpn? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">VPN</span>
|
||||||
|
<% end %>
|
||||||
|
<% if range.is_proxy? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Proxy</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if range.rules.any? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= range.rules.count %> rule<%= 's' if range.rules.count > 1 %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to "View", network_range_path(range), class: "text-blue-600 hover:text-blue-900 text-sm font-medium" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<% if @network_ranges.empty? %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No network ranges found</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by importing network data or creating ranges manually.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<%= link_to "Add Network Range", new_network_range_path, class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<% if @pagy.present? %>
|
||||||
|
<div class="mt-6 flex justify-center">
|
||||||
|
<%= pagy_nav(@pagy) %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
131
app/views/network_ranges/lookup.html.erb
Normal file
131
app/views/network_ranges/lookup.html.erb
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<% content_for :title, "IP Address Lookup" %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">IP Address Lookup</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Lookup IP addresses to find matching network ranges and intelligence</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Form -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="p-6">
|
||||||
|
<%= form_with url: lookup_network_ranges_path, method: :get, local: true, class: "space-y-4" do |form| %>
|
||||||
|
<div>
|
||||||
|
<%= form.label :ip_address, "IP Address", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="mt-1 flex rounded-md shadow-sm">
|
||||||
|
<%= form.text_field :ip, value: params[:ip], placeholder: "Enter IP address (e.g., 8.8.8.8)", class: "flex-1 min-w-0 block w-full rounded-none rounded-l-md border-gray-300 focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<%= form.submit "Lookup", class: "-ml-px relative inline-flex items-center px-4 py-2 border border-transparent rounded-r-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if params[:ip].present? && @ranges %>
|
||||||
|
<!-- IP Intelligence -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">IP Intelligence: <%= params[:ip] %></h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-3">Matching Network Ranges</h4>
|
||||||
|
<% if @ranges.any? %>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% @ranges.each do |range| %>
|
||||||
|
<div class="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<%= link_to range.cidr, network_range_path(range), class: "text-sm font-medium text-blue-600 hover:text-blue-900" %>
|
||||||
|
<span class="text-xs text-gray-500">Priority: <%= range[:specificity] %></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% intel = range[:intelligence] %>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<% if intel[:company] %>
|
||||||
|
<div><span class="font-medium">Company:</span> <%= intel[:company] %></div>
|
||||||
|
<% end %>
|
||||||
|
<% if intel[:asn] %>
|
||||||
|
<div><span class="font-medium">ASN:</span> <%= intel[:asn] %> (<%= intel[:asn_org] %>)</div>
|
||||||
|
<% end %>
|
||||||
|
<% if intel[:country] %>
|
||||||
|
<div><span class="font-medium">Country:</span> <%= intel[:country] %></div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="flex space-x-2 pt-2">
|
||||||
|
<% if intel[:is_datacenter] %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-orange-100 text-orange-800">DC</span>
|
||||||
|
<% end %>
|
||||||
|
<% if intel[:is_vpn] %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800">VPN</span>
|
||||||
|
<% end %>
|
||||||
|
<% if intel[:is_proxy] %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800">Proxy</span>
|
||||||
|
<% end %>
|
||||||
|
<% if intel[:inherited] %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800">Inherited</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500">No network ranges found for this IP address.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-3">Suggested Block Ranges</h4>
|
||||||
|
<% if @suggested_blocks.any? %>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<% @suggested_blocks.each do |suggestion| %>
|
||||||
|
<div class="flex items-center justify-between p-3 border border-gray-200 rounded">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium"><%= suggestion[:cidr] %></div>
|
||||||
|
<div class="text-xs text-gray-500"><%= suggestion[:description] %></div>
|
||||||
|
</div>
|
||||||
|
<% if suggestion[:current_block] %>
|
||||||
|
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-red-100 text-red-800">Already Blocked</span>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to "Block Range", new_rule_path(cidr: suggestion[:cidr]), class: "inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-white bg-red-600 hover:bg-red-700" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-sm text-gray-500">No suggestions available.</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-white shadow rounded-lg p-6 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 15l-2 5L9 9l11 4-5 2z" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-lg font-medium text-gray-900">Quick Lookup</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Enter any IP address to instantly find matching network ranges</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg p-6 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" 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>
|
||||||
|
<h3 class="mt-2 text-lg font-medium text-gray-900">Network Intelligence</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">View company, ASN, country, and classification data</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg p-6 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 0A9 9 0 005.636 5.636m12.728 0L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-lg font-medium text-gray-900">Create Rules</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Block ranges or create surgical exceptions instantly</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
128
app/views/network_ranges/new.html.erb
Normal file
128
app/views/network_ranges/new.html.erb
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<% content_for :title, "Add Network Range" %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Add Network Range</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Create a new network range for tracking and rule management</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<%= form_with(model: @network_range, local: true, class: "space-y-6") do |form| %>
|
||||||
|
<% if @network_range.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" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<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(@network_range.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">
|
||||||
|
<% @network_range.errors.full_messages.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Network Information</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 space-y-6">
|
||||||
|
<div>
|
||||||
|
<%= form.label :network, "Network Range (CIDR)", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :network, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "e.g., 192.168.1.0/24 or 2001:db8::/32" %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Enter the network range in CIDR notation. Supports both IPv4 and IPv6.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :source, options_for_select([
|
||||||
|
["User Created", "user_created"],
|
||||||
|
["Manual Import", "manual"],
|
||||||
|
["API Imported", "api_imported"],
|
||||||
|
["Auto Generated", "auto_generated"]
|
||||||
|
], "user_created"), {}, { class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :creation_reason, "Creation Reason", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :creation_reason, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional reason for creating this network range" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Network Intelligence</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Optional intelligence data about this network range</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<%= form.label :asn, "ASN", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.number_field :asn, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "e.g., 15169" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :asn_org, "ASN Organization", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :asn_org, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "e.g., Google LLC" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :company, "Company", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :company, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "e.g., Google LLC" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :country, "Country Code", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_field :country, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "e.g., US" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
<%= form.check_box :is_datacenter, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :is_datacenter, "Datacenter Network", class: "text-sm font-medium text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
<%= form.check_box :is_vpn, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :is_vpn, "VPN Network", class: "text-sm font-medium text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
<%= form.check_box :is_proxy, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :is_proxy, "Proxy Network", class: "text-sm font-medium text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :abuser_scores, "Abuser Scores", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :abuser_scores, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: '{ "score": "0.001", "source": "api" }' %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">JSON format with abuser scoring information</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :additional_data, "Additional Data", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :additional_data, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: '{ "custom_field": "value" }' %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">JSON format for any additional metadata</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<%= link_to "Cancel", network_ranges_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 Network Range", 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
347
app/views/network_ranges/show.html.erb
Normal file
347
app/views/network_ranges/show.html.erb
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
<% content_for :title, "#{@network_range.cidr} - Network Range Details" %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<nav class="flex" aria-label="Breadcrumb">
|
||||||
|
<ol class="flex items-center space-x-4">
|
||||||
|
<li>
|
||||||
|
<%= link_to "Network Ranges", network_ranges_path, class: "text-gray-500 hover:text-gray-700" %>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="flex-shrink-0 h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span class="ml-4 text-gray-700 font-medium"><%= @network_range.cidr %></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="mt-2 flex items-center space-x-3">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900"><%= @network_range.cidr %></h1>
|
||||||
|
<% if @network_range.ipv4? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">IPv4</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">IPv6</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "Edit", edit_network_range_path(@network_range), 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 "Create Rule", new_rule_path(network_range_id: @network_range.id), 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Intelligence Card -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Network Intelligence</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Network Address</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900 font-mono"><%= @network_range.network_address %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Prefix Length</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">/<%= @network_range.prefix_length %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Family</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @network_range.ipv4? ? "IPv4" : "IPv6" %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @network_range.asn.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">ASN</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<%= link_to "#{@network_range.asn} (#{@network_range.asn_org})", network_ranges_path(asn: @network_range.asn),
|
||||||
|
class: "text-blue-600 hover:text-blue-900 hover:underline" %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @network_range.company.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Company</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<%= link_to @network_range.company, network_ranges_path(company: @network_range.company),
|
||||||
|
class: "text-blue-600 hover:text-blue-900 hover:underline" %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @network_range.country.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Country</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<%= link_to @network_range.country, network_ranges_path(country: @network_range.country),
|
||||||
|
class: "text-blue-600 hover:text-blue-900 hover:underline" %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Source</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @network_range.source %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= time_ago_in_words(@network_range.created_at) %> ago</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Updated</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= time_ago_in_words(@network_range.updated_at) %> ago</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Classification Flags -->
|
||||||
|
<div class="md:col-span-2 lg:col-span-3">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 mb-2">Classification</dt>
|
||||||
|
<dd class="flex flex-wrap gap-2">
|
||||||
|
<% if @network_range.is_datacenter? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-100 text-orange-800">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
||||||
|
</svg>
|
||||||
|
Datacenter
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% if @network_range.is_vpn? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
|
||||||
|
</svg>
|
||||||
|
VPN
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% if @network_range.is_proxy? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.894-.553l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Proxy
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<% if @network_range.abuser_scores_hash.any? %>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Abuser Score: <%= @network_range.abuser_scores_hash['score'] || 'Unknown' %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @network_range.additional_data_hash.any? %>
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<dt class="text-sm font-medium text-gray-500 mb-2">Additional Data</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<pre class="bg-gray-50 p-3 rounded-md text-xs overflow-x-auto"><%= JSON.pretty_generate(@network_range.additional_data_hash) %></pre>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Traffic Statistics -->
|
||||||
|
<% if @traffic_stats[:total_requests] > 0 %>
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Traffic Statistics</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-gray-900"><%= number_with_delimiter(@traffic_stats[:total_requests]) %></div>
|
||||||
|
<div class="text-sm text-gray-500">Total Requests</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-gray-900"><%= number_with_delimiter(@traffic_stats[:unique_ips]) %></div>
|
||||||
|
<div class="text-sm text-gray-500">Unique IPs</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-green-600"><%= number_with_delimiter(@traffic_stats[:allowed_requests]) %></div>
|
||||||
|
<div class="text-sm text-gray-500">Allowed</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-2xl font-bold text-red-600"><%= number_with_delimiter(@traffic_stats[:blocked_requests]) %></div>
|
||||||
|
<div class="text-sm text-gray-500">Blocked</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @traffic_stats[:top_paths].any? %>
|
||||||
|
<div class="border-t border-gray-200 pt-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-2">Top Paths</h4>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<% @traffic_stats[:top_paths].first(5).each do |path, count| %>
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-gray-600 truncate"><%= path %></span>
|
||||||
|
<span class="font-medium"><%= count %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Associated Rules -->
|
||||||
|
<% if @associated_rules.any? %>
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Associated Rules (<%= @associated_rules.count %>)</h3>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-200">
|
||||||
|
<% @associated_rules.each do |rule| %>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-sm font-medium text-gray-900">
|
||||||
|
<%= rule.action.upcase %> <%= rule.cidr %>
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
Priority: <%= rule.priority %>
|
||||||
|
</span>
|
||||||
|
<% if rule.source.include?('surgical') %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
||||||
|
Surgical
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-sm text-gray-500">
|
||||||
|
Created <%= time_ago_in_words(rule.created_at) %> ago by <%= rule.user&.email_address || 'System' %>
|
||||||
|
</div>
|
||||||
|
<% if rule.metadata&.dig('reason').present? %>
|
||||||
|
<div class="mt-1 text-sm text-gray-600">
|
||||||
|
Reason: <%= rule.metadata['reason'] %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<% if rule.enabled? %>
|
||||||
|
<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">Disabled</span>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to "View", rule_path(rule), class: "text-blue-600 hover:text-blue-900 text-sm font-medium" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Network Relationships -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
<!-- Parent Ranges -->
|
||||||
|
<% if @parent_ranges.any? %>
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Parent Network Ranges</h3>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-200">
|
||||||
|
<% @parent_ranges.each do |parent| %>
|
||||||
|
<div class="px-6 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<%= link_to parent.cidr, network_range_path(parent), class: "text-sm font-medium text-gray-900 hover:text-blue-600" %>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Prefix: /<%= parent.prefix_length %> |
|
||||||
|
<% if parent.company.present? %><%= parent.company %> | <% end %>
|
||||||
|
<%= parent.source %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Child Ranges -->
|
||||||
|
<% if @child_ranges.any? %>
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Child Network Ranges</h3>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-200">
|
||||||
|
<% @child_ranges.each do |child| %>
|
||||||
|
<div class="px-6 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<%= link_to child.cidr, network_range_path(child), class: "text-sm font-medium text-gray-900 hover:text-blue-600" %>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Prefix: /<%= child.prefix_length %> |
|
||||||
|
<% if child.company.present? %><%= child.company %> | <% end %>
|
||||||
|
<%= child.source %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Events -->
|
||||||
|
<% if @related_events.any? %>
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Recent Events (<%= @related_events.count %>)</h3>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<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">Time</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Path</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">User Agent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<% @related_events.first(20).each do |event| %>
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<%= event.timestamp.strftime("%H:%M:%S") %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||||
|
<%= event.ip_address %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<%= event.request_path || "-" %>
|
||||||
|
</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 <%= event.waf_action == 'deny' ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800' %>">
|
||||||
|
<%= event.waf_action %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm text-gray-900">
|
||||||
|
<div class="truncate max-w-xs" title="<%= event.user_agent %>">
|
||||||
|
<%= event.user_agent&.truncate(50) || "-" %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -1,21 +1,91 @@
|
|||||||
<div class="mx-auto md:w-2/3 w-full">
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
<% if alert = flash[:alert] %>
|
<% if alert = flash[:alert] %>
|
||||||
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||||
<% end %>
|
<%= alert %>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
<h1 class="font-bold text-4xl">Update your password</h1>
|
|
||||||
|
|
||||||
<%= form_with url: password_path(params[:token]), method: :put, class: "contents" do |form| %>
|
|
||||||
<div class="my-5">
|
|
||||||
<%= form.password_field :password, required: true, autocomplete: "new-password", placeholder: "Enter new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="my-5">
|
|
||||||
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", placeholder: "Repeat new password", maxlength: 72, class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inline">
|
|
||||||
<%= form.submit "Save", class: "w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title mb-0">Account Settings</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- User Info -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<h5>Profile Information</h5>
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tr>
|
||||||
|
<th>Email:</th>
|
||||||
|
<td><%= @user.email_address %></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Role:</th>
|
||||||
|
<td><span class="badge bg-<%= @user.admin? ? 'danger' : @user.viewer? ? 'info' : 'primary' %>"><%= @user.role %></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Authentication:</th>
|
||||||
|
<td>
|
||||||
|
<% if @user.password_digest.present? %>
|
||||||
|
<span class="badge bg-success">Local Password</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="badge bg-info">OIDC</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Active Sessions:</th>
|
||||||
|
<td><%= @user.sessions.count %></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Member Since:</th>
|
||||||
|
<td><%= @user.created_at.strftime('%B %d, %Y') %></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @user.password_digest.present? %>
|
||||||
|
<!-- Password Change Form -->
|
||||||
|
<div class="border-top pt-4">
|
||||||
|
<h5>Change Password</h5>
|
||||||
|
<p class="text-muted">Changing your password will log you out of all other devices.</p>
|
||||||
|
|
||||||
|
<%= form_with model: @user, url: password_path, method: :patch, class: "row g-3" do |form| %>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<%= form.label :current_password, "Current Password", class: "form-label" %>
|
||||||
|
<%= form.password_field :current_password, required: true, autocomplete: "current-password", class: "form-control" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<%= form.label :password, "New Password", class: "form-label" %>
|
||||||
|
<%= form.password_field :password, required: true, autocomplete: "new-password", minlength: 8, class: "form-control" %>
|
||||||
|
<div class="form-text">Minimum 8 characters</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<%= form.label :password_confirmation, "Confirm New Password", class: "form-label" %>
|
||||||
|
<%= form.password_field :password_confirmation, required: true, autocomplete: "new-password", minlength: 8, class: "form-control" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<%= form.submit "Update Password", class: "btn btn-primary" %>
|
||||||
|
<%= link_to "Cancel", root_path, class: "btn btn-secondary ms-2" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<!-- OIDC User Info -->
|
||||||
|
<div class="border-top pt-4">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6>🔐 OIDC Authentication</h6>
|
||||||
|
<p class="mb-0">Your account is managed through your OIDC provider. To change your password, please use your provider's account management tools.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<div class="mx-auto md:w-2/3 w-full">
|
|
||||||
<% if alert = flash[:alert] %>
|
|
||||||
<p class="py-2 px-3 bg-red-50 mb-5 text-red-500 font-medium rounded-lg inline-block" id="alert"><%= alert %></p>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<h1 class="font-bold text-4xl">Forgot your password?</h1>
|
|
||||||
|
|
||||||
<%= form_with url: passwords_path, class: "contents" do |form| %>
|
|
||||||
<div class="my-5">
|
|
||||||
<%= form.email_field :email_address, required: true, autofocus: true, autocomplete: "username", placeholder: "Enter your email address", value: params[:email_address], class: "block shadow-sm rounded-md border border-gray-400 focus:outline-solid focus:outline-blue-600 px-3 py-2 mt-2 w-full" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="inline">
|
|
||||||
<%= form.submit "Email reset instructions", class: "w-full sm:w-auto text-center rounded-lg px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<p>
|
|
||||||
You can reset your password on
|
|
||||||
<%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>.
|
|
||||||
|
|
||||||
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
|
||||||
</p>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
You can reset your password on
|
|
||||||
<%= edit_password_url(@user.password_reset_token) %>
|
|
||||||
|
|
||||||
This link will expire in <%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.
|
|
||||||
213
app/views/rules/edit.html.erb
Normal file
213
app/views/rules/edit.html.erb
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<% content_for :title, "Edit Rule ##{@rule.id}" %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Edit Rule #<%= @rule.id %></h1>
|
||||||
|
<p class="mt-2 text-gray-600">Modify the WAF rule configuration</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<%= form_with(model: @rule, local: true, class: "space-y-6") do |form| %>
|
||||||
|
<% if @rule.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" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<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(@rule.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">
|
||||||
|
<% @rule.errors.full_messages.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Rule Type Selection -->
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Rule Configuration</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<%= form.label :rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :rule_type,
|
||||||
|
options_for_select(@rule_types.map { |type| [type.humanize, type] }, @rule.rule_type),
|
||||||
|
{ prompt: "Select rule type" },
|
||||||
|
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
id: "rule_type_select",
|
||||||
|
disabled: true } %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Rule type cannot be changed after creation</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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] }, @rule.action),
|
||||||
|
{ },
|
||||||
|
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">What action to take when this rule matches</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Range Selection (shown for network rules) -->
|
||||||
|
<% if @rule.network_rule? %>
|
||||||
|
<div id="network_range_section">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<%= form.label :network_range_id, "Network Range", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :network_range_id,
|
||||||
|
options_from_collection_for_select(NetworkRange.order(:network).limit(100), :id, :cidr, @rule.network_range_id),
|
||||||
|
{ prompt: "Select a network range" },
|
||||||
|
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Select from recent network ranges or create new ones</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-end">
|
||||||
|
<%= link_to "Create New Network Range", new_network_range_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>
|
||||||
|
|
||||||
|
<% if @rule.network_range.present? %>
|
||||||
|
<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" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<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">
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
Currently targeting: <strong><%= link_to @rule.network_range.cidr, network_range_path(@rule.network_range), class: "text-blue-600 hover:text-blue-900 underline" %></strong>
|
||||||
|
<% if @rule.network_range.company.present? %>
|
||||||
|
- <%= @rule.network_range.company %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Conditions (shown for non-network rules) -->
|
||||||
|
<% unless @rule.network_rule? %>
|
||||||
|
<div id="conditions_section">
|
||||||
|
<div>
|
||||||
|
<%= form.label :conditions, "Conditions", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :conditions, rows: 4,
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
placeholder: '{"path_pattern": "/admin/*", "user_agent": "bot*"}' %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">JSON format with matching conditions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div data-controller="json-validator" data-json-validator-valid-class="json-valid" data-json-validator-invalid-class="json-invalid" data-json-validator-valid-status-class="json-valid-status" data-json-validator-invalid-status-class="json-invalid-status">
|
||||||
|
<%= form.label :metadata, "Metadata", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="relative">
|
||||||
|
<%= form.text_area :metadata, rows: 3,
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
placeholder: '{"reason": "Suspicious activity detected", "source": "manual"}',
|
||||||
|
data: { json_validator_target: "textarea", action: "input->json-validator#validate" } %>
|
||||||
|
<div class="mt-1 flex items-center justify-between">
|
||||||
|
<div data-json-validator-target="status" class="text-sm"></div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->json-validator#format"
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-700 underline">
|
||||||
|
Format JSON
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->json-validator#insertSample"
|
||||||
|
data-json-validator-json-sample='{"reason": "Block malicious ISP", "threat_type": "botnet", "confidence": "high", "source": "manual"}'
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-700 underline">
|
||||||
|
Insert Sample
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">JSON format with additional metadata</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<%= form.label :expires_at, "Expires At", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.datetime_local_field :expires_at,
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Leave blank for permanent rule</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :source,
|
||||||
|
options_for_select(Rule::SOURCES.map { |source| [source.humanize, source] }, @rule.source),
|
||||||
|
{ },
|
||||||
|
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">How this rule was created</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center pt-6">
|
||||||
|
<%= form.check_box :enabled, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :enabled, "Rule Enabled", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "Cancel", @rule, 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 class="flex space-x-3">
|
||||||
|
<%= link_to "Delete Rule", @rule,
|
||||||
|
method: :delete,
|
||||||
|
data: { confirm: "Are you sure you want to delete this rule? This action cannot be undone." },
|
||||||
|
class: "inline-flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-700 bg-red-50 hover:bg-red-100" %>
|
||||||
|
<%= form.submit "Update Rule", 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Show/hide sections based on rule type
|
||||||
|
const ruleTypeSelect = document.getElementById('rule_type_select');
|
||||||
|
const networkSection = document.getElementById('network_range_section');
|
||||||
|
const conditionsSection = document.getElementById('conditions_section');
|
||||||
|
|
||||||
|
function toggleSections() {
|
||||||
|
if (ruleTypeSelect && ruleTypeSelect.value === 'network') {
|
||||||
|
networkSection.classList.remove('hidden');
|
||||||
|
if (conditionsSection) conditionsSection.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
if (networkSection) networkSection.classList.add('hidden');
|
||||||
|
if (conditionsSection) conditionsSection.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ruleTypeSelect) {
|
||||||
|
ruleTypeSelect.addEventListener('change', toggleSections);
|
||||||
|
toggleSections(); // Initial state
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
224
app/views/rules/index.html.erb
Normal file
224
app/views/rules/index.html.erb
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<% content_for :title, "Rules - #{@project.name}" %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Rules</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Manage WAF rules for traffic filtering and control</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "Add Network Range", new_network_range_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 "Create Rule", new_rule_path, 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<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-gray-400" 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">Total Rules</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@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-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||||
|
</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"><%= number_with_delimiter(@rules.active.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-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">Block Rules</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@rules.where(action: 'deny').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-400" 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">Expired Rules</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900"><%= number_with_delimiter(@rules.expired.count) %></dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rules List -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">All Rules</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @rules.any? %>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<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">Rule</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">Action</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Target</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">Created</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<% @rules.each do |rule| %>
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-gray-900">
|
||||||
|
<%= link_to "Rule ##{rule.id}", rule_path(rule), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<%= rule.source.humanize %>
|
||||||
|
<% if rule.network_range? && rule.network_range %>
|
||||||
|
• <%= link_to rule.network_range.cidr, network_range_path(rule.network_range), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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 <%=
|
||||||
|
case rule.rule_type
|
||||||
|
when 'network' then 'bg-blue-100 text-blue-800'
|
||||||
|
when 'rate_limit' then 'bg-yellow-100 text-yellow-800'
|
||||||
|
when 'path_pattern' then 'bg-purple-100 text-purple-800'
|
||||||
|
else 'bg-gray-100 text-gray-800'
|
||||||
|
end %>">
|
||||||
|
<%= rule.rule_type.humanize %>
|
||||||
|
</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 <%=
|
||||||
|
case rule.action
|
||||||
|
when 'allow' then 'bg-green-100 text-green-800'
|
||||||
|
when 'deny' then 'bg-red-100 text-red-800'
|
||||||
|
when 'rate_limit' then 'bg-yellow-100 text-yellow-800'
|
||||||
|
when 'redirect' then 'bg-indigo-100 text-indigo-800'
|
||||||
|
when 'log' then 'bg-gray-100 text-gray-800'
|
||||||
|
else 'bg-gray-100 text-gray-800'
|
||||||
|
end %>">
|
||||||
|
<%= rule.action.upcase %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<% if rule.network_range? && rule.network_range %>
|
||||||
|
<%= rule.network_range.cidr %>
|
||||||
|
<% if rule.network_range.company.present? %>
|
||||||
|
<div class="text-xs text-gray-500"><%= rule.network_range.company %></div>
|
||||||
|
<% end %>
|
||||||
|
<% elsif rule.conditions.present? %>
|
||||||
|
<div class="max-w-xs truncate">
|
||||||
|
<%= JSON.parse(rule.conditions || "{}").map { |k, v| "#{k}: #{v}" }.join(", ") rescue "Invalid JSON" %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<% if rule.enabled? && !rule.expired? %>
|
||||||
|
<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>
|
||||||
|
<% elsif rule.expired? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Expired</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">Disabled</span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if rule.expires_at.present? %>
|
||||||
|
<span class="text-xs text-gray-500" title="Expires at <%= rule.expires_at.strftime('%Y-%m-%d %H:%M') %>">
|
||||||
|
<%= distance_of_time_in_words(Time.current, rule.expires_at) %> left
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<%= time_ago_in_words(rule.created_at) %> ago
|
||||||
|
<div class="text-xs">
|
||||||
|
by <%= rule.user&.email_address || 'System' %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<%= link_to "View", rule_path(rule), class: "text-blue-600 hover:text-blue-900 mr-3" %>
|
||||||
|
<% if rule.enabled? %>
|
||||||
|
<%= link_to "Disable", disable_rule_path(rule),
|
||||||
|
method: :post,
|
||||||
|
data: { confirm: "Are you sure you want to disable this rule?" },
|
||||||
|
class: "text-yellow-600 hover:text-yellow-900 mr-3" %>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to "Enable", enable_rule_path(rule),
|
||||||
|
method: :post,
|
||||||
|
class: "text-green-600 hover:text-green-900 mr-3" %>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to "Edit", edit_rule_path(rule), class: "text-indigo-600 hover:text-indigo-900" %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No rules</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by creating your first WAF rule.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<%= link_to "Create Rule", new_rule_path, class: "inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
372
app/views/rules/new.html.erb
Normal file
372
app/views/rules/new.html.erb
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
<% content_for :title, "Create New Rule" %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Create New Rule</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Create a WAF rule to allow, block, or rate limit traffic</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<%= form_with(model: @rule, local: true, class: "space-y-6") do |form| %>
|
||||||
|
<% if @rule.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" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<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(@rule.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">
|
||||||
|
<% @rule.errors.full_messages.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Rule Type Selection -->
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Rule Configuration</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<%= form.label :rule_type, "Rule Type", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :rule_type,
|
||||||
|
options_for_select(@rule_types.map { |type| [type.humanize, type] }, @rule.rule_type),
|
||||||
|
{ prompt: "Select rule type" },
|
||||||
|
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
id: "rule_type_select" } %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Choose the type of rule you want to create</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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] }, @rule.action),
|
||||||
|
{ prompt: "Select action" },
|
||||||
|
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">What action to take when this rule matches</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Range Selection (shown for network rules) -->
|
||||||
|
<div id="network_range_section" class="hidden">
|
||||||
|
<%= form.label :network_range_id, "Network Range", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||||
|
|
||||||
|
<!-- Selected Network Range Display -->
|
||||||
|
<div id="selected_network_display" class="hidden mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-medium text-blue-800">Selected Network Range</h4>
|
||||||
|
<div id="selected_network_info" class="mt-1 text-sm text-blue-700"></div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="clearSelectedNetwork()" class="text-blue-600 hover:text-blue-800">
|
||||||
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Selection Interface -->
|
||||||
|
<div id="network_selection_interface" class="space-y-4">
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div>
|
||||||
|
<input type="text"
|
||||||
|
id="network_search"
|
||||||
|
placeholder="Search by CIDR, IP, company, or ASN..."
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Search existing network ranges or enter a CIDR/IP address below</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Create Input -->
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<%= text_field_tag :new_cidr, params[:cidr],
|
||||||
|
placeholder: "e.g., 192.168.1.0/24 or 203.0.113.1",
|
||||||
|
class: "flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
id: "new_cidr_input" %>
|
||||||
|
<button type="button" onclick="quickCreateNetwork()"
|
||||||
|
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500">
|
||||||
|
Create & Select
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Results -->
|
||||||
|
<div id="network_search_results" class="hidden">
|
||||||
|
<div class="border rounded-md divide-y max-h-64 overflow-y-auto">
|
||||||
|
<!-- Results will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden field to store selected network range ID -->
|
||||||
|
<%= form.hidden_field :network_range_id, id: "selected_network_range_id", value: @rule.network_range_id %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conditions (shown for non-network rules) -->
|
||||||
|
<div id="conditions_section" class="hidden">
|
||||||
|
<div>
|
||||||
|
<%= form.label :conditions, "Conditions", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.text_area :conditions, rows: 4,
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
placeholder: '{"path_pattern": "/admin/*", "user_agent": "bot*"}' %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">JSON format with matching conditions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<div data-controller="json-validator" data-json-validator-valid-class="json-valid" data-json-validator-invalid-class="json-invalid" data-json-validator-valid-status-class="json-valid-status" data-json-validator-invalid-status-class="json-invalid-status">
|
||||||
|
<%= form.label :metadata, "Metadata", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<div class="relative">
|
||||||
|
<%= form.text_area :metadata, rows: 3,
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
|
||||||
|
placeholder: '{"reason": "Suspicious activity detected", "source": "manual"}',
|
||||||
|
data: { json_validator_target: "textarea", action: "input->json-validator#validate" } %>
|
||||||
|
<div class="mt-1 flex items-center justify-between">
|
||||||
|
<div data-json-validator-target="status" class="text-sm"></div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->json-validator#format"
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-700 underline">
|
||||||
|
Format JSON
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
data-action="click->json-validator#insertSample"
|
||||||
|
data-json-validator-json-sample='{"reason": "Block malicious ISP", "threat_type": "botnet", "confidence": "high", "source": "manual"}'
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-700 underline">
|
||||||
|
Insert Sample
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">JSON format with additional metadata</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<%= form.label :source, "Source", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.select :source,
|
||||||
|
options_for_select(Rule::SOURCES.map { |source| [source.humanize, source] }, @rule.source || "manual"),
|
||||||
|
{ },
|
||||||
|
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">How this rule was created</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= form.label :expires_at, "Expires At", class: "block text-sm font-medium text-gray-700" %>
|
||||||
|
<%= form.datetime_local_field :expires_at,
|
||||||
|
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Leave blank for permanent rule</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center pt-6">
|
||||||
|
<%= form.check_box :enabled, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
|
||||||
|
<%= form.label :enabled, "Enable immediately", class: "ml-2 block text-sm text-gray-900" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 bg-gray-50 border-t border-gray-200">
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<%= link_to "Cancel", rules_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 Rule", 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" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedNetworkData = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const ruleTypeSelect = document.getElementById('rule_type_select');
|
||||||
|
const networkSection = document.getElementById('network_range_section');
|
||||||
|
const conditionsSection = document.getElementById('conditions_section');
|
||||||
|
|
||||||
|
function toggleSections() {
|
||||||
|
if (ruleTypeSelect.value === 'network') {
|
||||||
|
networkSection.classList.remove('hidden');
|
||||||
|
conditionsSection.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
networkSection.classList.add('hidden');
|
||||||
|
conditionsSection.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ruleTypeSelect.addEventListener('change', toggleSections);
|
||||||
|
toggleSections(); // Initial state
|
||||||
|
|
||||||
|
// Pre-select network range if provided
|
||||||
|
<% if @rule.network_range.present? %>
|
||||||
|
// Show selected network display
|
||||||
|
const displayDiv = document.getElementById('selected_network_display');
|
||||||
|
const infoDiv = document.getElementById('selected_network_info');
|
||||||
|
const selectionInterface = document.getElementById('network_selection_interface');
|
||||||
|
|
||||||
|
let infoHtml = '<strong><%= @rule.network_range.network %></strong>';
|
||||||
|
<% if @rule.network_range.company.present? %>
|
||||||
|
infoHtml += ' - <%= @rule.network_range.company %>';
|
||||||
|
<% end %>
|
||||||
|
<% if @rule.network_range.asn_org.present? %>
|
||||||
|
infoHtml += ' (ASN: <%= @rule.network_range.asn_org %>)';
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
infoDiv.innerHTML = infoHtml;
|
||||||
|
displayDiv.classList.remove('hidden');
|
||||||
|
selectionInterface.classList.add('hidden');
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
// Pre-fill CIDR if provided
|
||||||
|
<% if params[:cidr].present? %>
|
||||||
|
if (ruleTypeSelect.value === 'network') {
|
||||||
|
document.getElementById('new_cidr_input').value = '<%= params[:cidr] %>';
|
||||||
|
}
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
// Set up search on Enter key
|
||||||
|
document.getElementById('network_search').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
searchNetworkRanges();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up quick create on Enter key
|
||||||
|
document.getElementById('new_cidr_input').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
quickCreateNetwork();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function searchNetworkRanges() {
|
||||||
|
const query = document.getElementById('network_search').value.trim();
|
||||||
|
if (!query) return;
|
||||||
|
|
||||||
|
const resultsDiv = document.getElementById('network_search_results');
|
||||||
|
resultsDiv.innerHTML = '<div class="p-4 text-center text-gray-500">Searching...</div>';
|
||||||
|
resultsDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
fetch(`/network_ranges/search?q=${encodeURIComponent(query)}`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.length === 0) {
|
||||||
|
resultsDiv.innerHTML = '<div class="p-4 text-center text-gray-500">No network ranges found. Try creating a new one below.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = data.map(network => `
|
||||||
|
<div class="p-3 hover:bg-gray-50 cursor-pointer flex justify-between items-center"
|
||||||
|
onclick="selectNetworkRange(${network.id}, '${network.network}', '${network.company || ''}', '${network.asn_org || ''}')">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">${network.network}</div>
|
||||||
|
${network.company ? `<div class="text-sm text-gray-600">${network.company}</div>` : ''}
|
||||||
|
${network.asn_org ? `<div class="text-sm text-gray-500">ASN: ${network.asn} - ${network.asn_org}</div>` : ''}
|
||||||
|
${network.country ? `<div class="text-sm text-gray-400">Country: ${network.country}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400">
|
||||||
|
${network.is_datacenter ? '<span class="bg-gray-100 px-2 py-1 rounded">DC</span>' : ''}
|
||||||
|
${network.is_vpn ? '<span class="bg-blue-100 px-2 py-1 rounded">VPN</span>' : ''}
|
||||||
|
${network.is_proxy ? '<span class="bg-red-100 px-2 py-1 rounded">Proxy</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
resultsDiv.innerHTML = '<div class="p-4 text-center text-red-500">Search failed. Please try again.</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectNetworkRange(id, network, company, asnOrg) {
|
||||||
|
selectedNetworkData = { id, network, company, asnOrg };
|
||||||
|
|
||||||
|
// Update hidden field
|
||||||
|
document.getElementById('selected_network_range_id').value = id;
|
||||||
|
|
||||||
|
// Update display
|
||||||
|
const displayDiv = document.getElementById('selected_network_display');
|
||||||
|
const infoDiv = document.getElementById('selected_network_info');
|
||||||
|
|
||||||
|
let infoHtml = `<strong>${network}</strong>`;
|
||||||
|
if (company) infoHtml += ` - ${company}`;
|
||||||
|
if (asnOrg) infoHtml += ` (ASN: ${asnOrg})`;
|
||||||
|
|
||||||
|
infoDiv.innerHTML = infoHtml;
|
||||||
|
displayDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Hide the entire selection interface
|
||||||
|
document.getElementById('network_selection_interface').classList.add('hidden');
|
||||||
|
|
||||||
|
// Clear search results
|
||||||
|
document.getElementById('network_search_results').classList.add('hidden');
|
||||||
|
document.getElementById('network_search').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectedNetwork() {
|
||||||
|
selectedNetworkData = null;
|
||||||
|
document.getElementById('selected_network_range_id').value = '';
|
||||||
|
document.getElementById('selected_network_display').classList.add('hidden');
|
||||||
|
|
||||||
|
// Show the selection interface again
|
||||||
|
document.getElementById('network_selection_interface').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickCreateNetwork() {
|
||||||
|
const cidr = document.getElementById('new_cidr_input').value.trim();
|
||||||
|
if (!cidr) {
|
||||||
|
alert('Please enter a CIDR or IP address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple CIDR validation
|
||||||
|
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}(\/\d{1,3})?$/;
|
||||||
|
if (!cidrRegex.test(cidr)) {
|
||||||
|
alert('Invalid CIDR or IP address format');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create network range via API
|
||||||
|
fetch('/network_ranges', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
network_range: {
|
||||||
|
network: cidr,
|
||||||
|
source: 'manual',
|
||||||
|
creation_reason: 'Created from rule form'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.id) {
|
||||||
|
selectNetworkRange(data.id, data.network, data.company, data.asn_org);
|
||||||
|
document.getElementById('new_cidr_input').value = '';
|
||||||
|
} else {
|
||||||
|
alert('Failed to create network range: ' + (data.error || 'Unknown error'));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Create error:', error);
|
||||||
|
alert('Failed to create network range. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
210
app/views/rules/show.html.erb
Normal file
210
app/views/rules/show.html.erb
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<% content_for :title, "Rule ##{@rule.id} - #{@rule.action.upcase}" %>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<nav class="flex" aria-label="Breadcrumb">
|
||||||
|
<ol class="flex items-center space-x-4">
|
||||||
|
<li>
|
||||||
|
<%= link_to "Rules", rules_path, class: "text-gray-500 hover:text-gray-700" %>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="flex-shrink-0 h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span class="ml-4 text-gray-700 font-medium">Rule #<%= @rule.id %></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="mt-2 flex items-center space-x-3">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Rule #<%= @rule.id %></h1>
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium <%=
|
||||||
|
case @rule.action
|
||||||
|
when 'allow' then 'bg-green-100 text-green-800'
|
||||||
|
when 'deny' then 'bg-red-100 text-red-800'
|
||||||
|
when 'rate_limit' then 'bg-yellow-100 text-yellow-800'
|
||||||
|
when 'redirect' then 'bg-indigo-100 text-indigo-800'
|
||||||
|
when 'log' then 'bg-gray-100 text-gray-800'
|
||||||
|
else 'bg-gray-100 text-gray-800'
|
||||||
|
end %>">
|
||||||
|
<%= @rule.action.upcase %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<%= link_to "Edit", edit_rule_path(@rule), 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" %>
|
||||||
|
<% if @rule.enabled? %>
|
||||||
|
<%= link_to "Disable", disable_rule_path(@rule),
|
||||||
|
method: :post,
|
||||||
|
data: { confirm: "Are you sure you want to disable this rule?" },
|
||||||
|
class: "inline-flex items-center px-4 py-2 border border-yellow-300 rounded-md shadow-sm text-sm font-medium text-yellow-700 bg-yellow-50 hover:bg-yellow-100" %>
|
||||||
|
<% else %>
|
||||||
|
<%= link_to "Enable", enable_rule_path(@rule),
|
||||||
|
method: :post,
|
||||||
|
class: "inline-flex items-center px-4 py-2 border border-green-300 rounded-md shadow-sm text-sm font-medium text-green-700 bg-green-50 hover:bg-green-100" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rule Details -->
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Rule Details</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Rule Type</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @rule.rule_type.humanize %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Action</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @rule.action.upcase %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Status</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<% if @rule.enabled? && !@rule.expired? %>
|
||||||
|
<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>
|
||||||
|
<% elsif @rule.expired? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Expired</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">Disabled</span>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Source</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @rule.source.humanize %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Priority</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @rule.priority %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @rule.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @rule.expires_at.present? %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Expires At</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @rule.expires_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Created By</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @rule.user&.email_address || 'System' %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @rule.updated_at != @rule.created_at %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Last Updated</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= @rule.updated_at.strftime("%Y-%m-%d %H:%M:%S UTC") %></dd>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Target Information -->
|
||||||
|
<% if @rule.network_rule? && @rule.network_range.present? %>
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Network Target</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="bg-gray-50 rounded-lg p-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-medium text-gray-900">
|
||||||
|
<%= link_to @rule.network_range.cidr, network_range_path(@rule.network_range), class: "text-blue-600 hover:text-blue-900" %>
|
||||||
|
</div>
|
||||||
|
<% if @rule.network_range.company.present? %>
|
||||||
|
<div class="text-sm text-gray-600"><%= @rule.network_range.company %></div>
|
||||||
|
<% end %>
|
||||||
|
<% if @rule.network_range.asn.present? %>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
ASN <%= @rule.network_range.asn %><% if @rule.network_range.asn_org.present? %> (<%= @rule.network_range.asn_org %>)<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @rule.network_range.country.present? %>
|
||||||
|
<div class="text-sm text-gray-500">Country: <%= @rule.network_range.country %></div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<% if @rule.network_range.is_datacenter? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">Datacenter</span>
|
||||||
|
<% end %>
|
||||||
|
<% if @rule.network_range.is_vpn? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">VPN</span>
|
||||||
|
<% end %>
|
||||||
|
<% if @rule.network_range.is_proxy? %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Proxy</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Conditions -->
|
||||||
|
<% if @rule.conditions.present? %>
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Conditions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<pre class="bg-gray-50 p-4 rounded-md text-sm overflow-x-auto"><%= JSON.pretty_generate(JSON.parse(@rule.conditions)) rescue @rule.conditions %></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<% if @rule.metadata.present? %>
|
||||||
|
<div class="bg-white shadow rounded-lg mb-6">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Metadata</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<pre class="bg-gray-50 p-4 rounded-md text-sm overflow-x-auto"><%= JSON.pretty_generate(JSON.parse(@rule.metadata)) rescue @rule.metadata %></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Rule Actions -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Actions</h3>
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<%= link_to "Edit Rule", edit_rule_path(@rule), 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" %>
|
||||||
|
|
||||||
|
<% if @rule.enabled? %>
|
||||||
|
<%= form_with(model: @rule, url: disable_rule_path(@rule), method: :post, class: "inline-flex") do |form| %>
|
||||||
|
<%= form.submit "Disable Rule", class: "inline-flex items-center px-4 py-2 border border-yellow-300 rounded-md shadow-sm text-sm font-medium text-yellow-700 bg-yellow-50 hover:bg-yellow-100 cursor-pointer" %>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<%= form_with(model: @rule, url: enable_rule_path(@rule), method: :post, class: "inline-flex") do |form| %>
|
||||||
|
<%= form.submit "Enable Rule", class: "inline-flex items-center px-4 py-2 border border-green-300 rounded-md shadow-sm text-sm font-medium text-green-700 bg-green-50 hover:bg-green-100 cursor-pointer" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to "View All Rules", rules_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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,54 +1,69 @@
|
|||||||
# SQLite. Versions 3.8.0 and up are supported.
|
# Primary database: PostgreSQL for network intelligence
|
||||||
# gem install sqlite3
|
# Cache/Queue/Cable: SQLite for auxiliary storage
|
||||||
#
|
|
||||||
# Ensure the SQLite 3 gem is defined in your Gemfile
|
# Default configuration for SQLite databases (cache/queue/cable)
|
||||||
# gem "sqlite3"
|
sqlite_default: &sqlite_default
|
||||||
#
|
|
||||||
default: &default
|
|
||||||
adapter: sqlite3
|
adapter: sqlite3
|
||||||
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
|
|
||||||
|
# Default configuration for PostgreSQL
|
||||||
|
postgres_default: &postgres_default
|
||||||
|
adapter: postgresql
|
||||||
|
encoding: unicode
|
||||||
|
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
|
||||||
|
host: localhost
|
||||||
|
port: 5432
|
||||||
|
|
||||||
development:
|
development:
|
||||||
primary:
|
primary:
|
||||||
<<: *default
|
<<: *postgres_default
|
||||||
database: storage/development.sqlite3
|
database: baffle_hub_development
|
||||||
cache:
|
cache:
|
||||||
<<: *default
|
<<: *sqlite_default
|
||||||
database: storage/development_cache.sqlite3
|
database: storage/development_cache.sqlite3
|
||||||
migrations_paths: db/cache_migrate
|
migrations_paths: db/cache_migrate
|
||||||
queue:
|
queue:
|
||||||
<<: *default
|
<<: *sqlite_default
|
||||||
database: storage/development_queue.sqlite3
|
database: storage/development_queue.sqlite3
|
||||||
migrations_paths: db/queue_migrate
|
migrations_paths: db/queue_migrate
|
||||||
cable:
|
cable:
|
||||||
<<: *default
|
<<: *sqlite_default
|
||||||
database: storage/development_cable.sqlite3
|
database: storage/development_cable.sqlite3
|
||||||
migrations_paths: db/cable_migrate
|
migrations_paths: db/cable_migrate
|
||||||
|
|
||||||
# Warning: The database defined as "test" will be erased and
|
|
||||||
# re-generated from your development database when you run "rake".
|
|
||||||
# Do not set this db to the same as development or production.
|
|
||||||
test:
|
test:
|
||||||
<<: *default
|
primary:
|
||||||
database: storage/test.sqlite3
|
<<: *postgres_default
|
||||||
|
database: baffle_hub_test
|
||||||
|
cache:
|
||||||
|
<<: *sqlite_default
|
||||||
|
database: storage/test_cache.sqlite3
|
||||||
|
migrations_paths: db/cache_migrate
|
||||||
|
queue:
|
||||||
|
<<: *sqlite_default
|
||||||
|
database: storage/test_queue.sqlite3
|
||||||
|
migrations_paths: db/queue_migrate
|
||||||
|
cable:
|
||||||
|
<<: *sqlite_default
|
||||||
|
database: storage/test_cable.sqlite3
|
||||||
|
migrations_paths: db/cable_migrate
|
||||||
|
|
||||||
|
|
||||||
# Store production database in the storage/ directory, which by default
|
|
||||||
# is mounted as a persistent Docker volume in config/deploy.yml.
|
|
||||||
production:
|
production:
|
||||||
primary:
|
primary:
|
||||||
<<: *default
|
<<: *postgres_default
|
||||||
database: storage/production.sqlite3
|
database: baffle_hub_production
|
||||||
|
username: baffle_hub
|
||||||
|
password: <%= ENV["BAFFLE_HUB_DATABASE_PASSWORD"] %>
|
||||||
cache:
|
cache:
|
||||||
<<: *default
|
<<: *sqlite_default
|
||||||
database: storage/production_cache.sqlite3
|
database: storage/production_cache.sqlite3
|
||||||
migrations_paths: db/cache_migrate
|
migrations_paths: db/cache_migrate
|
||||||
queue:
|
queue:
|
||||||
<<: *default
|
<<: *sqlite_default
|
||||||
database: storage/production_queue.sqlite3
|
database: storage/production_queue.sqlite3
|
||||||
migrations_paths: db/queue_migrate
|
migrations_paths: db/queue_migrate
|
||||||
cable:
|
cable:
|
||||||
<<: *default
|
<<: *sqlite_default
|
||||||
database: storage/production_cable.sqlite3
|
database: storage/production_cable.sqlite3
|
||||||
migrations_paths: db/cable_migrate
|
migrations_paths: db/cable_migrate
|
||||||
@@ -2,7 +2,7 @@ Rails.application.routes.draw do
|
|||||||
# Registration only allowed when no users exist
|
# Registration only allowed when no users exist
|
||||||
resource :registration, only: [:new, :create]
|
resource :registration, only: [:new, :create]
|
||||||
resource :session
|
resource :session
|
||||||
resources :passwords, param: :token
|
resource :password
|
||||||
|
|
||||||
# OIDC authentication routes
|
# OIDC authentication routes
|
||||||
get "/auth/failure", to: "omniauth_callbacks#failure"
|
get "/auth/failure", to: "omniauth_callbacks#failure"
|
||||||
@@ -39,6 +39,20 @@ Rails.application.routes.draw do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Network range management
|
||||||
|
resources :network_ranges, only: [:index, :show, :new, :create, :edit, :update, :destroy] do
|
||||||
|
member do
|
||||||
|
post :enrich
|
||||||
|
end
|
||||||
|
collection do
|
||||||
|
get :lookup
|
||||||
|
get :search
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Support CIDR patterns with dots in network range routes
|
||||||
|
get '/network_ranges/:id', to: 'network_ranges#show', constraints: { id: /[\d\.:\/_]+/ }
|
||||||
|
|
||||||
# Rule management
|
# Rule management
|
||||||
resources :rules, only: [:index, :new, :create, :show, :edit, :update] do
|
resources :rules, only: [:index, :new, :create, :show, :edit, :update] do
|
||||||
member do
|
member do
|
||||||
|
|||||||
22
db/migrate/001_create_projects.rb
Normal file
22
db/migrate/001_create_projects.rb
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateProjects < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :projects, force: :cascade do |t|
|
||||||
|
t.string :name, null: false, index: true
|
||||||
|
t.string :slug, null: false, index: { unique: true }
|
||||||
|
t.string :public_key, null: false, index: { unique: true }
|
||||||
|
t.boolean :enabled, default: true, null: false, index: true
|
||||||
|
|
||||||
|
# WAF settings
|
||||||
|
t.integer :rate_limit_threshold, default: 100, null: false
|
||||||
|
t.text :settings, default: "{}", null: false
|
||||||
|
t.text :custom_rules, default: "{}", null: false
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
t.integer :blocked_ip_count, default: 0, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
13
db/migrate/002_create_users.rb
Normal file
13
db/migrate/002_create_users.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateUsers < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :users, force: :cascade do |t|
|
||||||
|
t.string :email_address, null: false, index: { unique: true }
|
||||||
|
t.string :password_digest, null: false
|
||||||
|
t.integer :role, default: 1, null: false # 1=admin, 2=user
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
43
db/migrate/003_create_network_ranges.rb
Normal file
43
db/migrate/003_create_network_ranges.rb
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateNetworkRanges < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :network_ranges, force: :cascade do |t|
|
||||||
|
# Postgres inet type handles both IPv4 and IPv6 networks
|
||||||
|
t.inet :network, null: false, index: { unique: true, name: 'index_network_ranges_on_network_unique' }
|
||||||
|
|
||||||
|
|
||||||
|
# Track the source of this network range
|
||||||
|
t.string :source, default: 'api_imported', null: false, index: true
|
||||||
|
t.text :creation_reason
|
||||||
|
|
||||||
|
# Network intelligence metadata
|
||||||
|
t.integer :asn, index: true
|
||||||
|
t.string :asn_org, index: true
|
||||||
|
t.string :company, index: true
|
||||||
|
t.string :country, index: true
|
||||||
|
|
||||||
|
# Network classification flags
|
||||||
|
t.boolean :is_datacenter, default: false, index: true
|
||||||
|
t.boolean :is_proxy, default: false
|
||||||
|
t.boolean :is_vpn, default: false
|
||||||
|
t.index [:is_datacenter, :is_proxy, :is_vpn], name: 'idx_network_flags'
|
||||||
|
|
||||||
|
# JSON fields for additional data
|
||||||
|
t.text :abuser_scores
|
||||||
|
t.text :additional_data
|
||||||
|
|
||||||
|
# API enrichment tracking
|
||||||
|
t.datetime :last_api_fetch
|
||||||
|
|
||||||
|
# Track creation (optional - some ranges are auto-imported)
|
||||||
|
t.references :user, foreign_key: true
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
|
||||||
|
# Postgres network indexes for performance
|
||||||
|
# GiST index for network containment operations (>>=, <<=, &&)
|
||||||
|
t.index :network, using: :gist, opclass: :inet_ops
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
44
db/migrate/004_create_request_optimization_tables.rb
Normal file
44
db/migrate/004_create_request_optimization_tables.rb
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateRequestOptimizationTables < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
# Path segments for compression and analytics
|
||||||
|
create_table :path_segments, force: :cascade do |t|
|
||||||
|
t.string :segment, null: false, index: { unique: true }
|
||||||
|
t.integer :usage_count, default: 1, null: false
|
||||||
|
t.datetime :first_seen_at, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Request hosts for compression and analytics
|
||||||
|
create_table :request_hosts, force: :cascade do |t|
|
||||||
|
t.string :hostname, null: false, index: { unique: true }
|
||||||
|
t.integer :usage_count, default: 1, null: false
|
||||||
|
t.datetime :first_seen_at, null: false
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Request methods for normalization
|
||||||
|
create_table :request_methods, force: :cascade do |t|
|
||||||
|
t.string :method, null: false, index: { unique: true }
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Request protocols for normalization
|
||||||
|
create_table :request_protocols, force: :cascade do |t|
|
||||||
|
t.string :protocol, null: false, index: { unique: true }
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
# Request actions for normalization
|
||||||
|
create_table :request_actions, force: :cascade do |t|
|
||||||
|
t.string :action, null: false, index: { unique: true }
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
36
db/migrate/005_create_rules.rb
Normal file
36
db/migrate/005_create_rules.rb
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateRules < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :rules, force: :cascade do |t|
|
||||||
|
# Rule classification
|
||||||
|
t.string :rule_type, null: false, index: true
|
||||||
|
t.string :action, null: false, index: true
|
||||||
|
t.string :source, limit: 100, default: 'manual', index: true
|
||||||
|
|
||||||
|
# Priority for rule evaluation (higher = more specific)
|
||||||
|
t.integer :priority, index: true
|
||||||
|
|
||||||
|
# Rule conditions (JSON for flexibility)
|
||||||
|
t.json :conditions, default: {}
|
||||||
|
|
||||||
|
# Rule metadata (JSON for extensibility)
|
||||||
|
t.json :metadata, default: {}
|
||||||
|
|
||||||
|
# Rule lifecycle
|
||||||
|
t.boolean :enabled, default: true, null: false, index: true
|
||||||
|
t.datetime :expires_at, index: true
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
t.references :user, foreign_key: true
|
||||||
|
t.references :network_range, foreign_key: true
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
|
||||||
|
# Composite indexes for common queries
|
||||||
|
t.index [:rule_type, :enabled], name: 'idx_rules_type_enabled'
|
||||||
|
t.index [:enabled, :expires_at], name: 'idx_rules_active'
|
||||||
|
t.index [:updated_at, :id], name: 'idx_rules_sync'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
60
db/migrate/006_create_events.rb
Normal file
60
db/migrate/006_create_events.rb
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateEvents < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :events, force: :cascade do |t|
|
||||||
|
# Core event identification
|
||||||
|
t.string :event_id, null: false, index: { unique: true }
|
||||||
|
t.references :project, null: false, foreign_key: true, index: true
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
t.datetime :timestamp, null: false, index: true
|
||||||
|
|
||||||
|
# WAF evaluation
|
||||||
|
t.integer :waf_action, default: 0, null: false, index: true
|
||||||
|
t.string :rule_matched
|
||||||
|
t.text :blocked_reason
|
||||||
|
|
||||||
|
# Request metadata
|
||||||
|
t.inet :ip_address, index: true
|
||||||
|
t.string :user_agent
|
||||||
|
t.string :request_url
|
||||||
|
t.string :request_path
|
||||||
|
t.string :request_protocol
|
||||||
|
t.integer :request_method, default: 0
|
||||||
|
|
||||||
|
# Response metadata
|
||||||
|
t.integer :response_status
|
||||||
|
t.integer :response_time_ms
|
||||||
|
|
||||||
|
# Geographic data
|
||||||
|
t.string :country_code
|
||||||
|
t.string :city
|
||||||
|
|
||||||
|
# Server/Environment info
|
||||||
|
t.string :server_name
|
||||||
|
t.string :environment
|
||||||
|
|
||||||
|
# WAF agent info
|
||||||
|
t.string :agent_name
|
||||||
|
t.string :agent_version
|
||||||
|
|
||||||
|
# Normalized relationships for analytics
|
||||||
|
t.references :request_host, foreign_key: true, index: true
|
||||||
|
t.string :request_segment_ids # JSON array of path segment IDs
|
||||||
|
|
||||||
|
# Full event payload
|
||||||
|
t.json :payload
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
|
||||||
|
# Composite indexes for analytics queries
|
||||||
|
t.index [:project_id, :timestamp], name: 'idx_events_project_time'
|
||||||
|
t.index [:project_id, :waf_action], name: 'idx_events_project_action'
|
||||||
|
t.index [:project_id, :ip_address], name: 'idx_events_project_ip'
|
||||||
|
t.index [:request_host_id, :request_method, :request_segment_ids],
|
||||||
|
name: 'idx_events_host_method_path'
|
||||||
|
t.index :request_segment_ids
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
13
db/migrate/007_create_sessions.rb
Normal file
13
db/migrate/007_create_sessions.rb
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateSessions < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :sessions, force: :cascade do |t|
|
||||||
|
t.references :user, null: false, foreign_key: true, index: true
|
||||||
|
t.inet :ip_address
|
||||||
|
t.string :user_agent
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
class CreateNetworkRanges < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
create_table :network_ranges do |t|
|
|
||||||
t.binary :ip_address, null: false
|
|
||||||
t.integer :network_prefix, null: false
|
|
||||||
t.integer :ip_version, null: false
|
|
||||||
t.string :company
|
|
||||||
t.integer :asn
|
|
||||||
t.string :asn_org
|
|
||||||
t.boolean :is_datacenter, default: false
|
|
||||||
t.boolean :is_proxy, default: false
|
|
||||||
t.boolean :is_vpn, default: false
|
|
||||||
t.string :ip_api_country
|
|
||||||
t.string :geo2_country
|
|
||||||
t.text :abuser_scores
|
|
||||||
t.text :additional_data
|
|
||||||
t.timestamp :last_api_fetch
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
# Indexes for common queries
|
|
||||||
add_index :network_ranges, [:ip_address, :network_prefix], name: 'idx_network_ranges_ip_range'
|
|
||||||
add_index :network_ranges, :asn, name: 'idx_network_ranges_asn'
|
|
||||||
add_index :network_ranges, :company, name: 'idx_network_ranges_company'
|
|
||||||
add_index :network_ranges, :ip_api_country, name: 'idx_network_ranges_country'
|
|
||||||
add_index :network_ranges, [:is_datacenter, :is_proxy, :is_vpn], name: 'idx_network_ranges_flags'
|
|
||||||
add_index :network_ranges, :ip_version, name: 'idx_network_ranges_version'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
class CreateProjects < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
create_table :projects do |t|
|
|
||||||
t.string :name, null: false
|
|
||||||
t.string :slug, null: false
|
|
||||||
t.string :public_key, null: false
|
|
||||||
t.boolean :enabled, default: true, null: false
|
|
||||||
t.integer :rate_limit_threshold, default: 100, null: false
|
|
||||||
t.integer :blocked_ip_count, default: 0, null: false
|
|
||||||
t.text :custom_rules, default: "{}", null: false
|
|
||||||
t.text :settings, default: "{}", null: false
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
add_index :projects, :slug, unique: true
|
|
||||||
add_index :projects, :public_key, unique: true
|
|
||||||
add_index :projects, :enabled
|
|
||||||
add_index :projects, :name
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
class CreateEvents < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
create_table :events do |t|
|
|
||||||
t.references :project, null: false, foreign_key: true
|
|
||||||
t.string :event_id, null: false
|
|
||||||
t.datetime :timestamp, null: false
|
|
||||||
t.string :action
|
|
||||||
t.string :ip_address
|
|
||||||
t.text :user_agent
|
|
||||||
t.string :request_method
|
|
||||||
t.string :request_path
|
|
||||||
t.string :request_url
|
|
||||||
t.string :request_protocol
|
|
||||||
t.integer :response_status
|
|
||||||
t.integer :response_time_ms
|
|
||||||
t.string :rule_matched
|
|
||||||
t.text :blocked_reason
|
|
||||||
t.string :server_name
|
|
||||||
t.string :environment
|
|
||||||
t.string :country_code
|
|
||||||
t.string :city
|
|
||||||
t.string :agent_version
|
|
||||||
t.string :agent_name
|
|
||||||
t.json :payload
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
add_index :events, :event_id, unique: true
|
|
||||||
add_index :events, :timestamp
|
|
||||||
add_index :events, [:project_id, :timestamp]
|
|
||||||
add_index :events, [:project_id, :action]
|
|
||||||
add_index :events, [:project_id, :ip_address]
|
|
||||||
add_index :events, :ip_address
|
|
||||||
add_index :events, :action
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
class CreateRuleSets < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
create_table :rule_sets do |t|
|
|
||||||
t.string :name
|
|
||||||
t.text :description
|
|
||||||
t.boolean :enabled
|
|
||||||
t.json :projects
|
|
||||||
t.json :rules
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
class CreateRules < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
create_table :rules do |t|
|
|
||||||
t.references :rule_set, null: false, foreign_key: true
|
|
||||||
t.string :rule_type
|
|
||||||
t.string :target
|
|
||||||
t.string :action
|
|
||||||
t.boolean :enabled
|
|
||||||
t.datetime :expires_at
|
|
||||||
t.integer :priority
|
|
||||||
t.json :conditions
|
|
||||||
t.json :metadata
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
class AddFieldsToRuleSets < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
add_column :rule_sets, :slug, :string
|
|
||||||
add_column :rule_sets, :priority, :integer
|
|
||||||
add_column :rule_sets, :projects_subscription, :json
|
|
||||||
|
|
||||||
add_index :rule_sets, :slug, unique: true
|
|
||||||
add_index :rule_sets, :enabled
|
|
||||||
add_index :rule_sets, :priority
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
class AddSimpleEventNormalization < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
# Add foreign key for hosts (most valuable normalization)
|
|
||||||
add_column :events, :request_host_id, :integer
|
|
||||||
add_foreign_key :events, :request_hosts
|
|
||||||
add_index :events, :request_host_id
|
|
||||||
|
|
||||||
# Add path segment storage as string for LIKE queries
|
|
||||||
add_column :events, :request_segment_ids, :string
|
|
||||||
add_index :events, :request_segment_ids
|
|
||||||
|
|
||||||
# Add composite index for common WAF queries using enums
|
|
||||||
add_index :events, [:request_host_id, :request_method, :request_segment_ids], name: 'idx_events_host_method_path'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
class RenameActionToWafActionInEvents < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
rename_column :events, :action, :waf_action
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
class EnhanceRulesTableForSync < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
# Remove rule_sets relationship (we're skipping rule sets for Phase 1)
|
|
||||||
if foreign_key_exists?(:rules, :rule_sets)
|
|
||||||
remove_foreign_key :rules, :rule_sets
|
|
||||||
end
|
|
||||||
|
|
||||||
if column_exists?(:rules, :rule_set_id)
|
|
||||||
remove_column :rules, :rule_set_id
|
|
||||||
end
|
|
||||||
|
|
||||||
change_table :rules do |t|
|
|
||||||
# Add source field to track rule origin
|
|
||||||
unless column_exists?(:rules, :source)
|
|
||||||
t.string :source, limit: 100
|
|
||||||
end
|
|
||||||
|
|
||||||
# Ensure core fields exist with proper types
|
|
||||||
unless column_exists?(:rules, :rule_type)
|
|
||||||
t.string :rule_type, null: false
|
|
||||||
end
|
|
||||||
|
|
||||||
unless column_exists?(:rules, :action)
|
|
||||||
t.string :action, null: false
|
|
||||||
end
|
|
||||||
|
|
||||||
unless column_exists?(:rules, :conditions)
|
|
||||||
t.json :conditions, null: false, default: {}
|
|
||||||
end
|
|
||||||
|
|
||||||
unless column_exists?(:rules, :metadata)
|
|
||||||
t.json :metadata, default: {}
|
|
||||||
end
|
|
||||||
|
|
||||||
unless column_exists?(:rules, :priority)
|
|
||||||
t.integer :priority
|
|
||||||
end
|
|
||||||
|
|
||||||
unless column_exists?(:rules, :expires_at)
|
|
||||||
t.datetime :expires_at
|
|
||||||
end
|
|
||||||
|
|
||||||
unless column_exists?(:rules, :enabled)
|
|
||||||
t.boolean :enabled, default: true, null: false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Add indexes for efficient sync queries
|
|
||||||
add_index :rules, [:updated_at, :id], if_not_exists: true, name: "idx_rules_sync"
|
|
||||||
add_index :rules, :enabled, if_not_exists: true
|
|
||||||
add_index :rules, :expires_at, if_not_exists: true
|
|
||||||
add_index :rules, :source, if_not_exists: true
|
|
||||||
add_index :rules, :rule_type, if_not_exists: true
|
|
||||||
add_index :rules, [:rule_type, :enabled], if_not_exists: true, name: "idx_rules_type_enabled"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
class SplitNetworkRangesIntoIpv4AndIpv6 < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
# Drop the old network_ranges table (no data to preserve)
|
|
||||||
drop_table :network_ranges, if_exists: true
|
|
||||||
|
|
||||||
# Create optimized IPv4 ranges table
|
|
||||||
create_table :ipv4_ranges do |t|
|
|
||||||
# Range fields for fast lookups
|
|
||||||
t.integer :network_start, limit: 8, null: false
|
|
||||||
t.integer :network_end, limit: 8, null: false
|
|
||||||
t.integer :network_prefix, null: false
|
|
||||||
|
|
||||||
# IP intelligence metadata
|
|
||||||
t.string :company
|
|
||||||
t.integer :asn
|
|
||||||
t.string :asn_org
|
|
||||||
t.boolean :is_datacenter, default: false
|
|
||||||
t.boolean :is_proxy, default: false
|
|
||||||
t.boolean :is_vpn, default: false
|
|
||||||
t.string :ip_api_country
|
|
||||||
t.string :geo2_country
|
|
||||||
t.text :abuser_scores
|
|
||||||
t.text :additional_data
|
|
||||||
t.timestamp :last_api_fetch
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
# Optimized indexes for IPv4
|
|
||||||
add_index :ipv4_ranges, [:network_start, :network_end, :network_prefix],
|
|
||||||
name: "idx_ipv4_range_lookup"
|
|
||||||
add_index :ipv4_ranges, :asn, name: "idx_ipv4_asn"
|
|
||||||
add_index :ipv4_ranges, :company, name: "idx_ipv4_company"
|
|
||||||
add_index :ipv4_ranges, :ip_api_country, name: "idx_ipv4_country"
|
|
||||||
add_index :ipv4_ranges, [:is_datacenter, :is_proxy, :is_vpn],
|
|
||||||
name: "idx_ipv4_flags"
|
|
||||||
|
|
||||||
# Create optimized IPv6 ranges table
|
|
||||||
create_table :ipv6_ranges do |t|
|
|
||||||
# Range fields for fast lookups (binary for 128-bit addresses)
|
|
||||||
t.binary :network_start, limit: 16, null: false
|
|
||||||
t.binary :network_end, limit: 16, null: false
|
|
||||||
t.integer :network_prefix, null: false
|
|
||||||
|
|
||||||
# IP intelligence metadata (same as IPv4)
|
|
||||||
t.string :company
|
|
||||||
t.integer :asn
|
|
||||||
t.string :asn_org
|
|
||||||
t.boolean :is_datacenter, default: false
|
|
||||||
t.boolean :is_proxy, default: false
|
|
||||||
t.boolean :is_vpn, default: false
|
|
||||||
t.string :ip_api_country
|
|
||||||
t.string :geo2_country
|
|
||||||
t.text :abuser_scores
|
|
||||||
t.text :additional_data
|
|
||||||
t.timestamp :last_api_fetch
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
# Optimized indexes for IPv6
|
|
||||||
add_index :ipv6_ranges, [:network_start, :network_end, :network_prefix],
|
|
||||||
name: "idx_ipv6_range_lookup"
|
|
||||||
add_index :ipv6_ranges, :asn, name: "idx_ipv6_asn"
|
|
||||||
add_index :ipv6_ranges, :company, name: "idx_ipv6_company"
|
|
||||||
add_index :ipv6_ranges, :ip_api_country, name: "idx_ipv6_country"
|
|
||||||
add_index :ipv6_ranges, [:is_datacenter, :is_proxy, :is_vpn],
|
|
||||||
name: "idx_ipv6_flags"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
class CreateGeoIpDatabases < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
create_table :geo_ip_databases do |t|
|
|
||||||
t.string :database_type
|
|
||||||
t.string :version
|
|
||||||
t.string :file_path
|
|
||||||
t.integer :file_size
|
|
||||||
t.string :checksum_md5
|
|
||||||
t.datetime :downloaded_at
|
|
||||||
t.datetime :last_checked_at
|
|
||||||
t.boolean :is_active
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
class DropGeoIpDatabasesTable < ActiveRecord::Migration[8.1]
|
|
||||||
def up
|
|
||||||
drop_table :geo_ip_databases
|
|
||||||
end
|
|
||||||
|
|
||||||
def down
|
|
||||||
create_table :geo_ip_databases do |t|
|
|
||||||
t.string :database_type, null: false
|
|
||||||
t.string :version, null: false
|
|
||||||
t.string :file_path, null: false
|
|
||||||
t.integer :file_size, null: false
|
|
||||||
t.string :checksum_md5, null: false
|
|
||||||
t.datetime :downloaded_at, null: false
|
|
||||||
t.datetime :last_checked_at
|
|
||||||
t.boolean :is_active, default: true
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
add_index :geo_ip_databases, :is_active
|
|
||||||
add_index :geo_ip_databases, :database_type
|
|
||||||
add_index :geo_ip_databases, :file_path, unique: true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
class ChangeRequestMethodToIntegerInEvents < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
# Convert enum columns from string to integer for proper enum support
|
|
||||||
reversible do |dir|
|
|
||||||
dir.up do
|
|
||||||
# Map request_method string values to enum integers
|
|
||||||
execute <<-SQL
|
|
||||||
UPDATE events
|
|
||||||
SET request_method = CASE
|
|
||||||
WHEN LOWER(request_method) = 'get' THEN '0'
|
|
||||||
WHEN LOWER(request_method) = 'post' THEN '1'
|
|
||||||
WHEN LOWER(request_method) = 'put' THEN '2'
|
|
||||||
WHEN LOWER(request_method) = 'patch' THEN '3'
|
|
||||||
WHEN LOWER(request_method) = 'delete' THEN '4'
|
|
||||||
WHEN LOWER(request_method) = 'head' THEN '5'
|
|
||||||
WHEN LOWER(request_method) = 'options' THEN '6'
|
|
||||||
ELSE '0' -- Default to GET for unknown values
|
|
||||||
END
|
|
||||||
WHERE request_method IS NOT NULL;
|
|
||||||
SQL
|
|
||||||
|
|
||||||
# Map waf_action string values to enum integers
|
|
||||||
execute <<-SQL
|
|
||||||
UPDATE events
|
|
||||||
SET waf_action = CASE
|
|
||||||
WHEN LOWER(waf_action) = 'allow' THEN '0'
|
|
||||||
WHEN LOWER(waf_action) IN ('deny', 'block') THEN '1'
|
|
||||||
WHEN LOWER(waf_action) = 'redirect' THEN '2'
|
|
||||||
WHEN LOWER(waf_action) = 'challenge' THEN '3'
|
|
||||||
ELSE '0' -- Default to allow for unknown values
|
|
||||||
END
|
|
||||||
WHERE waf_action IS NOT NULL;
|
|
||||||
SQL
|
|
||||||
|
|
||||||
# Change column types to integer
|
|
||||||
change_column :events, :request_method, :integer
|
|
||||||
change_column :events, :waf_action, :integer
|
|
||||||
end
|
|
||||||
|
|
||||||
dir.down do
|
|
||||||
# Convert back to string values
|
|
||||||
change_column :events, :request_method, :string
|
|
||||||
change_column :events, :waf_action, :string
|
|
||||||
|
|
||||||
execute <<-SQL
|
|
||||||
UPDATE events
|
|
||||||
SET request_method = CASE request_method
|
|
||||||
WHEN 0 THEN 'get'
|
|
||||||
WHEN 1 THEN 'post'
|
|
||||||
WHEN 2 THEN 'put'
|
|
||||||
WHEN 3 THEN 'patch'
|
|
||||||
WHEN 4 THEN 'delete'
|
|
||||||
WHEN 5 THEN 'head'
|
|
||||||
WHEN 6 THEN 'options'
|
|
||||||
ELSE 'get' -- Default to GET for unknown values
|
|
||||||
END
|
|
||||||
WHERE request_method IS NOT NULL;
|
|
||||||
SQL
|
|
||||||
|
|
||||||
execute <<-SQL
|
|
||||||
UPDATE events
|
|
||||||
SET waf_action = CASE waf_action
|
|
||||||
WHEN 0 THEN 'allow'
|
|
||||||
WHEN 1 THEN 'deny'
|
|
||||||
WHEN 2 THEN 'redirect'
|
|
||||||
WHEN 3 THEN 'challenge'
|
|
||||||
ELSE 'allow' -- Default to allow for unknown values
|
|
||||||
END
|
|
||||||
WHERE waf_action IS NOT NULL;
|
|
||||||
SQL
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
class CreateUsers < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
create_table :users do |t|
|
|
||||||
t.string :email_address, null: false
|
|
||||||
t.string :password_digest, null: false
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
add_index :users, :email_address, unique: true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
class CreateSessions < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
create_table :sessions do |t|
|
|
||||||
t.references :user, null: false, foreign_key: true
|
|
||||||
t.string :ip_address
|
|
||||||
t.string :user_agent
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
class AddRoleToUsers < ActiveRecord::Migration[8.1]
|
|
||||||
def change
|
|
||||||
add_column :users, :role, :integer, default: 1, null: false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
117
db/schema.rb
117
db/schema.rb
@@ -10,7 +10,10 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do
|
ActiveRecord::Schema[8.1].define(version: 7) do
|
||||||
|
# These are extensions that must be enabled in order to support this database
|
||||||
|
enable_extension "pg_catalog.plpgsql"
|
||||||
|
|
||||||
create_table "events", force: :cascade do |t|
|
create_table "events", force: :cascade do |t|
|
||||||
t.string "agent_name"
|
t.string "agent_name"
|
||||||
t.string "agent_version"
|
t.string "agent_version"
|
||||||
@@ -20,11 +23,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "environment"
|
t.string "environment"
|
||||||
t.string "event_id", null: false
|
t.string "event_id", null: false
|
||||||
t.string "ip_address"
|
t.inet "ip_address"
|
||||||
t.json "payload"
|
t.json "payload"
|
||||||
t.integer "project_id", null: false
|
t.bigint "project_id", null: false
|
||||||
t.integer "request_host_id"
|
t.bigint "request_host_id"
|
||||||
t.integer "request_method"
|
t.integer "request_method", default: 0
|
||||||
t.string "request_path"
|
t.string "request_path"
|
||||||
t.string "request_protocol"
|
t.string "request_protocol"
|
||||||
t.string "request_segment_ids"
|
t.string "request_segment_ids"
|
||||||
@@ -35,13 +38,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do
|
|||||||
t.string "server_name"
|
t.string "server_name"
|
||||||
t.datetime "timestamp", null: false
|
t.datetime "timestamp", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.text "user_agent"
|
t.string "user_agent"
|
||||||
t.integer "waf_action"
|
t.integer "waf_action", default: 0, null: false
|
||||||
t.index ["event_id"], name: "index_events_on_event_id", unique: true
|
t.index ["event_id"], name: "index_events_on_event_id", unique: true
|
||||||
t.index ["ip_address"], name: "index_events_on_ip_address"
|
t.index ["ip_address"], name: "index_events_on_ip_address"
|
||||||
t.index ["project_id", "ip_address"], name: "index_events_on_project_id_and_ip_address"
|
t.index ["project_id", "ip_address"], name: "idx_events_project_ip"
|
||||||
t.index ["project_id", "timestamp"], name: "index_events_on_project_id_and_timestamp"
|
t.index ["project_id", "timestamp"], name: "idx_events_project_time"
|
||||||
t.index ["project_id", "waf_action"], name: "index_events_on_project_id_and_waf_action"
|
t.index ["project_id", "waf_action"], name: "idx_events_project_action"
|
||||||
t.index ["project_id"], name: "index_events_on_project_id"
|
t.index ["project_id"], name: "index_events_on_project_id"
|
||||||
t.index ["request_host_id", "request_method", "request_segment_ids"], name: "idx_events_host_method_path"
|
t.index ["request_host_id", "request_method", "request_segment_ids"], name: "idx_events_host_method_path"
|
||||||
t.index ["request_host_id"], name: "index_events_on_request_host_id"
|
t.index ["request_host_id"], name: "index_events_on_request_host_id"
|
||||||
@@ -50,52 +53,33 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do
|
|||||||
t.index ["waf_action"], name: "index_events_on_waf_action"
|
t.index ["waf_action"], name: "index_events_on_waf_action"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "ipv4_ranges", force: :cascade do |t|
|
create_table "network_ranges", force: :cascade do |t|
|
||||||
t.text "abuser_scores"
|
t.text "abuser_scores"
|
||||||
t.text "additional_data"
|
t.text "additional_data"
|
||||||
t.integer "asn"
|
t.integer "asn"
|
||||||
t.string "asn_org"
|
t.string "asn_org"
|
||||||
t.string "company"
|
t.string "company"
|
||||||
|
t.string "country"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "geo2_country"
|
t.text "creation_reason"
|
||||||
t.string "ip_api_country"
|
|
||||||
t.boolean "is_datacenter", default: false
|
t.boolean "is_datacenter", default: false
|
||||||
t.boolean "is_proxy", default: false
|
t.boolean "is_proxy", default: false
|
||||||
t.boolean "is_vpn", default: false
|
t.boolean "is_vpn", default: false
|
||||||
t.datetime "last_api_fetch"
|
t.datetime "last_api_fetch"
|
||||||
t.integer "network_end", limit: 8, null: false
|
t.inet "network", null: false
|
||||||
t.integer "network_prefix", null: false
|
t.string "source", default: "api_imported", null: false
|
||||||
t.integer "network_start", limit: 8, null: false
|
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["asn"], name: "idx_ipv4_asn"
|
t.bigint "user_id"
|
||||||
t.index ["company"], name: "idx_ipv4_company"
|
t.index ["asn"], name: "index_network_ranges_on_asn"
|
||||||
t.index ["ip_api_country"], name: "idx_ipv4_country"
|
t.index ["asn_org"], name: "index_network_ranges_on_asn_org"
|
||||||
t.index ["is_datacenter", "is_proxy", "is_vpn"], name: "idx_ipv4_flags"
|
t.index ["company"], name: "index_network_ranges_on_company"
|
||||||
t.index ["network_start", "network_end", "network_prefix"], name: "idx_ipv4_range_lookup"
|
t.index ["country"], name: "index_network_ranges_on_country"
|
||||||
end
|
t.index ["is_datacenter", "is_proxy", "is_vpn"], name: "idx_network_flags"
|
||||||
|
t.index ["is_datacenter"], name: "index_network_ranges_on_is_datacenter"
|
||||||
create_table "ipv6_ranges", force: :cascade do |t|
|
t.index ["network"], name: "index_network_ranges_on_network", opclass: :inet_ops, using: :gist
|
||||||
t.text "abuser_scores"
|
t.index ["network"], name: "index_network_ranges_on_network_unique", unique: true
|
||||||
t.text "additional_data"
|
t.index ["source"], name: "index_network_ranges_on_source"
|
||||||
t.integer "asn"
|
t.index ["user_id"], name: "index_network_ranges_on_user_id"
|
||||||
t.string "asn_org"
|
|
||||||
t.string "company"
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.string "geo2_country"
|
|
||||||
t.string "ip_api_country"
|
|
||||||
t.boolean "is_datacenter", default: false
|
|
||||||
t.boolean "is_proxy", default: false
|
|
||||||
t.boolean "is_vpn", default: false
|
|
||||||
t.datetime "last_api_fetch"
|
|
||||||
t.binary "network_end", limit: 16, null: false
|
|
||||||
t.integer "network_prefix", null: false
|
|
||||||
t.binary "network_start", limit: 16, null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.index ["asn"], name: "idx_ipv6_asn"
|
|
||||||
t.index ["company"], name: "idx_ipv6_company"
|
|
||||||
t.index ["ip_api_country"], name: "idx_ipv6_country"
|
|
||||||
t.index ["is_datacenter", "is_proxy", "is_vpn"], name: "idx_ipv6_flags"
|
|
||||||
t.index ["network_start", "network_end", "network_prefix"], name: "idx_ipv6_range_lookup"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "path_segments", force: :cascade do |t|
|
create_table "path_segments", force: :cascade do |t|
|
||||||
@@ -154,48 +138,38 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do
|
|||||||
t.index ["protocol"], name: "index_request_protocols_on_protocol", unique: true
|
t.index ["protocol"], name: "index_request_protocols_on_protocol", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "rule_sets", force: :cascade do |t|
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.text "description"
|
|
||||||
t.boolean "enabled"
|
|
||||||
t.string "name"
|
|
||||||
t.integer "priority"
|
|
||||||
t.json "projects"
|
|
||||||
t.json "projects_subscription"
|
|
||||||
t.json "rules"
|
|
||||||
t.string "slug"
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.index ["enabled"], name: "index_rule_sets_on_enabled"
|
|
||||||
t.index ["priority"], name: "index_rule_sets_on_priority"
|
|
||||||
t.index ["slug"], name: "index_rule_sets_on_slug", unique: true
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table "rules", force: :cascade do |t|
|
create_table "rules", force: :cascade do |t|
|
||||||
t.string "action"
|
t.string "action", null: false
|
||||||
t.json "conditions"
|
t.json "conditions", default: {}
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.boolean "enabled"
|
t.boolean "enabled", default: true, null: false
|
||||||
t.datetime "expires_at"
|
t.datetime "expires_at"
|
||||||
t.json "metadata"
|
t.json "metadata", default: {}
|
||||||
|
t.bigint "network_range_id"
|
||||||
t.integer "priority"
|
t.integer "priority"
|
||||||
t.string "rule_type"
|
t.string "rule_type", null: false
|
||||||
t.string "source", limit: 100
|
t.string "source", limit: 100, default: "manual"
|
||||||
t.string "target"
|
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.bigint "user_id"
|
||||||
|
t.index ["action"], name: "index_rules_on_action"
|
||||||
|
t.index ["enabled", "expires_at"], name: "idx_rules_active"
|
||||||
t.index ["enabled"], name: "index_rules_on_enabled"
|
t.index ["enabled"], name: "index_rules_on_enabled"
|
||||||
t.index ["expires_at"], name: "index_rules_on_expires_at"
|
t.index ["expires_at"], name: "index_rules_on_expires_at"
|
||||||
|
t.index ["network_range_id"], name: "index_rules_on_network_range_id"
|
||||||
|
t.index ["priority"], name: "index_rules_on_priority"
|
||||||
t.index ["rule_type", "enabled"], name: "idx_rules_type_enabled"
|
t.index ["rule_type", "enabled"], name: "idx_rules_type_enabled"
|
||||||
t.index ["rule_type"], name: "index_rules_on_rule_type"
|
t.index ["rule_type"], name: "index_rules_on_rule_type"
|
||||||
t.index ["source"], name: "index_rules_on_source"
|
t.index ["source"], name: "index_rules_on_source"
|
||||||
t.index ["updated_at", "id"], name: "idx_rules_sync"
|
t.index ["updated_at", "id"], name: "idx_rules_sync"
|
||||||
|
t.index ["user_id"], name: "index_rules_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "sessions", force: :cascade do |t|
|
create_table "sessions", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "ip_address"
|
t.inet "ip_address"
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "user_agent"
|
t.string "user_agent"
|
||||||
t.integer "user_id", null: false
|
t.bigint "user_id", null: false
|
||||||
t.index ["user_id"], name: "index_sessions_on_user_id"
|
t.index ["user_id"], name: "index_sessions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -210,5 +184,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_11_03_225251) do
|
|||||||
|
|
||||||
add_foreign_key "events", "projects"
|
add_foreign_key "events", "projects"
|
||||||
add_foreign_key "events", "request_hosts"
|
add_foreign_key "events", "request_hosts"
|
||||||
|
add_foreign_key "network_ranges", "users"
|
||||||
|
add_foreign_key "rules", "network_ranges"
|
||||||
|
add_foreign_key "rules", "users"
|
||||||
add_foreign_key "sessions", "users"
|
add_foreign_key "sessions", "users"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -428,7 +428,8 @@ suspicious_paths = Event.where(waf_action: :deny)
|
|||||||
.pluck(:request_segment_ids)
|
.pluck(:request_segment_ids)
|
||||||
|
|
||||||
suspicious_paths.each do |seg_ids|
|
suspicious_paths.each do |seg_ids|
|
||||||
RuleSet.global.block_path_segments(seg_ids)
|
# TODO: Implement rule creation for blocking path segments
|
||||||
|
# Rule.create!(rule_type: 'path_pattern', conditions: { patterns: seg_ids }, action: 'deny')
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
102
lib/tasks/users.rake
Normal file
102
lib/tasks/users.rake
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
namespace :users do
|
||||||
|
desc "Reset password for a user"
|
||||||
|
task reset_password: :environment do
|
||||||
|
email = ENV['EMAIL']
|
||||||
|
new_password = ENV['PASSWORD']
|
||||||
|
|
||||||
|
if email.blank?
|
||||||
|
puts "Usage: EMAIL=user@example.com PASSWORD=newpassword rails users:reset_password"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
user = User.find_by(email_address: email)
|
||||||
|
if user.nil?
|
||||||
|
puts "Error: User with email '#{email}' not found."
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if new_password.blank?
|
||||||
|
puts "Error: PASSWORD environment variable is required."
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if user.password_digest.blank?
|
||||||
|
puts "Warning: User appears to be an OIDC user (no password set)."
|
||||||
|
print "Do you want to set a local password for this OIDC user? (y/N): "
|
||||||
|
response = STDIN.gets.chomp.downcase
|
||||||
|
unless response == 'y' || response == 'yes'
|
||||||
|
puts "Password reset cancelled."
|
||||||
|
exit 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
user.password = new_password
|
||||||
|
user.password_confirmation = new_password
|
||||||
|
|
||||||
|
if user.save
|
||||||
|
# Destroy all sessions to force re-login
|
||||||
|
user.sessions.destroy_all
|
||||||
|
|
||||||
|
puts "✅ Password successfully updated for #{user.email_address}"
|
||||||
|
puts " User: #{user.email_address} (#{user.role})"
|
||||||
|
puts " All existing sessions have been terminated."
|
||||||
|
puts " User will need to log in with the new password."
|
||||||
|
else
|
||||||
|
puts "❌ Failed to update password:"
|
||||||
|
user.errors.full_messages.each { |msg| puts " - #{msg}" }
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "List all users"
|
||||||
|
task list: :environment do
|
||||||
|
users = User.order(:role, :email_address)
|
||||||
|
|
||||||
|
puts "Users (#{users.count}):"
|
||||||
|
puts "=" * 60
|
||||||
|
users.each do |user|
|
||||||
|
has_password = user.password_digest.present? ? "local" : "OIDC"
|
||||||
|
last_login = user.sessions.maximum(:created_at)
|
||||||
|
|
||||||
|
puts "📧 #{user.email_address}"
|
||||||
|
puts " Role: #{user.role} | Auth: #{has_password}"
|
||||||
|
puts " Last login: #{last_login ? last_login.strftime('%Y-%m-%d %H:%M') : 'Never'}"
|
||||||
|
puts " Active sessions: #{user.sessions.count}"
|
||||||
|
puts
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Create admin user (only if no users exist)"
|
||||||
|
task create_admin: :environment do
|
||||||
|
if User.any?
|
||||||
|
puts "❌ Users already exist. Admin creation is disabled."
|
||||||
|
puts " Use 'rails users:reset_password' to reset an existing user's password."
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
email = ENV['EMAIL']
|
||||||
|
password = ENV['PASSWORD']
|
||||||
|
|
||||||
|
if email.blank? || password.blank?
|
||||||
|
puts "Usage: EMAIL=admin@example.com PASSWORD=securepassword rails users:create_admin"
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
user = User.new(
|
||||||
|
email_address: email,
|
||||||
|
password: password,
|
||||||
|
password_confirmation: password
|
||||||
|
)
|
||||||
|
|
||||||
|
if user.save
|
||||||
|
puts "✅ Admin user created successfully:"
|
||||||
|
puts " Email: #{user.email_address}"
|
||||||
|
puts " Role: #{user.role}"
|
||||||
|
puts " You can now log in to the application."
|
||||||
|
else
|
||||||
|
puts "❌ Failed to create admin user:"
|
||||||
|
user.errors.full_messages.each { |msg| puts " - #{msg}" }
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
require "test_helper"
|
|
||||||
|
|
||||||
class PasswordsControllerTest < ActionDispatch::IntegrationTest
|
|
||||||
setup { @user = User.take }
|
|
||||||
|
|
||||||
test "new" do
|
|
||||||
get new_password_path
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "create" do
|
|
||||||
post passwords_path, params: { email_address: @user.email_address }
|
|
||||||
assert_enqueued_email_with PasswordsMailer, :reset, args: [ @user ]
|
|
||||||
assert_redirected_to new_session_path
|
|
||||||
|
|
||||||
follow_redirect!
|
|
||||||
assert_notice "reset instructions sent"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "create for an unknown user redirects but sends no mail" do
|
|
||||||
post passwords_path, params: { email_address: "missing-user@example.com" }
|
|
||||||
assert_enqueued_emails 0
|
|
||||||
assert_redirected_to new_session_path
|
|
||||||
|
|
||||||
follow_redirect!
|
|
||||||
assert_notice "reset instructions sent"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "edit" do
|
|
||||||
get edit_password_path(@user.password_reset_token)
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "edit with invalid password reset token" do
|
|
||||||
get edit_password_path("invalid token")
|
|
||||||
assert_redirected_to new_password_path
|
|
||||||
|
|
||||||
follow_redirect!
|
|
||||||
assert_notice "reset link is invalid"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "update" do
|
|
||||||
assert_changes -> { @user.reload.password_digest } do
|
|
||||||
put password_path(@user.password_reset_token), params: { password: "new", password_confirmation: "new" }
|
|
||||||
assert_redirected_to new_session_path
|
|
||||||
end
|
|
||||||
|
|
||||||
follow_redirect!
|
|
||||||
assert_notice "Password has been reset"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "update with non matching passwords" do
|
|
||||||
token = @user.password_reset_token
|
|
||||||
assert_no_changes -> { @user.reload.password_digest } do
|
|
||||||
put password_path(token), params: { password: "no", password_confirmation: "match" }
|
|
||||||
assert_redirected_to edit_password_path(token)
|
|
||||||
end
|
|
||||||
|
|
||||||
follow_redirect!
|
|
||||||
assert_notice "Passwords did not match"
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def assert_notice(text)
|
|
||||||
assert_select "div", /#{text}/
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# Preview all emails at http://localhost:3000/rails/mailers/passwords_mailer
|
|
||||||
class PasswordsMailerPreview < ActionMailer::Preview
|
|
||||||
# Preview this email at http://localhost:3000/rails/mailers/passwords_mailer/reset
|
|
||||||
def reset
|
|
||||||
PasswordsMailer.reset(User.take)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
require "test_helper"
|
|
||||||
|
|
||||||
class RuleSetTest < ActiveSupport::TestCase
|
|
||||||
# test "the truth" do
|
|
||||||
# assert true
|
|
||||||
# end
|
|
||||||
end
|
|
||||||
Reference in New Issue
Block a user