Migrate to Postgresql for better network handling. Add more user functionality.

This commit is contained in:
Dan Milne
2025-11-06 14:08:39 +11:00
parent 85252a1a07
commit fc567f0b91
69 changed files with 4266 additions and 952 deletions

View File

@@ -4,8 +4,11 @@ source "https://rubygems.org"
gem "rails", "~> 8.1.1"
# The modern asset pipeline for Rails [https://github.com/rails/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"
# Use PostgreSQL as the primary database
gem "pg", ">= 1.1"
# Use the Puma web server [https://github.com/puma/puma]
gem "puma", ">= 5.0"
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
@@ -56,6 +59,9 @@ gem "maxmind-db"
# HTTP client for database downloads
gem "httparty"
# Authorization library
gem "pundit"
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"

View File

@@ -261,6 +261,12 @@ GEM
parser (3.3.10.0)
ast (~> 2.4.1)
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)
prettyprint
prettyprint (0.2.0)
@@ -275,6 +281,8 @@ GEM
public_suffix (6.0.2)
puma (7.1.0)
nio4r (~> 2.0)
pundit (2.5.2)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.3)
@@ -489,8 +497,10 @@ DEPENDENCIES
omniauth_openid_connect (~> 0.8)
openid_connect (~> 2.2)
pagy
pg (>= 1.1)
propshaft
puma (>= 5.0)
pundit
rails (~> 8.1.1)
rubocop-rails-omakase
selenium-webdriver

View File

@@ -2,7 +2,7 @@
**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

View File

@@ -8,3 +8,26 @@
*
* 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;
}

View File

@@ -2,6 +2,7 @@
class Api::EventsController < ApplicationController
skip_before_action :verify_authenticity_token
allow_unauthenticated_access # Skip normal session auth, use DSN auth instead
# POST /api/:project_id/events
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-Until'] = current_sampling[:effective_until]
# Check if agent sent a rule version to compare against
client_version = request.headers['X-Rule-Version']&.to_i
# Check if agent sent a rule version in the JSON body to compare against
client_version = event_data.dig('last_rule_sync')&.to_i
response_data = {
success: true,
@@ -40,8 +41,7 @@ class Api::EventsController < ApplicationController
if client_version.blank? || client_version != rule_version
# Get rules updated since client version
if client_version.present?
since_time = Time.at(client_version / 1_000_000, client_version % 1_000_000)
rules = Rule.where("updated_at > ?", since_time).enabled.sync_order
rules = Rule.since(client_version).enabled
else
# Full sync for new agents
rules = Rule.active.sync_order

View File

@@ -7,6 +7,7 @@ module Api
# These endpoints are kept for administrative/debugging purposes only
skip_before_action :verify_authenticity_token
allow_unauthenticated_access # Skip normal session auth, use project key auth instead
before_action :authenticate_project!
before_action :check_project_enabled
@@ -23,8 +24,8 @@ module Api
}
end
# GET /api/:public_key/rules?since=1730646186272060
# Incremental sync - returns rules updated since timestamp (microsecond Unix timestamp)
# GET /api/:public_key/rules?since=1730646186
# Incremental sync - returns rules updated since timestamp (Unix timestamp in seconds)
# GET /api/:public_key/rules
# Full sync - returns all active rules
def index
@@ -69,17 +70,14 @@ module Api
end
def parse_timestamp(timestamp_str)
# Parse microsecond Unix timestamp
# Parse Unix timestamp in seconds
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
total_microseconds = timestamp_str.to_i
seconds = total_microseconds / 1_000_000
microseconds = total_microseconds % 1_000_000
Time.at(seconds, microseconds)
Time.at(timestamp_str.to_i)
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

View File

@@ -7,9 +7,13 @@ class ApplicationController < ActionController::Base
stale_when_importmap_changes
include Pagy::Backend
include Pagy::Frontend
include Pundit::Authorization
helper_method :current_user, :user_signed_in?, :current_user_admin?, :current_user_viewer?
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
private
def current_user
@@ -43,4 +47,12 @@ class ApplicationController < ActionController::Base
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
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

