beskar 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +987 -21
  4. data/app/controllers/beskar/application_controller.rb +170 -0
  5. data/app/controllers/beskar/banned_ips_controller.rb +280 -0
  6. data/app/controllers/beskar/dashboard_controller.rb +70 -0
  7. data/app/controllers/beskar/security_events_controller.rb +182 -0
  8. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +70 -0
  9. data/app/models/beskar/banned_ip.rb +193 -0
  10. data/app/models/beskar/security_event.rb +64 -0
  11. data/app/services/beskar/banned_ip_manager.rb +78 -0
  12. data/app/views/beskar/banned_ips/edit.html.erb +259 -0
  13. data/app/views/beskar/banned_ips/index.html.erb +361 -0
  14. data/app/views/beskar/banned_ips/new.html.erb +310 -0
  15. data/app/views/beskar/banned_ips/show.html.erb +310 -0
  16. data/app/views/beskar/dashboard/index.html.erb +280 -0
  17. data/app/views/beskar/security_events/index.html.erb +309 -0
  18. data/app/views/beskar/security_events/show.html.erb +307 -0
  19. data/app/views/layouts/beskar/application.html.erb +647 -5
  20. data/config/locales/en.yml +10 -0
  21. data/config/routes.rb +41 -0
  22. data/db/migrate/20251016000001_create_beskar_security_events.rb +25 -0
  23. data/db/migrate/20251016000002_create_beskar_banned_ips.rb +23 -0
  24. data/lib/beskar/configuration.rb +214 -0
  25. data/lib/beskar/engine.rb +105 -0
  26. data/lib/beskar/logger.rb +293 -0
  27. data/lib/beskar/middleware/request_analyzer.rb +305 -0
  28. data/lib/beskar/middleware.rb +4 -0
  29. data/lib/beskar/models/security_trackable.rb +25 -0
  30. data/lib/beskar/models/security_trackable_authenticable.rb +167 -0
  31. data/lib/beskar/models/security_trackable_devise.rb +82 -0
  32. data/lib/beskar/models/security_trackable_generic.rb +355 -0
  33. data/lib/beskar/services/account_locker.rb +263 -0
  34. data/lib/beskar/services/device_detector.rb +250 -0
  35. data/lib/beskar/services/geolocation_service.rb +392 -0
  36. data/lib/beskar/services/ip_whitelist.rb +113 -0
  37. data/lib/beskar/services/rate_limiter.rb +257 -0
  38. data/lib/beskar/services/waf.rb +551 -0
  39. data/lib/beskar/version.rb +1 -1
  40. data/lib/beskar.rb +32 -1
  41. data/lib/generators/beskar/install/install_generator.rb +158 -0
  42. data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
  43. data/lib/tasks/beskar_tasks.rake +121 -4
  44. metadata +138 -5
@@ -1,4 +1,174 @@
1
1
  module Beskar
2
2
  class ApplicationController < ActionController::Base
