beskar 0.1.0 → 0.2.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 +150 -19
- data/README.md +142 -122
- data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
- data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
- data/app/controllers/beskar/application_controller.rb +69 -25
- data/app/controllers/beskar/banned_ips_controller.rb +116 -141
- data/app/controllers/beskar/dashboard_controller.rb +20 -28
- data/app/controllers/beskar/security_events_controller.rb +37 -55
- data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
- data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
- data/app/jobs/beskar/notification_job.rb +33 -0
- data/app/mailers/beskar/security_mailer.rb +59 -0
- data/app/models/beskar/administrative_action.rb +41 -0
- data/app/models/beskar/banned_ip.rb +91 -132
- data/app/models/beskar/security_event.rb +37 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +16 -6
- data/app/views/beskar/administrative_actions/index.html.erb +33 -0
- data/app/views/beskar/administrative_actions/show.html.erb +21 -0
- data/app/views/beskar/banned_ips/edit.html.erb +25 -89
- data/app/views/beskar/banned_ips/index.html.erb +20 -62
- data/app/views/beskar/banned_ips/new.html.erb +18 -138
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +9 -15
- data/app/views/beskar/dashboard/index.html.erb +4 -4
- data/app/views/beskar/security_events/index.html.erb +8 -15
- data/app/views/beskar/security_events/show.html.erb +6 -20
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/layouts/beskar/_behavior.html.erb +121 -0
- data/app/views/layouts/beskar/application.html.erb +9 -76
- data/config/routes.rb +10 -21
- data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
- data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
- data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
- data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
- data/docs/README.md +73 -0
- data/docs/archive/project-documentation.md +659 -0
- data/docs/audits/project-review.md +437 -0
- data/docs/audits/repair-status.md +216 -0
- data/docs/guides/audit-and-waf.md +175 -0
- data/docs/guides/audit-lifecycle.md +172 -0
- data/docs/guides/authentication.md +213 -0
- data/docs/guides/configuration.md +182 -0
- data/docs/guides/dashboard-and-search.md +251 -0
- data/docs/guides/notifications-and-recovery.md +157 -0
- data/docs/guides/risk-scoring.md +116 -0
- data/docs/operations/monitor-only-mode.md +85 -0
- data/docs/operations/security-hardening.md +167 -0
- data/docs/operations/state-storage.md +144 -0
- data/docs/research/rust-performance-assessment.md +69 -0
- data/lib/beskar/configuration.rb +84 -13
- data/lib/beskar/configuration_validator.rb +188 -0
- data/lib/beskar/devise_authentication.rb +24 -0
- data/lib/beskar/engine.rb +21 -88
- data/lib/beskar/logger.rb +30 -35
- data/lib/beskar/middleware/request_analyzer.rb +41 -82
- data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
- data/lib/beskar/models/security_trackable_devise.rb +32 -23
- data/lib/beskar/models/security_trackable_generic.rb +169 -212
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +85 -76
- data/lib/beskar/services/administrative_audit.rb +36 -0
- data/lib/beskar/services/administrative_bans.rb +104 -0
- data/lib/beskar/services/audit_data.rb +72 -0
- data/lib/beskar/services/authentication.rb +31 -0
- data/lib/beskar/services/authentication_attempt.rb +141 -0
- data/lib/beskar/services/ban_expiry.rb +28 -0
- data/lib/beskar/services/device_detector.rb +32 -41
- data/lib/beskar/services/event_search.rb +58 -0
- data/lib/beskar/services/geolocation_service.rb +83 -114
- data/lib/beskar/services/ip_whitelist.rb +31 -40
- data/lib/beskar/services/location_assessment.rb +109 -0
- data/lib/beskar/services/native_account_lock.rb +82 -0
- data/lib/beskar/services/notifications.rb +46 -0
- data/lib/beskar/services/rate_limiter.rb +99 -125
- data/lib/beskar/services/request_context.rb +64 -0
- data/lib/beskar/services/risk_assessment.rb +58 -0
- data/lib/beskar/services/session_revocation.rb +62 -0
- data/lib/beskar/services/waf.rb +164 -280
- data/lib/beskar/services/waf_request.rb +60 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar/warden_authentication.rb +53 -0
- data/lib/beskar.rb +53 -4
- data/lib/generators/beskar/install/install_generator.rb +36 -36
- data/lib/generators/beskar/install/templates/initializer.rb.tt +104 -20
- data/lib/tasks/beskar_tasks.rake +15 -19
- metadata +60 -8
|
@@ -3,15 +3,44 @@ module Beskar
|
|
|
3
3
|
# Use the main app's CSRF protection settings
|
|
4
4
|
protect_from_forgery with: :exception, prepend: true
|
|
5
5
|
|
|
6
|
-
layout
|
|
6
|
+
layout "beskar/application"
|
|
7
7
|
|
|
8
8
|
# Ensure CSRF token is available for forms
|
|
9
9
|
before_action :ensure_csrf_token
|
|
10
10
|
|
|
11
11
|
before_action :authenticate_admin!
|
|
12
|
+
before_action :authorize_admin_action!
|
|
12
13
|
|
|
13
14
|
private
|
|
14
15
|
|
|
16
|
+
def authorize_admin_action!
|
|
17
|
+
permission = if controller_name == "administrative_actions"
|
|
18
|
+
:read_audit
|
|
19
|
+
elsif action_name == "export"
|
|
20
|
+
:export
|
|
21
|
+
elsif controller_name == "banned_ips" && %w[new create edit update destroy extend review bulk_action].include?(action_name)
|
|
22
|
+
:manage_bans
|
|
23
|
+
else
|
|
24
|
+
:read
|
|
25
|
+
end
|
|
26
|
+
callback = Beskar.configuration.authorize_admin
|
|
27
|
+
allowed = callback && instance_exec(request, permission, &callback) == true
|
|
28
|
+
head :forbidden unless performed? || allowed
|
|
29
|
+
rescue => error
|
|
30
|
+
Beskar::Logger.warn("Administrative authorization unavailable (#{error.class})")
|
|
31
|
+
head :service_unavailable unless performed?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def administrative_actor!
|
|
35
|
+
callback = Beskar.configuration.audit_actor
|
|
36
|
+
actor = instance_exec(request, &callback) if callback
|
|
37
|
+
raise Services::AdministrativeAudit::Unavailable unless Services::AdministrativeBans.valid_actor?(actor)
|
|
38
|
+
actor
|
|
39
|
+
rescue => error
|
|
40
|
+
Beskar::Logger.warn("Administrative actor unavailable (#{error.class})")
|
|
41
|
+
raise Services::AdministrativeAudit::Unavailable, "Administrative identity unavailable"
|
|
42
|
+
end
|
|
43
|
+
|
|
15
44
|
# Override this method in your application to implement authentication
|
|
16
45
|
# For example, you might want to use Devise's authenticate_admin! or
|
|
17
46
|
# a custom authentication method
|
|
@@ -29,19 +58,20 @@ module Beskar
|
|
|
29
58
|
# This gives the block access to controller methods like cookies, session,
|
|
30
59
|
# authenticate_or_request_with_http_basic, etc.
|
|
31
60
|
result = instance_exec(request, &Beskar.configuration.authenticate_admin)
|
|
61
|
+
return false if performed?
|
|
32
62
|
return true if result
|
|
33
63
|
|
|
34
64
|
handle_authentication_failure
|
|
35
65
|
false
|
|
36
66
|
rescue => e
|
|
37
|
-
Rails.logger.error "Beskar authentication error: #{e.
|
|
67
|
+
Rails.logger.error "Beskar authentication error: #{e.class}"
|
|
38
68
|
handle_authentication_failure
|
|
39
69
|
false
|
|
40
70
|
end
|
|
41
71
|
|
|
42
72
|
def handle_missing_authentication_configuration
|
|
43
73
|
# Log the configuration error for debugging, but return 404 to avoid revealing Beskar is installed
|
|
44
|
-
error_message = <<~MSG
|
|
74
|
+
error_message = <<~'MSG'
|
|
45
75
|
Beskar authentication not configured!
|
|
46
76
|
|
|
47
77
|
Configure Beskar.configuration.authenticate_admin in your initializer:
|
|
@@ -57,14 +87,16 @@ module Beskar
|
|
|
57
87
|
|
|
58
88
|
# Example 2: HTTP Basic Auth (uses controller method)
|
|
59
89
|
# authenticate_or_request_with_http_basic do |username, password|
|
|
60
|
-
# username
|
|
90
|
+
# Beskar::Services::RequestContext.secure_match?(username, ENV['BESKAR_USERNAME']) &&
|
|
91
|
+
# Beskar::Services::RequestContext.secure_match?(password, ENV['BESKAR_PASSWORD'])
|
|
61
92
|
# end
|
|
62
93
|
|
|
63
94
|
# Example 3: Cookie-based auth (uses controller cookies)
|
|
64
|
-
# cookies.signed[:admin_token]
|
|
95
|
+
# Beskar::Services::RequestContext.secure_match?(cookies.signed[:admin_token], ENV['BESKAR_ADMIN_TOKEN'])
|
|
65
96
|
|
|
66
97
|
# Example 4: Simple token-based auth
|
|
67
|
-
#
|
|
98
|
+
# token = ENV['BESKAR_ADMIN_TOKEN']
|
|
99
|
+
# token.present? && Beskar::Services::RequestContext.secure_match?(request.headers['Authorization'], "Bearer #{token}")
|
|
68
100
|
|
|
69
101
|
# Example 5: For development/testing (NOT for production!)
|
|
70
102
|
# Rails.env.development? || Rails.env.test?
|
|
@@ -77,14 +109,14 @@ module Beskar
|
|
|
77
109
|
|
|
78
110
|
def handle_authentication_failure
|
|
79
111
|
# Return 404 to avoid revealing that Beskar is installed
|
|
80
|
-
render_404
|
|
112
|
+
render_404 unless performed?
|
|
81
113
|
end
|
|
82
114
|
|
|
83
115
|
def render_404
|
|
84
116
|
respond_to do |format|
|
|
85
117
|
format.html { render file: "#{Rails.public_path}/404.html", status: :not_found, layout: false }
|
|
86
|
-
format.json { render json: {
|
|
87
|
-
format.any
|
|
118
|
+
format.json { render json: {error: "Not found"}, status: :not_found }
|
|
119
|
+
format.any { head :not_found }
|
|
88
120
|
end
|
|
89
121
|
end
|
|
90
122
|
|
|
@@ -95,6 +127,11 @@ module Beskar
|
|
|
95
127
|
end
|
|
96
128
|
helper_method :format_timestamp
|
|
97
129
|
|
|
130
|
+
def ban_expiry_input_value(time)
|
|
131
|
+
time&.utc&.iso8601(3)&.delete_suffix("Z")
|
|
132
|
+
end
|
|
133
|
+
helper_method :ban_expiry_input_value
|
|
134
|
+
|
|
98
135
|
# Helper method to format IP addresses with location if available
|
|
99
136
|
def format_ip_with_location(ip, metadata = {})
|
|
100
137
|
return ip unless metadata.present?
|
|
@@ -107,27 +144,34 @@ module Beskar
|
|
|
107
144
|
end
|
|
108
145
|
|
|
109
146
|
return ip if location_parts.empty?
|
|
110
|
-
"#{ip} (#{location_parts.join(
|
|
147
|
+
"#{ip} (#{location_parts.join(", ")})"
|
|
111
148
|
end
|
|
112
149
|
helper_method :format_ip_with_location
|
|
113
150
|
|
|
114
151
|
# Helper to determine risk level badge color
|
|
115
152
|
def risk_level_class(risk_score)
|
|
116
|
-
|
|
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
|
|
153
|
+
RiskLevel::BADGES.fetch(RiskLevel.for(risk_score), "neutral")
|
|
128
154
|
end
|
|
129
155
|
helper_method :risk_level_class
|
|
130
156
|
|
|
157
|
+
def risk_level_color(risk_score)
|
|
158
|
+
RiskLevel::COLORS.fetch(RiskLevel.for(risk_score), "#697386")
|
|
159
|
+
end
|
|
160
|
+
helper_method :risk_level_color
|
|
161
|
+
|
|
162
|
+
def risk_level_label(risk_score)
|
|
163
|
+
level = RiskLevel.for(risk_score)
|
|
164
|
+
level ? "#{level.to_s.capitalize} Risk" : "Unknown Risk"
|
|
165
|
+
end
|
|
166
|
+
helper_method :risk_level_label
|
|
167
|
+
|
|
168
|
+
def audit_user_label(event)
|
|
169
|
+
attempted = event.attempted_email
|
|
170
|
+
Services::AuditData.user_email(event.user) || (Services::AuditData.field(:email, attempted, limit: 320) if attempted.present?) ||
|
|
171
|
+
(event.user_id ? "User ##{event.user_id}" : "-")
|
|
172
|
+
end
|
|
173
|
+
helper_method :audit_user_label
|
|
174
|
+
|
|
131
175
|
# Helper to format event type for display
|
|
132
176
|
def format_event_type(event_type)
|
|
133
177
|
event_type.to_s.humanize.titleize
|
|
@@ -147,7 +191,7 @@ module Beskar
|
|
|
147
191
|
page = 1 if page < 1
|
|
148
192
|
|
|
149
193
|
total_count = collection.count
|
|
150
|
-
total_pages = total_count > 0 ? (total_count.to_f / per_page).ceil : 0
|
|
194
|
+
total_pages = (total_count > 0) ? (total_count.to_f / per_page).ceil : 0
|
|
151
195
|
|
|
152
196
|
offset = (page - 1) * per_page
|
|
153
197
|
records = collection.limit(per_page).offset(offset)
|
|
@@ -160,8 +204,8 @@ module Beskar
|
|
|
160
204
|
per_page: per_page,
|
|
161
205
|
has_previous: page > 1,
|
|
162
206
|
has_next: page < total_pages,
|
|
163
|
-
previous_page: page > 1 ? page - 1 : nil,
|
|
164
|
-
next_page: page < total_pages ? page + 1 : nil
|
|
207
|
+
previous_page: (page > 1) ? page - 1 : nil,
|
|
208
|
+
next_page: (page < total_pages) ? page + 1 : nil
|
|
165
209
|
}
|
|
166
210
|
end
|
|
167
211
|
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
require
|
|
1
|
+
require "csv"
|
|
2
2
|
|
|
3
3
|
module Beskar
|
|
4
4
|
class BannedIpsController < ApplicationController
|
|
5
|
-
|
|
5
|
+
include Controllers::AuditExport
|
|
6
|
+
|
|
7
|
+
class ActorUnavailable < StandardError; end
|
|
8
|
+
before_action :set_banned_ip, only: [:show, :edit, :update, :destroy, :extend, :review]
|
|
9
|
+
before_action :prepare_administration, only: [:create, :update, :destroy, :extend, :bulk_action]
|
|
10
|
+
rescue_from ActorUnavailable, with: :administration_unavailable
|
|
11
|
+
rescue_from Services::AdministrativeBans::InvalidInput, Services::BanExpiry::InvalidInput do |error|
|
|
12
|
+
render plain: error.message, status: :unprocessable_content
|
|
13
|
+
end
|
|
14
|
+
rescue_from ActiveRecord::ActiveRecordError, with: :administration_unavailable
|
|
6
15
|
|
|
7
16
|
def index
|
|
8
17
|
@banned_ips = Beskar::BannedIp.order(banned_at: :desc)
|
|
@@ -16,23 +25,21 @@ module Beskar
|
|
|
16
25
|
|
|
17
26
|
# Get filter options
|
|
18
27
|
@ban_reasons = Beskar::BannedIp.distinct.pluck(:reason).compact.sort
|
|
19
|
-
@ban_statuses = [
|
|
28
|
+
@ban_statuses = ["active", "expired", "permanent", "temporary"]
|
|
20
29
|
end
|
|
21
30
|
|
|
22
31
|
def show
|
|
23
32
|
# Get related security events for this IP
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
.order(created_at: :desc)
|
|
27
|
-
.limit(20)
|
|
33
|
+
events = Beskar::SecurityEvent.where(ip_address: @banned_ip.ip_address)
|
|
34
|
+
@related_events = events.preload(:user).order(created_at: :desc, id: :desc).limit(20)
|
|
28
35
|
|
|
29
36
|
# Calculate statistics
|
|
30
37
|
@stats = {
|
|
31
|
-
total_events:
|
|
32
|
-
avg_risk_score:
|
|
33
|
-
max_risk_score:
|
|
34
|
-
first_seen:
|
|
35
|
-
last_seen:
|
|
38
|
+
total_events: events.count,
|
|
39
|
+
avg_risk_score: events.average(:risk_score)&.round(1) || 0,
|
|
40
|
+
max_risk_score: events.maximum(:risk_score) || 0,
|
|
41
|
+
first_seen: events.minimum(:created_at),
|
|
42
|
+
last_seen: events.maximum(:created_at)
|
|
36
43
|
}
|
|
37
44
|
end
|
|
38
45
|
|
|
@@ -44,116 +51,132 @@ module Beskar
|
|
|
44
51
|
|
|
45
52
|
def create
|
|
46
53
|
manager = BannedIpManager.new(create_params)
|
|
47
|
-
manager.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
54
|
+
@banned_ip = @administration.create!(manager.build)
|
|
55
|
+
redirect_to banned_ip_path(@banned_ip), notice: "IP address #{@banned_ip.ip_address} has been banned successfully."
|
|
56
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
57
|
+
render_invalid_ban(error, :new)
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
def edit
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
def update
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
attributes = banned_ip_params.to_h
|
|
65
|
+
if attributes.key?("expires_at")
|
|
66
|
+
attributes["expires_at"] = if ActiveModel::Type::Boolean.new.cast(attributes.fetch("permanent", @banned_ip.permanent?))
|
|
67
|
+
nil
|
|
68
|
+
else
|
|
69
|
+
Services::BanExpiry.parse(attributes["expires_at"])
|
|
70
|
+
end
|
|
71
|
+
# HTML datetime-local only represents milliseconds. Preserve the stored
|
|
72
|
+
# microseconds when the displayed value was submitted without a change.
|
|
73
|
+
current_expiry = @banned_ip.expires_at
|
|
74
|
+
if params[:expiry_precision] == "milliseconds" && current_expiry &&
|
|
75
|
+
attributes["expires_at"] == current_expiry.change(usec: current_expiry.usec / 1000 * 1000)
|
|
76
|
+
attributes["expires_at"] = current_expiry
|
|
77
|
+
end
|
|
67
78
|
end
|
|
79
|
+
count = @administration.change!([@banned_ip.id], action: "update", attributes: attributes)
|
|
80
|
+
message = count.positive? ? "Ban for IP #{@banned_ip.ip_address} has been updated." : "No ban changes were necessary."
|
|
81
|
+
redirect_to banned_ip_path(@banned_ip), notice: message
|
|
82
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
83
|
+
render_invalid_ban(error, :edit)
|
|
68
84
|
end
|
|
69
85
|
|
|
70
86
|
def destroy
|
|
71
87
|
ip_address = @banned_ip.ip_address
|
|
72
|
-
@banned_ip.
|
|
88
|
+
@administration.change!([@banned_ip.id], action: "unban")
|
|
73
89
|
|
|
74
90
|
redirect_to banned_ips_path,
|
|
75
|
-
|
|
91
|
+
notice: "IP address #{ip_address} has been unbanned."
|
|
76
92
|
end
|
|
77
93
|
|
|
78
94
|
def extend
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
95
|
+
action = (params[:duration] == "permanent") ? "make_permanent" : "extend"
|
|
96
|
+
@administration.change!([@banned_ip.id], action: action, duration: params[:duration] || "24h")
|
|
97
|
+
redirect_to banned_ip_path(@banned_ip), notice: "Ban updated and administrative action recorded."
|
|
98
|
+
end
|
|
113
99
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
redirect_to banned_ip_path(@banned_ip), alert: "Failed to extend ban: #{e.message}"
|
|
100
|
+
def review
|
|
101
|
+
@operation = params[:operation]
|
|
102
|
+
head :bad_request unless %w[unban extend].include?(@operation)
|
|
118
103
|
end
|
|
119
104
|
|
|
120
105
|
def bulk_action
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
when 'extend'
|
|
127
|
-
extend_selected
|
|
128
|
-
else
|
|
129
|
-
redirect_to banned_ips_path, alert: "Unknown action."
|
|
130
|
-
end
|
|
106
|
+
action = params[:bulk_action]
|
|
107
|
+
raise Services::AdministrativeBans::InvalidInput, "Unknown bulk action" unless %w[unban extend make_permanent].include?(action)
|
|
108
|
+
count = @administration.change!(params[:ip_ids], action: action, duration: params[:duration])
|
|
109
|
+
description = {"unban" => "unbanned", "make_permanent" => "made permanent", "extend" => "extended"}.fetch(action)
|
|
110
|
+
redirect_to banned_ips_path, notice: "#{count} ban(s) #{description}; administrative actions recorded."
|
|
131
111
|
end
|
|
132
112
|
|
|
133
113
|
def export
|
|
134
114
|
@banned_ips = Beskar::BannedIp.all
|
|
135
115
|
apply_filters!
|
|
116
|
+
records = export_records(@banned_ips)
|
|
117
|
+
return if performed?
|
|
136
118
|
|
|
137
119
|
respond_to do |format|
|
|
138
120
|
format.csv do
|
|
139
|
-
send_data generate_csv(
|
|
121
|
+
send_data generate_csv(records),
|
|
140
122
|
filename: "banned-ips-#{Date.current}.csv",
|
|
141
|
-
type:
|
|
123
|
+
type: "text/csv"
|
|
142
124
|
end
|
|
143
125
|
format.json do
|
|
144
|
-
render json:
|
|
126
|
+
render json: records.map { |ban|
|
|
127
|
+
ban.attributes.slice("id", "ip_address", "reason", "details",
|
|
128
|
+
"permanent", "banned_at", "expires_at", "violation_count", "metadata", "created_at")
|
|
129
|
+
}
|
|
145
130
|
end
|
|
146
131
|
end
|
|
147
132
|
end
|
|
148
133
|
|
|
149
134
|
private
|
|
150
135
|
|
|
136
|
+
def ban_reason_options
|
|
137
|
+
options = [["Rate Limit Abuse", "rate_limit_abuse"], ["Authentication Abuse", "authentication_abuse"],
|
|
138
|
+
["WAF Violation", "waf_violation"], ["Brute Force Attack", "brute_force_attack"],
|
|
139
|
+
["Suspicious Activity", "suspicious_activity"], ["Manual Ban", "manual_ban"], ["Other", "other"]]
|
|
140
|
+
reason = @suggested_reason || @banned_ip.reason
|
|
141
|
+
options << [reason, reason] if reason.present? && options.none? { |_, value| value == reason }
|
|
142
|
+
options
|
|
143
|
+
end
|
|
144
|
+
helper_method :ban_reason_options
|
|
145
|
+
|
|
146
|
+
def prepare_administration
|
|
147
|
+
begin
|
|
148
|
+
callback = Beskar.configuration.audit_actor
|
|
149
|
+
actor = instance_exec(request, &callback) if callback
|
|
150
|
+
raise ActorUnavailable unless Services::AdministrativeBans.valid_actor?(actor)
|
|
151
|
+
rescue => error
|
|
152
|
+
Beskar::Logger.warn("Administrative actor unavailable (#{error.class})")
|
|
153
|
+
raise ActorUnavailable, "Administrative identity unavailable"
|
|
154
|
+
end
|
|
155
|
+
@administration = Services::AdministrativeBans.new(actor: actor, reason: params[:audit_reason], request_id: request.request_id)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def administration_unavailable(error)
|
|
159
|
+
return head :not_found if error.is_a?(ActiveRecord::RecordNotFound)
|
|
160
|
+
Beskar::Logger.warn("Administrative operation unavailable (#{error.class})")
|
|
161
|
+
render plain: "Administrative changes are unavailable. Reload state before retrying.", status: :service_unavailable
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def render_invalid_ban(error, template)
|
|
165
|
+
return administration_unavailable(error) unless error.record.is_a?(BannedIp)
|
|
166
|
+
@banned_ip = error.record
|
|
167
|
+
render template, status: :unprocessable_content
|
|
168
|
+
end
|
|
169
|
+
|
|
151
170
|
def set_banned_ip
|
|
152
171
|
@banned_ip = Beskar::BannedIp.find(params[:id])
|
|
153
172
|
end
|
|
154
173
|
|
|
155
174
|
def banned_ip_params
|
|
156
|
-
params.require(:banned_ip)
|
|
175
|
+
input = params.require(:banned_ip)
|
|
176
|
+
if input.key?(:expires_at) && !input[:expires_at].nil? && !input[:expires_at].is_a?(String)
|
|
177
|
+
raise Services::BanExpiry::InvalidInput, "Expiry must be a date and time string"
|
|
178
|
+
end
|
|
179
|
+
input.permit(
|
|
157
180
|
:ip_address, :reason, :details, :permanent,
|
|
158
181
|
:expires_at, :violation_count, metadata: {}
|
|
159
182
|
)
|
|
@@ -169,13 +192,13 @@ module Beskar
|
|
|
169
192
|
def apply_filters!
|
|
170
193
|
# Filter by status
|
|
171
194
|
case params[:status]
|
|
172
|
-
when
|
|
195
|
+
when "active"
|
|
173
196
|
@banned_ips = @banned_ips.active
|
|
174
|
-
when
|
|
197
|
+
when "expired"
|
|
175
198
|
@banned_ips = @banned_ips.expired
|
|
176
|
-
when
|
|
199
|
+
when "permanent"
|
|
177
200
|
@banned_ips = @banned_ips.permanent
|
|
178
|
-
when
|
|
201
|
+
when "temporary"
|
|
179
202
|
@banned_ips = @banned_ips.temporary
|
|
180
203
|
end
|
|
181
204
|
|
|
@@ -209,70 +232,22 @@ module Beskar
|
|
|
209
232
|
end
|
|
210
233
|
end
|
|
211
234
|
|
|
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
235
|
def generate_csv(banned_ips)
|
|
261
|
-
require
|
|
236
|
+
require "csv"
|
|
262
237
|
|
|
263
|
-
CSV.generate(headers: true) do |csv|
|
|
264
|
-
csv << [
|
|
238
|
+
CSV.generate(headers: true, force_quotes: true) do |csv|
|
|
239
|
+
csv << ["IP Address", "Reason", "Banned At", "Expires At", "Status", "Violation Count", "Details"]
|
|
265
240
|
|
|
266
|
-
banned_ips.
|
|
241
|
+
banned_ips.each do |ban|
|
|
267
242
|
csv << [
|
|
268
243
|
ban.ip_address,
|
|
269
244
|
ban.reason,
|
|
270
|
-
ban.banned_at.strftime(
|
|
271
|
-
ban.expires_at&.strftime(
|
|
272
|
-
ban.active? ?
|
|
245
|
+
ban.banned_at.strftime("%Y-%m-%d %H:%M:%S"),
|
|
246
|
+
ban.expires_at&.strftime("%Y-%m-%d %H:%M:%S") || (ban.permanent? ? "Never (Permanent)" : "-"),
|
|
247
|
+
ban.active? ? "Active" : "Expired",
|
|
273
248
|
ban.violation_count,
|
|
274
|
-
ban.details ||
|
|
275
|
-
]
|
|
249
|
+
ban.details || "-"
|
|
250
|
+
].map { |value| Services::AuditData.csv_cell(value) }
|
|
276
251
|
end
|
|
277
252
|
end
|
|
278
253
|
end
|
|
@@ -2,48 +2,40 @@ module Beskar
|
|
|
2
2
|
class DashboardController < ApplicationController
|
|
3
3
|
def index
|
|
4
4
|
# Time ranges for statistics
|
|
5
|
-
@time_range = params[:time_range] ||
|
|
5
|
+
@time_range = params[:time_range] || "24h"
|
|
6
6
|
@start_time = calculate_start_time(@time_range)
|
|
7
7
|
|
|
8
8
|
# Overview statistics
|
|
9
|
+
events = Beskar::SecurityEvent.where(created_at: @start_time..Time.current)
|
|
10
|
+
@event_distribution = events.group(:event_type).count.sort_by { |_, count| -count }
|
|
11
|
+
score_counts = events.group(:risk_score).count
|
|
12
|
+
@risk_distribution = RiskLevel::RANGES.keys.to_h { |level| [level, 0] }
|
|
13
|
+
score_counts.each do |score, count|
|
|
14
|
+
level = RiskLevel.for(score)
|
|
15
|
+
@risk_distribution[level] += count if level
|
|
16
|
+
end
|
|
9
17
|
@stats = {
|
|
10
|
-
total_events:
|
|
11
|
-
failed_logins:
|
|
18
|
+
total_events: @event_distribution.sum(&:last),
|
|
19
|
+
failed_logins: @event_distribution.to_h.fetch("login_failure", 0),
|
|
12
20
|
blocked_ips: Beskar::BannedIp.active.count,
|
|
13
|
-
high_risk_events:
|
|
14
|
-
critical_threats:
|
|
21
|
+
high_risk_events: @risk_distribution[:high] + @risk_distribution[:critical],
|
|
22
|
+
critical_threats: @risk_distribution[:critical]
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
# Recent activity
|
|
18
26
|
@recent_events = Beskar::SecurityEvent
|
|
19
27
|
.includes(:user)
|
|
20
|
-
.order(created_at: :desc)
|
|
28
|
+
.order(created_at: :desc, id: :desc)
|
|
21
29
|
.limit(10)
|
|
22
30
|
|
|
23
31
|
# Top threat IPs
|
|
24
|
-
@top_threat_ips =
|
|
25
|
-
.where("created_at >= ?", @start_time)
|
|
32
|
+
@top_threat_ips = events
|
|
26
33
|
.group(:ip_address)
|
|
27
34
|
.select("ip_address, COUNT(*) as event_count, AVG(risk_score) as avg_risk_score, MAX(risk_score) as max_risk_score")
|
|
28
35
|
.having("COUNT(*) > 1")
|
|
29
36
|
.order("event_count DESC, avg_risk_score DESC")
|
|
30
37
|
.limit(5)
|
|
31
38
|
|
|
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
39
|
# Currently active bans
|
|
48
40
|
@active_bans = Beskar::BannedIp.active.order(banned_at: :desc).limit(5)
|
|
49
41
|
end
|
|
@@ -52,15 +44,15 @@ module Beskar
|
|
|
52
44
|
|
|
53
45
|
def calculate_start_time(range)
|
|
54
46
|
case range
|
|
55
|
-
when
|
|
47
|
+
when "1h"
|
|
56
48
|
1.hour.ago
|
|
57
|
-
when
|
|
49
|
+
when "6h"
|
|
58
50
|
6.hours.ago
|
|
59
|
-
when
|
|
51
|
+
when "24h"
|
|
60
52
|
24.hours.ago
|
|
61
|
-
when
|
|
53
|
+
when "7d"
|
|
62
54
|
7.days.ago
|
|
63
|
-
when
|
|
55
|
+
when "30d"
|
|
64
56
|
30.days.ago
|
|
65
57
|
else
|
|
66
58
|
24.hours.ago
|