View 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

View File

@@ -1,35 +1,24 @@
class PasswordsController < ApplicationController
allow_unauthenticated_access
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
before_action :require_authentication
def edit
@user = Current.user
end
def update
if @user.update(params.permit(:password, :password_confirmation))
@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
@user = Current.user
private
def set_user_by_token
@user = User.find_by_password_reset_token!(params[:token])
rescue ActiveSupport::MessageVerifier::InvalidSignature
redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
if @user.authenticate(params[:current_password])
if @user.update(params.permit(:password, :password_confirmation))
@user.sessions.where.not(id: Current.session.id).destroy_all
redirect_to root_path, notice: "Password updated successfully."
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

View File

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

View File

@@ -1,29 +1,58 @@
# frozen_string_literal: true
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 :authorize_rule
before_action :set_project, only: [:index, :show]
# GET /rules
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
@actions = Rule::ACTIONS
end
# GET /rules/new
def new
authorize Rule
@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
@actions = Rule::ACTIONS
end
# POST /rules
def create
authorize Rule
@rule = Rule.new(rule_params)
@rule.user = Current.user
@rule_types = Rule::RULE_TYPES
@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
redirect_to @rule, notice: 'Rule was successfully created.'
else
@@ -33,16 +62,19 @@ class RulesController < ApplicationController
# GET /rules/:id
def show
authorize @rule
end
# GET /rules/:id/edit
def edit
authorize @rule
@rule_types = Rule::RULE_TYPES
@actions = Rule::ACTIONS
end
# PATCH/PUT /rules/:id
def update
authorize @rule
if @rule.update(rule_params)
redirect_to @rule, notice: 'Rule was successfully updated.'
else
@@ -52,6 +84,7 @@ class RulesController < ApplicationController
# POST /rules/:id/disable
def disable
authorize @rule, :disable?
reason = params[:reason] || "Disabled manually"
@rule.disable!(reason: reason)
redirect_to @rule, notice: 'Rule was successfully disabled.'
@@ -59,6 +92,7 @@ class RulesController < ApplicationController
# POST /rules/:id/enable
def enable
authorize @rule, :enable?
@rule.enable!
redirect_to @rule, notice: 'Rule was successfully enabled.'
end
@@ -69,20 +103,32 @@ class RulesController < ApplicationController
@rule = Rule.find(params[:id])
end
def authorize_rule
# Add authorization logic here if needed
# For now, allow all authenticated users
end
def rule_params
params.require(:rule).permit(
permitted = [
:rule_type,
:action,
:conditions,
:metadata,
:expires_at,
: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

View 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()
}
}
}

View File

@@ -1,6 +0,0 @@
class PasswordsMailer < ApplicationMailer
def reset(user)
@user = user
mail subject: "Reset your password", to: user.email_address
end
end

View File

@@ -1,4 +1,6 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
attribute :baffle_host
attribute :baffle_internal_host
delegate :user, to: :session, allow_nil: true
end

View File

@@ -268,11 +268,114 @@ class Event < ApplicationRecord
headers.transform_keys(&:downcase)
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!
return if ip_address.blank?
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)
update!(country_code: country) if country.present?
rescue => e
@@ -282,13 +385,21 @@ class Event < ApplicationRecord
# Class method to enrich multiple events
def self.enrich_geo_location_batch(events = nil)
events ||= where(country_code: [nil, '']).where.not(ip_address: [nil, ''])
geo_service = GeoIpService.new
updated_count = 0
events.find_each do |event|
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?
event.update!(country_code: country)
updated_count += 1
@@ -303,6 +414,11 @@ class Event < ApplicationRecord
return country_code if country_code.present?
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)
rescue => e
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
def has_geo_data?
country_code.present? || city.present?
country_code.present? || city.present? || network_intelligence[:country].present?
end
# Get full geo location details
def geo_location
network_info = network_intelligence
{
country_code: country_code,
country_code: country_code || network_info[:country],
city: city,
ip_address: ip_address,
has_data: has_geo_data?
has_data: has_geo_data?,
network_intelligence: network_info
}
end

