beskar 0.0.2 → 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 +274 -0
- data/README.md +412 -204
- 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 +214 -0
- data/app/controllers/beskar/banned_ips_controller.rb +255 -0
- data/app/controllers/beskar/dashboard_controller.rb +62 -0
- data/app/controllers/beskar/security_events_controller.rb +164 -0
- 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 +105 -105
- data/app/models/beskar/security_event.rb +51 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +88 -0
- 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 +195 -0
- data/app/views/beskar/banned_ips/index.html.erb +319 -0
- data/app/views/beskar/banned_ips/new.html.erb +190 -0
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +304 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +302 -0
- data/app/views/beskar/security_events/show.html.erb +293 -0
- 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 +581 -6
- data/config/routes.rb +30 -0
- 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 +105 -20
- 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 +288 -0
- data/lib/beskar/middleware/request_analyzer.rb +133 -99
- data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
- data/lib/beskar/models/security_trackable_devise.rb +34 -25
- data/lib/beskar/models/security_trackable_generic.rb +171 -214
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +90 -81
- 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 +311 -198
- 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 +54 -4
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
- data/lib/tasks/beskar_tasks.rake +25 -20
- metadata +93 -12
- data/lib/beskar/templates/beskar_initializer.rb +0 -107
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
module Channels
|
|
3
|
+
# Prepend to ApplicationCable::Channel to cover every subscription and inbound
|
|
4
|
+
# action. The connection resolves identity and the credential's signed epoch.
|
|
5
|
+
# Standard outbound channel transmissions are checked too. No polling of idle
|
|
6
|
+
# connections is needed; direct connection.transmit bypasses channel policy.
|
|
7
|
+
module SessionSecurity
|
|
8
|
+
def subscribe_to_channel
|
|
9
|
+
unless beskar_session_allowed?
|
|
10
|
+
reject
|
|
11
|
+
reject_subscription
|
|
12
|
+
return
|
|
13
|
+
end
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def perform_action(data)
|
|
18
|
+
unless beskar_session_allowed?
|
|
19
|
+
stop_all_streams
|
|
20
|
+
connection.close(reason: "authentication_revoked", reconnect: false)
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
super
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def transmit(data, via: nil)
|
|
29
|
+
unless beskar_session_allowed?
|
|
30
|
+
stop_all_streams
|
|
31
|
+
connection.close(reason: "authentication_revoked", reconnect: false)
|
|
32
|
+
return
|
|
33
|
+
end
|
|
34
|
+
super
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def beskar_session_allowed?
|
|
38
|
+
return false unless connection.respond_to?(:beskar_authenticated_user) && connection.respond_to?(:beskar_authenticated_generation)
|
|
39
|
+
Services::SessionRevocation.allowed?(connection.beskar_authenticated_user,
|
|
40
|
+
request: connection.request, token: connection.beskar_authenticated_generation)
|
|
41
|
+
rescue Services::AuthenticationAttempt::Unavailable
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
class AdministrativeActionsController < ApplicationController
|
|
3
|
+
before_action { response.headers["Cache-Control"] = "no-store" }
|
|
4
|
+
|
|
5
|
+
def index
|
|
6
|
+
records = AdministrativeAction.order(id: :desc)
|
|
7
|
+
records = records.where(target_type: "BannedIp", target_id: params[:target_id]) if params[:target_id].present?
|
|
8
|
+
@pagination = paginate(records)
|
|
9
|
+
@administrative_actions = @pagination[:records]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
@administrative_action = AdministrativeAction.find(params[:id])
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -1,4 +1,218 @@
|
|
|
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
|
+
before_action :authorize_admin_action!
|
|
13
|
+
|
|
14
|
+
private
|
|
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
|
+
|
|
44
|
+
# Override this method in your application to implement authentication
|
|
45
|
+
# For example, you might want to use Devise's authenticate_admin! or
|
|
46
|
+
# a custom authentication method
|
|
47
|
+
def authenticate_admin!
|
|
48
|
+
unless Beskar.configuration.authenticate_admin.present?
|
|
49
|
+
handle_missing_authentication_configuration
|
|
50
|
+
return false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
handle_custom_authentication
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handle_custom_authentication
|
|
57
|
+
# Execute the authentication block in the controller's context
|
|
58
|
+
# This gives the block access to controller methods like cookies, session,
|
|
59
|
+
# authenticate_or_request_with_http_basic, etc.
|
|
60
|
+
result = instance_exec(request, &Beskar.configuration.authenticate_admin)
|
|
61
|
+
return false if performed?
|
|
62
|
+
return true if result
|
|
63
|
+
|
|
64
|
+
handle_authentication_failure
|
|
65
|
+
false
|
|
66
|
+
rescue => e
|
|
67
|
+
Rails.logger.error "Beskar authentication error: #{e.class}"
|
|
68
|
+
handle_authentication_failure
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def handle_missing_authentication_configuration
|
|
73
|
+
# Log the configuration error for debugging, but return 404 to avoid revealing Beskar is installed
|
|
74
|
+
error_message = <<~'MSG'
|
|
75
|
+
Beskar authentication not configured!
|
|
76
|
+
|
|
77
|
+
Configure Beskar.configuration.authenticate_admin in your initializer:
|
|
78
|
+
|
|
79
|
+
# config/initializers/beskar.rb
|
|
80
|
+
Beskar.configuration.authenticate_admin = ->(request) do
|
|
81
|
+
# The block is executed in the controller context, giving you access
|
|
82
|
+
# to controller methods like cookies, session, authenticate_or_request_with_http_basic, etc.
|
|
83
|
+
|
|
84
|
+
# Example 1: Check for admin user with Devise
|
|
85
|
+
# user = request.env['warden']&.authenticate(scope: :user)
|
|
86
|
+
# user&.admin?
|
|
87
|
+
|
|
88
|
+
# Example 2: HTTP Basic Auth (uses controller method)
|
|
89
|
+
# authenticate_or_request_with_http_basic do |username, password|
|
|
90
|
+
# Beskar::Services::RequestContext.secure_match?(username, ENV['BESKAR_USERNAME']) &&
|
|
91
|
+
# Beskar::Services::RequestContext.secure_match?(password, ENV['BESKAR_PASSWORD'])
|
|
92
|
+
# end
|
|
93
|
+
|
|
94
|
+
# Example 3: Cookie-based auth (uses controller cookies)
|
|
95
|
+
# Beskar::Services::RequestContext.secure_match?(cookies.signed[:admin_token], ENV['BESKAR_ADMIN_TOKEN'])
|
|
96
|
+
|
|
97
|
+
# Example 4: Simple token-based auth
|
|
98
|
+
# token = ENV['BESKAR_ADMIN_TOKEN']
|
|
99
|
+
# token.present? && Beskar::Services::RequestContext.secure_match?(request.headers['Authorization'], "Bearer #{token}")
|
|
100
|
+
|
|
101
|
+
# Example 5: For development/testing (NOT for production!)
|
|
102
|
+
# Rails.env.development? || Rails.env.test?
|
|
103
|
+
end
|
|
104
|
+
MSG
|
|
105
|
+
|
|
106
|
+
Rails.logger.error error_message
|
|
107
|
+
render_404
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def handle_authentication_failure
|
|
111
|
+
# Return 404 to avoid revealing that Beskar is installed
|
|
112
|
+
render_404 unless performed?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def render_404
|
|
116
|
+
respond_to do |format|
|
|
117
|
+
format.html { render file: "#{Rails.public_path}/404.html", status: :not_found, layout: false }
|
|
118
|
+
format.json { render json: {error: "Not found"}, status: :not_found }
|
|
119
|
+
format.any { head :not_found }
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Helper method to format timestamps
|
|
124
|
+
def format_timestamp(time)
|
|
125
|
+
return "-" unless time
|
|
126
|
+
time.in_time_zone.strftime("%Y-%m-%d %H:%M:%S %Z")
|
|
127
|
+
end
|
|
128
|
+
helper_method :format_timestamp
|
|
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
|
+
|
|
135
|
+
# Helper method to format IP addresses with location if available
|
|
136
|
+
def format_ip_with_location(ip, metadata = {})
|
|
137
|
+
return ip unless metadata.present?
|
|
138
|
+
|
|
139
|
+
location_parts = []
|
|
140
|
+
if metadata["geolocation"].present?
|
|
141
|
+
geo = metadata["geolocation"]
|
|
142
|
+
location_parts << geo["city"] if geo["city"].present?
|
|
143
|
+
location_parts << geo["country"] if geo["country"].present?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
return ip if location_parts.empty?
|
|
147
|
+
"#{ip} (#{location_parts.join(", ")})"
|
|
148
|
+
end
|
|
149
|
+
helper_method :format_ip_with_location
|
|
150
|
+
|
|
151
|
+
# Helper to determine risk level badge color
|
|
152
|
+
def risk_level_class(risk_score)
|
|
153
|
+
RiskLevel::BADGES.fetch(RiskLevel.for(risk_score), "neutral")
|
|
154
|
+
end
|
|
155
|
+
helper_method :risk_level_class
|
|
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
|
+
|
|
175
|
+
# Helper to format event type for display
|
|
176
|
+
def format_event_type(event_type)
|
|
177
|
+
event_type.to_s.humanize.titleize
|
|
178
|
+
end
|
|
179
|
+
helper_method :format_event_type
|
|
180
|
+
|
|
181
|
+
# Pagination helper
|
|
182
|
+
def paginate(collection, per_page: 25)
|
|
183
|
+
# Handle per_page from params if provided
|
|
184
|
+
if params[:per_page].present?
|
|
185
|
+
per_page = params[:per_page].to_i
|
|
186
|
+
per_page = 25 if per_page <= 0 # Default if invalid
|
|
187
|
+
per_page = 100 if per_page > 100 # Max limit
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
page = (params[:page] || 1).to_i
|
|
191
|
+
page = 1 if page < 1
|
|
192
|
+
|
|
193
|
+
total_count = collection.count
|
|
194
|
+
total_pages = (total_count > 0) ? (total_count.to_f / per_page).ceil : 0
|
|
195
|
+
|
|
196
|
+
offset = (page - 1) * per_page
|
|
197
|
+
records = collection.limit(per_page).offset(offset)
|
|
198
|
+
|
|
199
|
+
{
|
|
200
|
+
records: records,
|
|
201
|
+
current_page: page,
|
|
202
|
+
total_pages: total_pages,
|
|
203
|
+
total_count: total_count,
|
|
204
|
+
per_page: per_page,
|
|
205
|
+
has_previous: page > 1,
|
|
206
|
+
has_next: page < total_pages,
|
|
207
|
+
previous_page: (page > 1) ? page - 1 : nil,
|
|
208
|
+
next_page: (page < total_pages) ? page + 1 : nil
|
|
209
|
+
}
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Ensure CSRF token is properly set for forms in the engine
|
|
213
|
+
def ensure_csrf_token
|
|
214
|
+
# Force generation of CSRF token if not present
|
|
215
|
+
form_authenticity_token
|
|
216
|
+
end
|
|
3
217
|
end
|
|
4
218
|
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
require "csv"
|
|
2
|
+
|
|
3
|
+
module Beskar
|
|
4
|
+
class BannedIpsController < ApplicationController
|
|
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
|
|
15
|
+
|
|
16
|
+
def index
|
|
17
|
+
@banned_ips = Beskar::BannedIp.order(banned_at: :desc)
|
|
18
|
+
|
|
19
|
+
# Apply filters
|
|
20
|
+
apply_filters!
|
|
21
|
+
|
|
22
|
+
# Paginate results
|
|
23
|
+
@pagination = paginate(@banned_ips, per_page: params[:per_page]&.to_i || 25)
|
|
24
|
+
@banned_ips = @pagination[:records]
|
|
25
|
+
|
|
26
|
+
# Get filter options
|
|
27
|
+
@ban_reasons = Beskar::BannedIp.distinct.pluck(:reason).compact.sort
|
|
28
|
+
@ban_statuses = ["active", "expired", "permanent", "temporary"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def show
|
|
32
|
+
# Get related security events for this IP
|
|
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)
|
|
35
|
+
|
|
36
|
+
# Calculate statistics
|
|
37
|
+
@stats = {
|
|
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)
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def new
|
|
47
|
+
@banned_ip = Beskar::BannedIp.new
|
|
48
|
+
@suggested_ip = params[:ip_address]
|
|
49
|
+
@suggested_reason = params[:reason]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def create
|
|
53
|
+
manager = BannedIpManager.new(create_params)
|
|
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)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def edit
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def update
|
|
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
|
|
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)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def destroy
|
|
87
|
+
ip_address = @banned_ip.ip_address
|
|
88
|
+
@administration.change!([@banned_ip.id], action: "unban")
|
|
89
|
+
|
|
90
|
+
redirect_to banned_ips_path,
|
|
91
|
+
notice: "IP address #{ip_address} has been unbanned."
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def extend
|
|
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
|
|
99
|
+
|
|
100
|
+
def review
|
|
101
|
+
@operation = params[:operation]
|
|
102
|
+
head :bad_request unless %w[unban extend].include?(@operation)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def bulk_action
|
|
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."
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def export
|
|
114
|
+
@banned_ips = Beskar::BannedIp.all
|
|
115
|
+
apply_filters!
|
|
116
|
+
records = export_records(@banned_ips)
|
|
117
|
+
return if performed?
|
|
118
|
+
|
|
119
|
+
respond_to do |format|
|
|
120
|
+
format.csv do
|
|
121
|
+
send_data generate_csv(records),
|
|
122
|
+
filename: "banned-ips-#{Date.current}.csv",
|
|
123
|
+
type: "text/csv"
|
|
124
|
+
end
|
|
125
|
+
format.json do
|
|
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
|
+
}
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
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
|
+
|
|
170
|
+
def set_banned_ip
|
|
171
|
+
@banned_ip = Beskar::BannedIp.find(params[:id])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def banned_ip_params
|
|
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(
|
|
180
|
+
:ip_address, :reason, :details, :permanent,
|
|
181
|
+
:expires_at, :violation_count, metadata: {}
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def create_params
|
|
186
|
+
banned_ip_params.to_h.merge(
|
|
187
|
+
ban_type: params[:ban_type],
|
|
188
|
+
duration: params[:duration]
|
|
189
|
+
).symbolize_keys
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def apply_filters!
|
|
193
|
+
# Filter by status
|
|
194
|
+
case params[:status]
|
|
195
|
+
when "active"
|
|
196
|
+
@banned_ips = @banned_ips.active
|
|
197
|
+
when "expired"
|
|
198
|
+
@banned_ips = @banned_ips.expired
|
|
199
|
+
when "permanent"
|
|
200
|
+
@banned_ips = @banned_ips.permanent
|
|
201
|
+
when "temporary"
|
|
202
|
+
@banned_ips = @banned_ips.temporary
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Filter by reason
|
|
206
|
+
if params[:reason].present?
|
|
207
|
+
@banned_ips = @banned_ips.by_reason(params[:reason])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Filter by IP address (partial match)
|
|
211
|
+
if params[:ip_search].present?
|
|
212
|
+
@banned_ips = @banned_ips.where("ip_address LIKE ?", "%#{params[:ip_search]}%")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Filter by date range
|
|
216
|
+
if params[:banned_after].present?
|
|
217
|
+
begin
|
|
218
|
+
date = Date.parse(params[:banned_after])
|
|
219
|
+
@banned_ips = @banned_ips.where("banned_at >= ?", date.beginning_of_day)
|
|
220
|
+
rescue ArgumentError
|
|
221
|
+
# Invalid date, ignore
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
if params[:banned_before].present?
|
|
226
|
+
begin
|
|
227
|
+
date = Date.parse(params[:banned_before])
|
|
228
|
+
@banned_ips = @banned_ips.where("banned_at <= ?", date.end_of_day)
|
|
229
|
+
rescue ArgumentError
|
|
230
|
+
# Invalid date, ignore
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def generate_csv(banned_ips)
|
|
236
|
+
require "csv"
|
|
237
|
+
|
|
238
|
+
CSV.generate(headers: true, force_quotes: true) do |csv|
|
|
239
|
+
csv << ["IP Address", "Reason", "Banned At", "Expires At", "Status", "Violation Count", "Details"]
|
|
240
|
+
|
|
241
|
+
banned_ips.each do |ban|
|
|
242
|
+
csv << [
|
|
243
|
+
ban.ip_address,
|
|
244
|
+
ban.reason,
|
|
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",
|
|
248
|
+
ban.violation_count,
|
|
249
|
+
ban.details || "-"
|
|
250
|
+
].map { |value| Services::AuditData.csv_cell(value) }
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
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
|
|
17
|
+
@stats = {
|
|
18
|
+
total_events: @event_distribution.sum(&:last),
|
|
19
|
+
failed_logins: @event_distribution.to_h.fetch("login_failure", 0),
|
|
20
|
+
blocked_ips: Beskar::BannedIp.active.count,
|
|
21
|
+
high_risk_events: @risk_distribution[:high] + @risk_distribution[:critical],
|
|
22
|
+
critical_threats: @risk_distribution[:critical]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Recent activity
|
|
26
|
+
@recent_events = Beskar::SecurityEvent
|
|
27
|
+
.includes(:user)
|
|
28
|
+
.order(created_at: :desc, id: :desc)
|
|
29
|
+
.limit(10)
|
|
30
|
+
|
|
31
|
+
# Top threat IPs
|
|
32
|
+
@top_threat_ips = events
|
|
33
|
+
.group(:ip_address)
|
|
34
|
+
.select("ip_address, COUNT(*) as event_count, AVG(risk_score) as avg_risk_score, MAX(risk_score) as max_risk_score")
|
|
35
|
+
.having("COUNT(*) > 1")
|
|
36
|
+
.order("event_count DESC, avg_risk_score DESC")
|
|
37
|
+
.limit(5)
|
|
38
|
+
|
|
39
|
+
# Currently active bans
|
|
40
|
+
@active_bans = Beskar::BannedIp.active.order(banned_at: :desc).limit(5)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def calculate_start_time(range)
|
|
46
|
+
case range
|
|
47
|
+
when "1h"
|
|
48
|
+
1.hour.ago
|
|
49
|
+
when "6h"
|
|
50
|
+
6.hours.ago
|
|
51
|
+
when "24h"
|
|
52
|
+
24.hours.ago
|
|
53
|
+
when "7d"
|
|
54
|
+
7.days.ago
|
|
55
|
+
when "30d"
|
|
56
|
+
30.days.ago
|
|
57
|
+
else
|
|
58
|
+
24.hours.ago
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|