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.
Files changed (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +150 -19
  3. data/README.md +142 -122
  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 +69 -25
  7. data/app/controllers/beskar/banned_ips_controller.rb +116 -141
  8. data/app/controllers/beskar/dashboard_controller.rb +20 -28
  9. data/app/controllers/beskar/security_events_controller.rb +37 -55
  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 +91 -132
  17. data/app/models/beskar/security_event.rb +37 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +16 -6
  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 +25 -89
  23. data/app/views/beskar/banned_ips/index.html.erb +20 -62
  24. data/app/views/beskar/banned_ips/new.html.erb +18 -138
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +9 -15
  27. data/app/views/beskar/dashboard/index.html.erb +4 -4
  28. data/app/views/beskar/security_events/index.html.erb +8 -15
  29. data/app/views/beskar/security_events/show.html.erb +6 -20
  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 +9 -76
  33. data/config/routes.rb +10 -21
  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 +84 -13
  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 +30 -35
  58. data/lib/beskar/middleware/request_analyzer.rb +41 -82
  59. data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
  60. data/lib/beskar/models/security_trackable_devise.rb +32 -23
  61. data/lib/beskar/models/security_trackable_generic.rb +169 -212
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +85 -76
  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 +164 -280
  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 +53 -4
  86. data/lib/generators/beskar/install/install_generator.rb +36 -36
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +104 -20
  88. data/lib/tasks/beskar_tasks.rake +15 -19
  89. 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 'beskar/application'
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.message}"
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 == ENV['BESKAR_USERNAME'] && password == ENV['BESKAR_PASSWORD']
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] == ENV['BESKAR_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
- # request.headers['Authorization'] == "Bearer #{ENV['BESKAR_ADMIN_TOKEN']}"
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: { error: "Not found" }, status: :not_found }
87
- format.any { head :not_found }
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
- 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
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 'csv'
1
+ require "csv"
2
2
 
3
3
  module Beskar
4
4
  class BannedIpsController < ApplicationController
5
- before_action :set_banned_ip, only: [:show, :edit, :update, :destroy, :extend]
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 = ['active', 'expired', 'permanent', 'temporary']
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
- @related_events = Beskar::SecurityEvent
25
- .where(ip_address: @banned_ip.ip_address)
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: @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)
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.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
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
- 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
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.destroy
88
+ @administration.change!([@banned_ip.id], action: "unban")
73
89
 
74
90
  redirect_to banned_ips_path,
75
- notice: "IP address #{ip_address} has been unbanned."
91
+ notice: "IP address #{ip_address} has been unbanned."
76
92
  end
77
93
 
78
94
  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
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
- 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}"
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
- 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
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(@banned_ips),
121
+ send_data generate_csv(records),
140
122
  filename: "banned-ips-#{Date.current}.csv",
141
- type: 'text/csv'
123
+ type: "text/csv"
142
124
  end
143
125
  format.json do
144
- render json: @banned_ips.as_json(except: [:updated_at])
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).permit(
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 'active'
195
+ when "active"
173
196
  @banned_ips = @banned_ips.active
174
- when 'expired'
197
+ when "expired"
175
198
  @banned_ips = @banned_ips.expired
176
- when 'permanent'
199
+ when "permanent"
177
200
  @banned_ips = @banned_ips.permanent
178
- when 'temporary'
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 'csv'
236
+ require "csv"
262
237
 
263
- CSV.generate(headers: true) do |csv|
264
- csv << ['IP Address', 'Reason', 'Banned At', 'Expires At', 'Status', 'Violation Count', 'Details']
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.find_each do |ban|
241
+ banned_ips.each do |ban|
267
242
  csv << [
268
243
  ban.ip_address,
269
244
  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',
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] || '24h'
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: Beskar::SecurityEvent.where("created_at >= ?", @start_time).count,
11
- failed_logins: Beskar::SecurityEvent.where("created_at >= ?", @start_time).login_failures.count,
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: Beskar::SecurityEvent.where("created_at >= ?", @start_time).high_risk.count,
14
- critical_threats: Beskar::SecurityEvent.where("created_at >= ?", @start_time).critical_risk.count
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 = Beskar::SecurityEvent
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 '1h'
47
+ when "1h"
56
48
  1.hour.ago
57
- when '6h'
49
+ when "6h"
58
50
  6.hours.ago
59
- when '24h'
51
+ when "24h"
60
52
  24.hours.ago
61
- when '7d'
53
+ when "7d"
62
54
  7.days.ago
63
- when '30d'
55
+ when "30d"
64
56
  30.days.ago
65
57
  else
66
58
  24.hours.ago