290
app/models/network_range.rb Normal file
View 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

View File

@@ -1,20 +1,31 @@
# 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
# Rule types for the new architecture
RULE_TYPES = %w[network_v4 network_v6 rate_limit path_pattern].freeze
# Rule types and actions
RULE_TYPES = %w[network rate_limit path_pattern].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
validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
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 :source, inclusion: { in: SOURCES }
# Custom validations based on rule type
# Custom validations
validate :validate_conditions_by_type
validate :validate_metadata_by_action
validate :network_range_required_for_network_rules
validate :validate_network_consistency, if: :network_rule?
# Scopes
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 :expired, -> { where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) }
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 :path_pattern_rules, -> { where(rule_type: "path_pattern") }
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)
scope :since, ->(timestamp) { where("updated_at >= ?", timestamp - 0.5.seconds).order(:updated_at, :id) }
# Sync queries
scope :since, ->(timestamp) { where("updated_at >= ?", Time.at(timestamp)).order(:updated_at, :id) }
scope :sync_order, -> { order(:updated_at, :id) }
# Callbacks
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?
enabled? && !expired?
end
@@ -44,14 +115,37 @@ class Rule < ApplicationRecord
expires_at.present? && expires_at <= Time.current
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
{
format = {
id: id,
rule_type: rule_type,
action: action,
conditions: conditions || {},
priority: priority,
conditions: agent_conditions,
priority: agent_priority,
expires_at: expires_at&.iso8601,
enabled: enabled,
source: source,
@@ -59,50 +153,118 @@ class Rule < ApplicationRecord
created_at: created_at.iso8601,
updated_at: updated_at.iso8601
}
# Add network intelligence for debugging (optional)
if network_rule? && network_range
format[:network_intelligence] = network_intelligence
end
# Class method to get latest version (for sync cursor)
# Returns microsecond Unix timestamp for efficient machine comparison
format
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
max_time = maximum(:updated_at)
if max_time
# 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
max_time ? max_time.to_i : Time.current.to_i
end
# Disable rule (soft delete)
def disable!(reason: nil)
update!(
enabled: false,
metadata: (metadata || {}).merge(
disabled_at: Time.current.iso8601,
disabled_reason: reason
)
)
def self.active_for_agent
active.sync_order.map(&:to_agent_format)
end
# Enable rule
def enable!
update!(enabled: true)
# Analytics methods
def matching_events(limit: 100)
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
# Check if this is a network rule
def network_rule?
rule_type.in?(%w[network_v4 network_v6])
end
def effectiveness_stats
return {} unless network_rule?
# Get CIDR from conditions (for network rules)
def cidr
conditions&.dig("cidr") if network_rule?
end
# Get prefix length from CIDR
def prefix_length
return nil unless cidr
cidr.split("/").last.to_i
events = matching_events
{
total_events: events.count,
blocked_events: events.blocked.count,
allowed_events: events.allowed.count,
block_rate: events.count > 0 ? (events.blocked.count.to_f / events.count * 100).round(2) : 0
}
end
private
@@ -112,19 +274,40 @@ class Rule < ApplicationRecord
self.conditions ||= {}
self.metadata ||= {}
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
def calculate_priority_from_cidr
# For network rules, priority is the prefix length (more specific = higher priority)
if network_rule? && cidr.present?
self.priority = prefix_length
def calculate_priority_for_network_rules
if network_rule? && network_range
self.priority = network_range.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
def validate_conditions_by_type
case rule_type
when "network_v4", "network_v6"
validate_network_conditions
when "network"
# Network rules don't need conditions in DB - stored in network_range
true
when "rate_limit"
validate_rate_limit_conditions
when "path_pattern"
@@ -132,29 +315,6 @@ class Rule < ApplicationRecord
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
scope = conditions&.dig("scope")
cidr_value = conditions&.dig("cidr")
@@ -163,11 +323,6 @@ class Rule < ApplicationRecord
errors.add(:conditions, "must include 'scope' for rate_limit rules")
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?
errors.add(:metadata, "must include 'limit' and 'window' for rate_limit rules")
end
@@ -193,4 +348,50 @@ class Rule < ApplicationRecord
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

View File

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

View File

@@ -6,6 +6,10 @@ class User < ApplicationRecord
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 :role, presence: true
@@ -18,13 +22,18 @@ class User < ApplicationRecord
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')
user.role = map_oidc_groups_to_role(auth_hash.dig('extra', 'raw_info', 'groups'))
end
# Don't override password for OIDC users
user.save!(validate: false) if user.new_record?
# For OIDC users, set a random password if they don't have one
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
end

View 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

View 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

View 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

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

View 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

View File

@@ -71,10 +71,12 @@
<span class="badge bg-secondary ms-1"><%= current_user.role %></span>
</a>
<ul class="dropdown-menu">
<li><%= link_to "Account Settings", edit_password_path, class: "dropdown-item" %></li>
<% if current_user_admin? %>
<li><%= link_to "Manage Users", users_path, class: "dropdown-item" %></li>
<li><hr class="dropdown-divider"></li>
<li><%= link_to "Manage Users", users_path, class: "dropdown-item" %></li>
<% end %>
<li><hr class="dropdown-divider"></li>
<li><%= link_to "Sign Out", session_path, data: { turbo_method: :delete }, class: "dropdown-item" %></li>
</ul>
</li>

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

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

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

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

View File

@@ -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] %>
<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">
<%= alert %>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<% end %>
<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 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>
<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" %>
<% 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="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 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>

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -1,54 +1,69 @@
# SQLite. Versions 3.8.0 and up are supported.
# gem install sqlite3
#
# Ensure the SQLite 3 gem is defined in your Gemfile
# gem "sqlite3"
#
default: &default
# Primary database: PostgreSQL for network intelligence
# Cache/Queue/Cable: SQLite for auxiliary storage
# Default configuration for SQLite databases (cache/queue/cable)
sqlite_default: &sqlite_default
adapter: sqlite3
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
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:
primary:
<<: *default
database: storage/development.sqlite3
<<: *postgres_default
database: baffle_hub_development
cache:
<<: *default
<<: *sqlite_default
database: storage/development_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
<<: *sqlite_default
database: storage/development_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
<<: *sqlite_default
database: storage/development_cable.sqlite3
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:
<<: *default
database: storage/test.sqlite3
primary:
<<: *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:
primary:
<<: *default
database: storage/production.sqlite3
<<: *postgres_default
database: baffle_hub_production
username: baffle_hub
password: <%= ENV["BAFFLE_HUB_DATABASE_PASSWORD"] %>
cache:
<<: *default
<<: *sqlite_default
database: storage/production_cache.sqlite3
migrations_paths: db/cache_migrate
queue:
<<: *default
<<: *sqlite_default
database: storage/production_queue.sqlite3
migrations_paths: db/queue_migrate
cable:
<<: *default
<<: *sqlite_default
database: storage/production_cable.sqlite3
migrations_paths: db/cable_migrate

