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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +143 -0
- data/README.md +987 -21
- data/app/controllers/beskar/application_controller.rb +170 -0
- data/app/controllers/beskar/banned_ips_controller.rb +280 -0
- data/app/controllers/beskar/dashboard_controller.rb +70 -0
- data/app/controllers/beskar/security_events_controller.rb +182 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +70 -0
- data/app/models/beskar/banned_ip.rb +193 -0
- data/app/models/beskar/security_event.rb +64 -0
- data/app/services/beskar/banned_ip_manager.rb +78 -0
- data/app/views/beskar/banned_ips/edit.html.erb +259 -0
- data/app/views/beskar/banned_ips/index.html.erb +361 -0
- data/app/views/beskar/banned_ips/new.html.erb +310 -0
- data/app/views/beskar/banned_ips/show.html.erb +310 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +309 -0
- data/app/views/beskar/security_events/show.html.erb +307 -0
- data/app/views/layouts/beskar/application.html.erb +647 -5
- data/config/locales/en.yml +10 -0
- data/config/routes.rb +41 -0
- data/db/migrate/20251016000001_create_beskar_security_events.rb +25 -0
- data/db/migrate/20251016000002_create_beskar_banned_ips.rb +23 -0
- data/lib/beskar/configuration.rb +214 -0
- data/lib/beskar/engine.rb +105 -0
- data/lib/beskar/logger.rb +293 -0
- data/lib/beskar/middleware/request_analyzer.rb +305 -0
- data/lib/beskar/middleware.rb +4 -0
- data/lib/beskar/models/security_trackable.rb +25 -0
- data/lib/beskar/models/security_trackable_authenticable.rb +167 -0
- data/lib/beskar/models/security_trackable_devise.rb +82 -0
- data/lib/beskar/models/security_trackable_generic.rb +355 -0
- data/lib/beskar/services/account_locker.rb +263 -0
- data/lib/beskar/services/device_detector.rb +250 -0
- data/lib/beskar/services/geolocation_service.rb +392 -0
- data/lib/beskar/services/ip_whitelist.rb +113 -0
- data/lib/beskar/services/rate_limiter.rb +257 -0
- data/lib/beskar/services/waf.rb +551 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar.rb +32 -1
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
- data/lib/tasks/beskar_tasks.rake +121 -4
- 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
|