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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +274 -0
  3. data/README.md +412 -204
  4. data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
  5. data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
  6. data/app/controllers/beskar/application_controller.rb +214 -0
  7. data/app/controllers/beskar/banned_ips_controller.rb +255 -0
  8. data/app/controllers/beskar/dashboard_controller.rb +62 -0
  9. data/app/controllers/beskar/security_events_controller.rb +164 -0
  10. data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
  11. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
  12. data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
  13. data/app/jobs/beskar/notification_job.rb +33 -0
  14. data/app/mailers/beskar/security_mailer.rb +59 -0
  15. data/app/models/beskar/administrative_action.rb +41 -0
  16. data/app/models/beskar/banned_ip.rb +105 -105
  17. data/app/models/beskar/security_event.rb +51 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +88 -0
  20. data/app/views/beskar/administrative_actions/index.html.erb +33 -0
  21. data/app/views/beskar/administrative_actions/show.html.erb +21 -0
  22. data/app/views/beskar/banned_ips/edit.html.erb +195 -0
  23. data/app/views/beskar/banned_ips/index.html.erb +319 -0
  24. data/app/views/beskar/banned_ips/new.html.erb +190 -0
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +304 -0
  27. data/app/views/beskar/dashboard/index.html.erb +280 -0
  28. data/app/views/beskar/security_events/index.html.erb +302 -0
  29. data/app/views/beskar/security_events/show.html.erb +293 -0
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  32. data/app/views/layouts/beskar/application.html.erb +581 -6
  33. data/config/routes.rb +30 -0
  34. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  35. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  36. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  37. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  38. data/docs/README.md +73 -0
  39. data/docs/archive/project-documentation.md +659 -0
  40. data/docs/audits/project-review.md +437 -0
  41. data/docs/audits/repair-status.md +216 -0
  42. data/docs/guides/audit-and-waf.md +175 -0
  43. data/docs/guides/audit-lifecycle.md +172 -0
  44. data/docs/guides/authentication.md +213 -0
  45. data/docs/guides/configuration.md +182 -0
  46. data/docs/guides/dashboard-and-search.md +251 -0
  47. data/docs/guides/notifications-and-recovery.md +157 -0
  48. data/docs/guides/risk-scoring.md +116 -0
  49. data/docs/operations/monitor-only-mode.md +85 -0
  50. data/docs/operations/security-hardening.md +167 -0
  51. data/docs/operations/state-storage.md +144 -0
  52. data/docs/research/rust-performance-assessment.md +69 -0
  53. data/lib/beskar/configuration.rb +105 -20
  54. data/lib/beskar/configuration_validator.rb +188 -0
  55. data/lib/beskar/devise_authentication.rb +24 -0
  56. data/lib/beskar/engine.rb +21 -88
  57. data/lib/beskar/logger.rb +288 -0
  58. data/lib/beskar/middleware/request_analyzer.rb +133 -99
  59. data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
  60. data/lib/beskar/models/security_trackable_devise.rb +34 -25
  61. data/lib/beskar/models/security_trackable_generic.rb +171 -214
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +90 -81
  64. data/lib/beskar/services/administrative_audit.rb +36 -0
  65. data/lib/beskar/services/administrative_bans.rb +104 -0
  66. data/lib/beskar/services/audit_data.rb +72 -0
  67. data/lib/beskar/services/authentication.rb +31 -0
  68. data/lib/beskar/services/authentication_attempt.rb +141 -0
  69. data/lib/beskar/services/ban_expiry.rb +28 -0
  70. data/lib/beskar/services/device_detector.rb +32 -41
  71. data/lib/beskar/services/event_search.rb +58 -0
  72. data/lib/beskar/services/geolocation_service.rb +83 -114
  73. data/lib/beskar/services/ip_whitelist.rb +31 -40
  74. data/lib/beskar/services/location_assessment.rb +109 -0
  75. data/lib/beskar/services/native_account_lock.rb +82 -0
  76. data/lib/beskar/services/notifications.rb +46 -0
  77. data/lib/beskar/services/rate_limiter.rb +99 -125
  78. data/lib/beskar/services/request_context.rb +64 -0
  79. data/lib/beskar/services/risk_assessment.rb +58 -0
  80. data/lib/beskar/services/session_revocation.rb +62 -0
  81. data/lib/beskar/services/waf.rb +311 -198
  82. data/lib/beskar/services/waf_request.rb +60 -0
  83. data/lib/beskar/version.rb +1 -1
  84. data/lib/beskar/warden_authentication.rb +53 -0
  85. data/lib/beskar.rb +54 -4
  86. data/lib/generators/beskar/install/install_generator.rb +158 -0
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
  88. data/lib/tasks/beskar_tasks.rake +25 -20
  89. metadata +93 -12
  90. 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