3
+ # Use the main app's CSRF protection settings
4
+ protect_from_forgery with: :exception, prepend: true
5
+
6
+ layout 'beskar/application'
7
+
8
+ # Ensure CSRF token is available for forms
9
+ before_action :ensure_csrf_token
10
+
11
+ before_action :authenticate_admin!
12
+
13
+ private
14
+
15
+ # Override this method in your application to implement authentication
16
+ # For example, you might want to use Devise's authenticate_admin! or
17
+ # a custom authentication method
18
+ def authenticate_admin!
19
+ unless Beskar.configuration.authenticate_admin.present?
20
+ handle_missing_authentication_configuration
21
+ return false
22
+ end
23
+
24
+ handle_custom_authentication
25
+ end
26
+
27
+ def handle_custom_authentication
28
+ # Execute the authentication block in the controller's context
29
+ # This gives the block access to controller methods like cookies, session,
30
+ # authenticate_or_request_with_http_basic, etc.
31
+ result = instance_exec(request, &Beskar.configuration.authenticate_admin)
32
+ return true if result
33
+
34
+ handle_authentication_failure
35
+ false
36
+ rescue => e
37
+ Rails.logger.error "Beskar authentication error: #{e.message}"
38
+ handle_authentication_failure
39
+ false
40
+ end
41
+
42
+ def handle_missing_authentication_configuration
43
+ # Log the configuration error for debugging, but return 404 to avoid revealing Beskar is installed
44
+ error_message = <<~MSG
45
+ Beskar authentication not configured!
46
+
47
+ Configure Beskar.configuration.authenticate_admin in your initializer:
48
+
49
+ # config/initializers/beskar.rb
50
+ Beskar.configuration.authenticate_admin = ->(request) do
51
+ # The block is executed in the controller context, giving you access
52
+ # to controller methods like cookies, session, authenticate_or_request_with_http_basic, etc.
53
+
54
+ # Example 1: Check for admin user with Devise
55
+ # user = request.env['warden']&.authenticate(scope: :user)
56
+ # user&.admin?
57
+
58
+ # Example 2: HTTP Basic Auth (uses controller method)
59
+ # authenticate_or_request_with_http_basic do |username, password|
60
+ # username == ENV['BESKAR_USERNAME'] && password == ENV['BESKAR_PASSWORD']
61
+ # end
62
+
63
+ # Example 3: Cookie-based auth (uses controller cookies)
64
+ # cookies.signed[:admin_token] == ENV['BESKAR_ADMIN_TOKEN']
65
+
66
+ # Example 4: Simple token-based auth
67
+ # request.headers['Authorization'] == "Bearer #{ENV['BESKAR_ADMIN_TOKEN']}"
68
+
69
+ # Example 5: For development/testing (NOT for production!)
70
+ # Rails.env.development? || Rails.env.test?
71
+ end
72
+ MSG
73
+
74
+ Rails.logger.error error_message
75
+ render_404
76
+ end
77
+
78
+ def handle_authentication_failure
79
+ # Return 404 to avoid revealing that Beskar is installed
80
+ render_404
81
+ end
82
+
83
+ def render_404
84
+ respond_to do |format|
85
+ format.html { render file: "#{Rails.public_path}/404.html", status: :not_found, layout: false }
86
+ format.json { render json: { error: "Not found" }, status: :not_found }
87
+ format.any { head :not_found }
88
+ end
89
+ end
90
+
91
+ # Helper method to format timestamps
92
+ def format_timestamp(time)
93
+ return "-" unless time
94
+ time.in_time_zone.strftime("%Y-%m-%d %H:%M:%S %Z")
95
+ end
96
+ helper_method :format_timestamp
97
+
98
+ # Helper method to format IP addresses with location if available
99
+ def format_ip_with_location(ip, metadata = {})
100
+ return ip unless metadata.present?
101
+
102
+ location_parts = []
103
+ if metadata["geolocation"].present?
104
+ geo = metadata["geolocation"]
105
+ location_parts << geo["city"] if geo["city"].present?
106
+ location_parts << geo["country"] if geo["country"].present?
107
+ end
108
+
109
+ return ip if location_parts.empty?
110
+ "#{ip} (#{location_parts.join(', ')})"
111
+ end
112
+ helper_method :format_ip_with_location
113
+
114
+ # Helper to determine risk level badge color
115
+ def risk_level_class(risk_score)
116
+ return "neutral" unless risk_score
117
+
118
+ case risk_score
119
+ when 0..30
120
+ "success"
121
+ when 31..60
122
+ "warning"
123
+ when 61..85
124
+ "danger"
125
+ else
126
+ "critical"
127
+ end
128
+ end
129
+ helper_method :risk_level_class
130
+
131
+ # Helper to format event type for display
132
+ def format_event_type(event_type)
133
+ event_type.to_s.humanize.titleize
134
+ end
135
+ helper_method :format_event_type
136
+
137
+ # Pagination helper
138
+ def paginate(collection, per_page: 25)
139
+ # Handle per_page from params if provided
140
+ if params[:per_page].present?
141
+ per_page = params[:per_page].to_i
142
+ per_page = 25 if per_page <= 0 # Default if invalid
143
+ per_page = 100 if per_page > 100 # Max limit
144
+ end
145
+
146
+ page = (params[:page] || 1).to_i
147
+ page = 1 if page < 1
148
+
149
+ total_count = collection.count
150
+ total_pages = total_count > 0 ? (total_count.to_f / per_page).ceil : 0
151
+
152
+ offset = (page - 1) * per_page
153
+ records = collection.limit(per_page).offset(offset)
154
+
155
+ {
156
+ records: records,
157
+ current_page: page,
158
+ total_pages: total_pages,
159
+ total_count: total_count,
160
+ per_page: per_page,
161
+ has_previous: page > 1,
162
+ has_next: page < total_pages,
163
+ previous_page: page > 1 ? page - 1 : nil,
164
+ next_page: page < total_pages ? page + 1 : nil
165
+ }
166
+ end
167
+
168
+ # Ensure CSRF token is properly set for forms in the engine
169
+ def ensure_csrf_token
170
+ # Force generation of CSRF token if not present
171
+ form_authenticity_token
172
+ end
3
173
  end