View File

@@ -2,7 +2,7 @@ Rails.application.routes.draw do
# Registration only allowed when no users exist
resource :registration, only: [:new, :create]
resource :session
resources :passwords, param: :token
resource :password
# OIDC authentication routes
get "/auth/failure", to: "omniauth_callbacks#failure"
@@ -39,6 +39,20 @@ Rails.application.routes.draw do
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
resources :rules, only: [:index, :new, :create, :show, :edit, :update] do
member do

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
class RenameActionToWafActionInEvents < ActiveRecord::Migration[8.1]
def change
rename_column :events, :action, :waf_action
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +0,0 @@
class AddRoleToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :role, :integer, default: 1, null: false
end
end

View File

@@ -10,7 +10,10 @@
#
# 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|
t.string "agent_name"
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.string "environment"
t.string "event_id", null: false
t.string "ip_address"
t.inet "ip_address"
t.json "payload"
t.integer "project_id", null: false
t.integer "request_host_id"
t.integer "request_method"
t.bigint "project_id", null: false
t.bigint "request_host_id"
t.integer "request_method", default: 0
t.string "request_path"
t.string "request_protocol"
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.datetime "timestamp", null: false
t.datetime "updated_at", null: false
t.text "user_agent"
t.integer "waf_action"
t.string "user_agent"
t.integer "waf_action", default: 0, null: false
t.index ["event_id"], name: "index_events_on_event_id", unique: true
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", "timestamp"], name: "index_events_on_project_id_and_timestamp"
t.index ["project_id", "waf_action"], name: "index_events_on_project_id_and_waf_action"
t.index ["project_id", "ip_address"], name: "idx_events_project_ip"
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"], 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"], 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"
end
create_table "ipv4_ranges", force: :cascade do |t|
create_table "network_ranges", force: :cascade do |t|
t.text "abuser_scores"
t.text "additional_data"
t.integer "asn"
t.string "asn_org"
t.string "company"
t.string "country"
t.datetime "created_at", null: false
t.string "geo2_country"
t.string "ip_api_country"
t.text "creation_reason"
t.boolean "is_datacenter", default: false
t.boolean "is_proxy", default: false
t.boolean "is_vpn", default: false
t.datetime "last_api_fetch"
t.integer "network_end", limit: 8, null: false
t.integer "network_prefix", null: false
t.integer "network_start", limit: 8, null: false
t.inet "network", null: false
t.string "source", default: "api_imported", null: false
t.datetime "updated_at", null: false
t.index ["asn"], name: "idx_ipv4_asn"
t.index ["company"], name: "idx_ipv4_company"
t.index ["ip_api_country"], name: "idx_ipv4_country"
t.index ["is_datacenter", "is_proxy", "is_vpn"], name: "idx_ipv4_flags"
t.index ["network_start", "network_end", "network_prefix"], name: "idx_ipv4_range_lookup"
end
create_table "ipv6_ranges", force: :cascade do |t|
t.text "abuser_scores"
t.text "additional_data"
t.integer "asn"
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"
t.bigint "user_id"
t.index ["asn"], name: "index_network_ranges_on_asn"
t.index ["asn_org"], name: "index_network_ranges_on_asn_org"
t.index ["company"], name: "index_network_ranges_on_company"
t.index ["country"], name: "index_network_ranges_on_country"
t.index ["is_datacenter", "is_proxy", "is_vpn"], name: "idx_network_flags"
t.index ["is_datacenter"], name: "index_network_ranges_on_is_datacenter"
t.index ["network"], name: "index_network_ranges_on_network", opclass: :inet_ops, using: :gist
t.index ["network"], name: "index_network_ranges_on_network_unique", unique: true
t.index ["source"], name: "index_network_ranges_on_source"
t.index ["user_id"], name: "index_network_ranges_on_user_id"
end
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
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|
t.string "action"
t.json "conditions"
t.string "action", null: false
t.json "conditions", default: {}
t.datetime "created_at", null: false
t.boolean "enabled"
t.boolean "enabled", default: true, null: false
t.datetime "expires_at"
t.json "metadata"
t.json "metadata", default: {}
t.bigint "network_range_id"
t.integer "priority"
t.string "rule_type"
t.string "source", limit: 100
t.string "target"
t.string "rule_type", null: false
t.string "source", limit: 100, default: "manual"
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 ["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"], name: "index_rules_on_rule_type"
t.index ["source"], name: "index_rules_on_source"
t.index ["updated_at", "id"], name: "idx_rules_sync"
t.index ["user_id"], name: "index_rules_on_user_id"
end
create_table "sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "ip_address"
t.inet "ip_address"
t.datetime "updated_at", null: false
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"
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", "request_hosts"
add_foreign_key "network_ranges", "users"
add_foreign_key "rules", "network_ranges"
add_foreign_key "rules", "users"
add_foreign_key "sessions", "users"
end

View File

@@ -428,7 +428,8 @@ suspicious_paths = Event.where(waf_action: :deny)
.pluck(:request_segment_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
```

102
lib/tasks/users.rake Normal file
View 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

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
require "test_helper"
class RuleSetTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end