4
174
  end
@@ -0,0 +1,280 @@
1
+ require 'csv'
2
+
3
+ module Beskar
4
+ class BannedIpsController < ApplicationController
5
+ before_action :set_banned_ip, only: [:show, :edit, :update, :destroy, :extend]
6
+
7
+ def index
8
+ @banned_ips = Beskar::BannedIp.order(banned_at: :desc)
9
+
10
+ # Apply filters
11
+ apply_filters!
12
+
13
+ # Paginate results
14
+ @pagination = paginate(@banned_ips, per_page: params[:per_page]&.to_i || 25)
15
+ @banned_ips = @pagination[:records]
16
+
17
+ # Get filter options
18
+ @ban_reasons = Beskar::BannedIp.distinct.pluck(:reason).compact.sort
19
+ @ban_statuses = ['active', 'expired', 'permanent', 'temporary']
20
+ end
21
+
22
+ def show
23
+ # Get related security events for this IP
24
+ @related_events = Beskar::SecurityEvent
25
+ .where(ip_address: @banned_ip.ip_address)
26
+ .order(created_at: :desc)
27
+ .limit(20)
28
+
29
+ # Calculate statistics
30
+ @stats = {
31
+ total_events: @related_events.count,
32
+ avg_risk_score: @related_events.average(:risk_score)&.round(1) || 0,
33
+ max_risk_score: @related_events.maximum(:risk_score) || 0,
34
+ first_seen: @related_events.minimum(:created_at),
35
+ last_seen: @related_events.maximum(:created_at)
36
+ }
37
+ end
38
+
39
+ def new
40
+ @banned_ip = Beskar::BannedIp.new
41
+ @suggested_ip = params[:ip_address]
42
+ @suggested_reason = params[:reason]
43
+ end
44
+
45
+ def create
46
+ manager = BannedIpManager.new(create_params)
47
+ manager.create
48
+
49
+ if manager.success?
50
+ redirect_to banned_ip_path(manager.banned_ip),
51
+ notice: "IP address #{manager.banned_ip.ip_address} has been banned successfully."
52
+ else
53
+ @banned_ip = manager.banned_ip
54
+ render :new
55
+ end
56
+ end
57
+
58
+ def edit
59
+ end
60
+
61
+ def update
62
+ if @banned_ip.update(banned_ip_params)
63
+ redirect_to banned_ip_path(@banned_ip),
64
+ notice: "Ban for IP #{@banned_ip.ip_address} has been updated."
65
+ else
66
+ render :edit
67
+ end
68
+ end
69
+
70
+ def destroy
71
+ ip_address = @banned_ip.ip_address
72
+ @banned_ip.destroy
73
+
74
+ redirect_to banned_ips_path,
75
+ notice: "IP address #{ip_address} has been unbanned."
76
+ end
77
+
78
+ def extend
79
+ # Default to 24 hours if no duration specified (e.g., from index page)
80
+ duration_param = params[:duration] || '24h'
81
+
82
+ duration = case duration_param
83
+ when '1h'
84
+ 1.hour
85
+ when '6h'
86
+ 6.hours
87
+ when '24h'
88
+ 24.hours
89
+ when '7d'
90
+ 7.days
91
+ when '30d'
92
+ 30.days
93
+ when 'permanent'
94
+ nil
95
+ else
96
+ 24.hours
97
+ end
98
+
99
+ if duration_param == 'permanent'
100
+ @banned_ip.update!(permanent: true, expires_at: nil)
101
+ message = "Ban for IP #{@banned_ip.ip_address} is now permanent."
102
+ else
103
+ # Ensure the ban can be extended (not already permanent)
104
+ if @banned_ip.permanent?
105
+ redirect_to banned_ip_path(@banned_ip), alert: "Cannot extend a permanent ban."
106
+ return
107
+ end
108
+
109
+ @banned_ip.extend_ban!(duration)
110
+ duration_text = duration_param == '24h' ? '24 hours' : duration_param.gsub(/(\d+)([hd])/, '\1 \2').gsub('h', 'hour(s)').gsub('d', 'day(s)')
111
+ message = "Ban for IP #{@banned_ip.ip_address} has been extended by #{duration_text}."
112
+ end
113
+
114
+ redirect_to banned_ip_path(@banned_ip), notice: message
115
+ rescue => e
116
+ Rails.logger.error "Failed to extend ban: #{e.message}"
117
+ redirect_to banned_ip_path(@banned_ip), alert: "Failed to extend ban: #{e.message}"
118
+ end
119
+
120
+ def bulk_action
121
+ case params[:bulk_action]
122
+ when 'unban'
123
+ unban_selected
124
+ when 'make_permanent'
125
+ make_permanent_selected
126
+ when 'extend'
127
+ extend_selected
128
+ else
129
+ redirect_to banned_ips_path, alert: "Unknown action."
130
+ end
131
+ end
132
+
133
+ def export
134
+ @banned_ips = Beskar::BannedIp.all
135
+ apply_filters!
136
+
137
+ respond_to do |format|
138
+ format.csv do
139
+ send_data generate_csv(@banned_ips),
140
+ filename: "banned-ips-#{Date.current}.csv",
141
+ type: 'text/csv'
142
+ end
143
+ format.json do
144
+ render json: @banned_ips.as_json(except: [:updated_at])
145
+ end
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ def set_banned_ip
152
+ @banned_ip = Beskar::BannedIp.find(params[:id])
153
+ end
154
+
155
+ def banned_ip_params
156
+ params.require(:banned_ip).permit(
157
+ :ip_address, :reason, :details, :permanent,
158
+ :expires_at, :violation_count, metadata: {}
159
+ )
160
+ end
161
+
162
+ def create_params
163
+ banned_ip_params.to_h.merge(
164
+ ban_type: params[:ban_type],
165
+ duration: params[:duration]
166
+ ).symbolize_keys
167
+ end
168
+
169
+ def apply_filters!
170
+ # Filter by status
171
+ case params[:status]
172
+ when 'active'
173
+ @banned_ips = @banned_ips.active
174
+ when 'expired'
175
+ @banned_ips = @banned_ips.expired
176
+ when 'permanent'
177
+ @banned_ips = @banned_ips.permanent
178
+ when 'temporary'
179
+ @banned_ips = @banned_ips.temporary
180
+ end
181
+
182
+ # Filter by reason
183
+ if params[:reason].present?
184
+ @banned_ips = @banned_ips.by_reason(params[:reason])
185
+ end
186
+
187
+ # Filter by IP address (partial match)
188
+ if params[:ip_search].present?
189
+ @banned_ips = @banned_ips.where("ip_address LIKE ?", "%#{params[:ip_search]}%")
190
+ end
191
+
192
+ # Filter by date range
193
+ if params[:banned_after].present?
194
+ begin
195
+ date = Date.parse(params[:banned_after])
196
+ @banned_ips = @banned_ips.where("banned_at >= ?", date.beginning_of_day)
197
+ rescue ArgumentError
198
+ # Invalid date, ignore
199
+ end
200
+ end
201
+
202
+ if params[:banned_before].present?
203
+ begin
204
+ date = Date.parse(params[:banned_before])
205
+ @banned_ips = @banned_ips.where("banned_at <= ?", date.end_of_day)
206
+ rescue ArgumentError
207
+ # Invalid date, ignore
208
+ end
209
+ end
210
+ end
211
+
212
+ def unban_selected
213
+ if params[:ip_ids].present?
214
+ banned_ips = Beskar::BannedIp.where(id: params[:ip_ids])
215
+ count = banned_ips.count
216
+
217
+ banned_ips.destroy_all
218
+
219
+ redirect_to banned_ips_path,
220
+ notice: "#{count} IP(s) have been unbanned."
221
+ else
222
+ redirect_to banned_ips_path, alert: "No IPs selected."
223
+ end
224
+ end
225
+
226
+ def make_permanent_selected
227
+ if params[:ip_ids].present?
228
+ count = Beskar::BannedIp.where(id: params[:ip_ids])
229
+ .update_all(permanent: true, expires_at: nil)
230
+
231
+ redirect_to banned_ips_path,
232
+ notice: "#{count} ban(s) have been made permanent."
233
+ else
234
+ redirect_to banned_ips_path, alert: "No IPs selected."
235
+ end
236
+ end
237
+
238
+ def extend_selected
239
+ if params[:ip_ids].present? && params[:duration].present?
240
+ banned_ips = Beskar::BannedIp.where(id: params[:ip_ids])
241
+
242
+ duration = case params[:duration]
243
+ when '24h' then 24.hours
244
+ when '7d' then 7.days
245
+ when '30d' then 30.days
246
+ else 24.hours
247
+ end
248
+
249
+ banned_ips.each do |banned_ip|
250
+ banned_ip.extend_ban!(duration)
251
+ end
252
+
253
+ redirect_to banned_ips_path,
254
+ notice: "#{banned_ips.count} ban(s) have been extended."
255
+ else
256
+ redirect_to banned_ips_path, alert: "No IPs selected or duration not specified."
257
+ end
258
+ end
259
+
260
+ def generate_csv(banned_ips)
261
+ require 'csv'
262
+
263
+ CSV.generate(headers: true) do |csv|
264
+ csv << ['IP Address', 'Reason', 'Banned At', 'Expires At', 'Status', 'Violation Count', 'Details']
265
+
266
+ banned_ips.find_each do |ban|
267
+ csv << [
268
+ ban.ip_address,
269
+ ban.reason,
270
+ ban.banned_at.strftime('%Y-%m-%d %H:%M:%S'),
271
+ ban.expires_at&.strftime('%Y-%m-%d %H:%M:%S') || (ban.permanent? ? 'Never (Permanent)' : '-'),
272
+ ban.active? ? 'Active' : 'Expired',
273
+ ban.violation_count,
274
+ ban.details || '-'
275
+ ]
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,70 @@
1
+ module Beskar
2
+ class DashboardController < ApplicationController
3
+ def index
4
+ # Time ranges for statistics
5
+ @time_range = params[:time_range] || '24h'
6
+ @start_time = calculate_start_time(@time_range)
7
+
8
+ # Overview statistics
9
+ @stats = {
10
+ total_events: Beskar::SecurityEvent.where("created_at >= ?", @start_time).count,
11
+ failed_logins: Beskar::SecurityEvent.where("created_at >= ?", @start_time).login_failures.count,
12
+ blocked_ips: Beskar::BannedIp.active.count,
13
+ high_risk_events: Beskar::SecurityEvent.where("created_at >= ?", @start_time).high_risk.count,
14
+ critical_threats: Beskar::SecurityEvent.where("created_at >= ?", @start_time).critical_risk.count
15
+ }
16
+
17
+ # Recent activity
18
+ @recent_events = Beskar::SecurityEvent
19
+ .includes(:user)
20
+ .order(created_at: :desc)
21
+ .limit(10)
22
+
23
+ # Top threat IPs
24
+ @top_threat_ips = Beskar::SecurityEvent
25
+ .where("created_at >= ?", @start_time)
26
+ .group(:ip_address)
27
+ .select("ip_address, COUNT(*) as event_count, AVG(risk_score) as avg_risk_score, MAX(risk_score) as max_risk_score")
28
+ .having("COUNT(*) > 1")
29
+ .order("event_count DESC, avg_risk_score DESC")
30
+ .limit(5)
31
+
32
+ # Event types distribution
33
+ @event_distribution = Beskar::SecurityEvent
34
+ .where("created_at >= ?", @start_time)
35
+ .group(:event_type)
36
+ .count
37
+ .sort_by { |_, count| -count }
38
+
39
+ # Risk score distribution
40
+ @risk_distribution = {
41
+ low: Beskar::SecurityEvent.where("created_at >= ? AND risk_score < 30", @start_time).count,
42
+ medium: Beskar::SecurityEvent.where("created_at >= ? AND risk_score BETWEEN 30 AND 60", @start_time).count,
43
+ high: Beskar::SecurityEvent.where("created_at >= ? AND risk_score BETWEEN 61 AND 85", @start_time).count,
44
+ critical: Beskar::SecurityEvent.where("created_at >= ? AND risk_score > 85", @start_time).count
45
+ }
46
+
47
+ # Currently active bans
48
+ @active_bans = Beskar::BannedIp.active.order(banned_at: :desc).limit(5)
49
+ end
50
+
51
+ private
52
+
53
+ def calculate_start_time(range)
54
+ case range
55
+ when '1h'
56
+ 1.hour.ago
57
+ when '6h'
58
+ 6.hours.ago
59
+ when '24h'
60
+ 24.hours.ago
61
+ when '7d'
62
+ 7.days.ago
63
+ when '30d'
64
+ 30.days.ago
65
+ else
66
+ 24.hours.ago
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,182 @@
1
+ require 'csv'
2
+
3
+ module Beskar
4
+ class SecurityEventsController < ApplicationController
5
+ def index
6
+ @events = Beskar::SecurityEvent.preload(:user).order(created_at: :desc)
7
+
8
+ # Apply filters
9
+ apply_filters!
10
+
11
+ # Paginate results
12
+ @pagination = paginate(@events, per_page: params[:per_page]&.to_i || 25)
13
+ @events = @pagination[:records]
14
+
15
+ # Get filter options for dropdowns
16
+ @event_types = Beskar::SecurityEvent.distinct.pluck(:event_type).sort
17
+ @risk_levels = ['low', 'medium', 'high', 'critical']
18
+ end
19
+
20
+ def show
21
+ @event = Beskar::SecurityEvent.find(params[:id])
22
+
23
+ # Find related events
24
+ @related_events = Beskar::SecurityEvent
25
+ .where.not(id: @event.id)
26
+ .where(ip_address: @event.ip_address)
27
+ .order(created_at: :desc)
28
+ .limit(10)
29
+
30
+ # Get user's recent events if user exists
31
+ if @event.user.present?
32
+ @user_events = @event.user.security_events
33
+ .where.not(id: @event.id)
34
+ .order(created_at: :desc)
35
+ .limit(10)
36
+ end
37
+
38
+ # Check if IP is banned
39
+ @ip_ban = Beskar::BannedIp.find_by(ip_address: @event.ip_address)
40
+ end
41
+
42
+ def export
43
+ @events = Beskar::SecurityEvent.preload(:user)
44
+
45
+ # Apply same filters as index
46
+ apply_filters!
47
+
48
+ respond_to do |format|
49
+ format.csv do
50
+ send_data generate_csv(@events),
51
+ filename: "security-events-#{Date.current}.csv",
52
+ type: 'text/csv'
53
+ end
54
+ format.json do
55
+ render json: @events.as_json(
56
+ include: { user: { only: [:id, :email] } },
57
+ except: [:updated_at]
58
+ )
59
+ end
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def apply_filters!
66
+ # Filter by event type
67
+ if params[:event_type].present?
68
+ @events = @events.where(event_type: params[:event_type])
69
+ end
70
+
71
+ # Filter by risk level
72
+ if params[:risk_level].present?
73
+ @events = case params[:risk_level]
74
+ when 'low'
75
+ @events.where("risk_score < 30")
76
+ when 'medium'
77
+ @events.where("risk_score BETWEEN 30 AND 60")
78
+ when 'high'
79
+ @events.where("risk_score BETWEEN 61 AND 85")
80
+ when 'critical'
81
+ @events.where("risk_score > 85")
82
+ else
83
+ @events
84
+ end
85
+ end
86
+
87
+ # Filter by IP address
88
+ if params[:ip_address].present?
89
+ @events = @events.where("ip_address LIKE ?", "%#{params[:ip_address]}%")
90
+ end
91
+
92
+ # Filter by user email (if attempted_email is stored)
93
+ if params[:email].present?
94
+ # Search in attempted_email column and metadata (avoid joining polymorphic)
95
+ @events = @events.where("attempted_email LIKE ? OR metadata LIKE ?",
96
+ "%#{params[:email]}%", "%#{params[:email]}%")
97
+ end
98
+
99
+ # Filter by date range
100
+ if params[:start_date].present?
101
+ begin
102
+ start_date = Date.parse(params[:start_date])
103
+ @events = @events.where("created_at >= ?", start_date.beginning_of_day)
104
+ rescue ArgumentError
105
+ # Invalid date, ignore filter
106
+ end
107
+ end
108
+
109
+ if params[:end_date].present?
110
+ begin
111
+ end_date = Date.parse(params[:end_date])
112
+ @events = @events.where("created_at <= ?", end_date.end_of_day)
113
+ rescue ArgumentError
114
+ # Invalid date, ignore filter
115
+ end
116
+ end
117
+
118
+ # Quick time range filters
119
+ if params[:time_range].present?
120
+ start_time = case params[:time_range]
121
+ when 'last_hour'
122
+ 1.hour.ago
123
+ when 'last_24h'
124
+ 24.hours.ago
125
+ when 'last_7d'
126
+ 7.days.ago
127
+ when 'last_30d'
128
+ 30.days.ago
129
+ else
130
+ nil
131
+ end
132
+
133
+ @events = @events.where("created_at >= ?", start_time) if start_time
134
+ end
135
+
136
+ # Filter by threat level
137
+ if params[:threats_only] == 'true'
138
+ @events = @events.high_risk
139
+ end
140
+
141
+ # Search in metadata
142
+ if params[:search].present?
143
+ search_term = "%#{params[:search]}%"
144
+ # Use database-agnostic approach for metadata search
145
+ if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL'
146
+ @events = @events.where(
147
+ "ip_address LIKE ? OR user_agent LIKE ? OR attempted_email LIKE ? OR metadata::text LIKE ?",
148
+ search_term, search_term, search_term, search_term
149
+ )
150
+ else
151
+ # For SQLite and other databases, search in regular columns
152
+ # SQLite's JSON support varies by version, so we'll search in standard columns
153
+ @events = @events.where(
154
+ "ip_address LIKE ? OR user_agent LIKE ? OR attempted_email LIKE ? OR event_type LIKE ?",
155
+ search_term, search_term, search_term, search_term
156
+ )
157
+ end
158
+ end
159
+ end
160
+
161
+ def generate_csv(events)
162
+ require 'csv'
163
+
164
+ CSV.generate(headers: true) do |csv|
165
+ csv << ['ID', 'Date/Time', 'Event Type', 'IP Address', 'User', 'Risk Score', 'User Agent', 'Details']
166
+
167
+ events.find_each do |event|
168
+ csv << [
169
+ event.id,
170
+ event.created_at.strftime('%Y-%m-%d %H:%M:%S'),
171
+ event.event_type,
172
+ event.ip_address,
173
+ event.user&.try(:email) || event.attempted_email || '-',
174
+ event.risk_score,
175
+ event.user_agent,
176
+ event.details || event.metadata&.dig("message") || '-'
177
+ ]
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end