beskar 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +298 -110
  4. data/app/controllers/beskar/application_controller.rb +170 -0
  5. data/app/controllers/beskar/banned_ips_controller.rb +280 -0
  6. data/app/controllers/beskar/dashboard_controller.rb +70 -0
  7. data/app/controllers/beskar/security_events_controller.rb +182 -0
  8. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +6 -6
  9. data/app/models/beskar/banned_ip.rb +68 -27
  10. data/app/models/beskar/security_event.rb +14 -0
  11. data/app/services/beskar/banned_ip_manager.rb +78 -0
  12. data/app/views/beskar/banned_ips/edit.html.erb +259 -0
  13. data/app/views/beskar/banned_ips/index.html.erb +361 -0
  14. data/app/views/beskar/banned_ips/new.html.erb +310 -0
  15. data/app/views/beskar/banned_ips/show.html.erb +310 -0
  16. data/app/views/beskar/dashboard/index.html.erb +280 -0
  17. data/app/views/beskar/security_events/index.html.erb +309 -0
  18. data/app/views/beskar/security_events/show.html.erb +307 -0
  19. data/app/views/layouts/beskar/application.html.erb +647 -5
  20. data/config/routes.rb +41 -0
  21. data/lib/beskar/configuration.rb +24 -10
  22. data/lib/beskar/engine.rb +4 -4
  23. data/lib/beskar/logger.rb +293 -0
  24. data/lib/beskar/middleware/request_analyzer.rb +128 -53
  25. data/lib/beskar/models/security_trackable_authenticable.rb +11 -11
  26. data/lib/beskar/models/security_trackable_devise.rb +5 -5
  27. data/lib/beskar/models/security_trackable_generic.rb +12 -12
  28. data/lib/beskar/services/account_locker.rb +12 -12
  29. data/lib/beskar/services/geolocation_service.rb +8 -8
  30. data/lib/beskar/services/ip_whitelist.rb +2 -2
  31. data/lib/beskar/services/waf.rb +307 -78
  32. data/lib/beskar/version.rb +1 -1
  33. data/lib/beskar.rb +1 -0
  34. data/lib/generators/beskar/install/install_generator.rb +158 -0
  35. data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
  36. data/lib/tasks/beskar_tasks.rake +11 -2
  37. metadata +35 -6
  38. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -29,9 +29,9 @@ module Beskar
29
29
  return unless Beskar.configuration.track_successful_logins?
30
30
 
31
31
  user.track_authentication_event(request, :success)
32
- Rails.logger.info "[Beskar] Tracked successful authentication for user #{user.id}"
32
+ Beskar::Logger.info("Tracked successful authentication for user #{user.id}")
33
33
  rescue => e
34
- Rails.logger.error "[Beskar] Failed to track authentication success: #{e.message}"
34
+ Beskar::Logger.error("Failed to track authentication success: #{e.message}")
35
35
  end
36
36
 
37
37
  # Track failed authentication attempt
@@ -40,9 +40,9 @@ module Beskar
40
40
  return unless Beskar.configuration.track_failed_logins?
41
41
 
42
42
  model_class.track_failed_authentication(request, scope)
43
- Rails.logger.info "[Beskar] Tracked failed authentication for scope #{scope}"
43
+ Beskar::Logger.info("Tracked failed authentication for scope #{scope}")
44
44
  rescue => e
45
- Rails.logger.error "[Beskar] Failed to track authentication failure: #{e.message}"
45
+ Beskar::Logger.error("Failed to track authentication failure: #{e.message}")
46
46
  end
47
47
 
48
48
  # Track logout event
@@ -61,9 +61,9 @@ module Beskar
61
61
  },
62
62
  risk_score: 0
63
63
  )
64
- Rails.logger.info "[Beskar] Tracked logout for user #{user.id}"
64
+ Beskar::Logger.info("Tracked logout for user #{user.id}")
65
65
  rescue => e
66
- Rails.logger.error "[Beskar] Failed to track logout: #{e.message}"
66
+ Beskar::Logger.error("Failed to track logout: #{e.message}")
67
67
  end
68
68
  end
69
69
  end
@@ -12,6 +12,10 @@ module Beskar
12
12
  self.metadata ||= {}
13
13
  end
14
14
 
15
+ # Cache management callbacks
16
+ after_save :update_cache
17
+ after_destroy :clear_cache
18
+
15
19
  scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) }
16
20
  scope :permanent, -> { where(permanent: true) }
17
21
  scope :temporary, -> { where(permanent: false) }
@@ -67,35 +71,52 @@ module Beskar
67
71
  class << self
68
72
  # Ban an IP address
69
73
  def ban!(ip_address, reason:, duration: nil, permanent: false, details: nil, metadata: {})
70
- banned_ip = find_or_initialize_by(ip_address: ip_address)
71
-
72
- if banned_ip.persisted?
73
- # Existing ban - extend it
74
- banned_ip.extend_ban!(duration)
75
- banned_ip.details = details if details
76
- # Deep stringify keys to avoid duplicate key issues
77
- if metadata.any?
78
- banned_ip.metadata = banned_ip.metadata.deep_stringify_keys.merge(metadata.deep_stringify_keys)
74
+ # Retry logic to handle race conditions when multiple requests try to ban the same IP
75
+ retries = 0
76
+ max_retries = 2
77
+
78
+ begin
79
+ banned_ip = find_or_initialize_by(ip_address: ip_address)
80
+
81
+ if banned_ip.persisted?
82
+ # Existing ban - extend it
83
+ banned_ip.extend_ban!(duration)
84
+ banned_ip.details = details if details
85
+ # Deep stringify keys to avoid duplicate key issues
86
+ if metadata.any?
87
+ banned_ip.metadata = banned_ip.metadata.deep_stringify_keys.merge(metadata.deep_stringify_keys)
88
+ end
89
+ banned_ip.save!
90
+ else
91
+ # New ban
92
+ banned_ip.assign_attributes(
93
+ reason: reason,
94
+ banned_at: Time.current,
95
+ expires_at: permanent ? nil : (Time.current + (duration || 1.hour)),
96
+ permanent: permanent,
97
+ details: details,
98
+ metadata: metadata
99
+ )
100
+ banned_ip.save!
79
101
  end
80
- banned_ip.save!
81
- else
82
- # New ban
83
- banned_ip.assign_attributes(
84
- reason: reason,
85
- banned_at: Time.current,
86
- expires_at: permanent ? nil : (Time.current + (duration || 1.hour)),
87
- permanent: permanent,
88
- details: details,
89
- metadata: metadata
90
- )
91
- banned_ip.save!
92
- end
93
102
 
94
- # Update cache
95
- cache_key = "beskar:banned_ip:#{ip_address}"
96
- Rails.cache.write(cache_key, true, expires_in: permanent ? nil : (duration || 1.hour))
97
-
98
- banned_ip
103
+ # Update cache
104
+ cache_key = "beskar:banned_ip:#{ip_address}"
105
+ Rails.cache.write(cache_key, true, expires_in: permanent ? nil : (duration || 1.hour))
106
+
107
+ banned_ip
108
+ rescue ActiveRecord::RecordInvalid => e
109
+ # Race condition: another request created the record between find and save
110
+ if e.message.include?("Ip address has already been taken") && retries < max_retries
111
+ retries += 1
112
+ # Small random delay to reduce contention (1-10ms)
113
+ sleep(rand(1..10) / 1000.0)
114
+ retry
115
+ else
116
+ # Re-raise if it's a different validation error or we've exceeded retries
117
+ raise
118
+ end
119
+ end
99
120
  end
100
121
 
101
122
  # Check if an IP is banned (cache-first approach)
@@ -148,5 +169,25 @@ module Beskar
148
169
  expired.destroy_all
149
170
  end
150
171
  end
172
+
173
+ private
174
+
175
+ def update_cache
176
+ return unless active?
177
+
178
+ cache_key = "beskar:banned_ip:#{ip_address}"
179
+ ttl = calculate_cache_ttl
180
+ Rails.cache.write(cache_key, true, expires_in: ttl)
181
+ end
182
+
183
+ def clear_cache
184
+ Rails.cache.delete("beskar:banned_ip:#{ip_address}")
185
+ end
186
+
187
+ def calculate_cache_ttl
188
+ return nil if permanent? || expires_at.nil?
189
+
190
+ (expires_at - Time.current).to_i
191
+ end
151
192
  end
152
193
  end
@@ -46,5 +46,19 @@ module Beskar
46
46
  def geolocation
47
47
  metadata&.dig("geolocation") || {}
48
48
  end
49
+
50
+ def details
51
+ # Extract details from metadata if available
52
+ # Check multiple possible fields where details might be stored
53
+ return nil unless metadata.present?
54
+
55
+ metadata["details"] ||
56
+ metadata["description"] ||
57
+ metadata["message"] ||
58
+ metadata["reason"] ||
59
+ metadata["error"] ||
60
+ metadata["info"] ||
61
+ nil
62
+ end
49
63
  end
50
64
  end
@@ -0,0 +1,78 @@
1
+ module Beskar
2
+ class BannedIpManager
3
+ attr_reader :banned_ip, :errors
4
+
5
+ def initialize(params)
6
+ @params = params
7
+ @errors = []
8
+ @banned_ip = nil
9
+ end
10
+
11
+ def create
12
+ @banned_ip = BannedIp.new(base_attributes)
13
+ configure_ban_duration
14
+
15
+ @banned_ip.save
16
+ end
17
+
18
+ def success?
19
+ @banned_ip&.persisted?
20
+ end
21
+
22
+ private
23
+
24
+ def base_attributes
25
+ {
26
+ ip_address: @params[:ip_address],
27
+ reason: @params[:reason],
28
+ details: @params[:details],
29
+ violation_count: @params[:violation_count] || 1,
30
+ metadata: @params[:metadata] || {},
31
+ banned_at: Time.current
32
+ }
33
+ end
34
+
35
+ def configure_ban_duration
36
+ return set_permanent_ban if permanent_ban?
37
+
38
+ set_temporary_ban
39
+ end
40
+
41
+ def permanent_ban?
42
+ @params[:ban_type] == 'permanent'
43
+ end
44
+
45
+ def set_permanent_ban
46
+ @banned_ip.permanent = true
47
+ @banned_ip.expires_at = nil
48
+ end
49
+
50
+ def set_temporary_ban
51
+ @banned_ip.permanent = false
52
+ @banned_ip.expires_at = calculate_expiry_time
53
+ end
54
+
55
+ def calculate_expiry_time
56
+ return custom_expiry_time if custom_expiry_time.present?
57
+ return preset_duration_expiry if preset_duration.present?
58
+
59
+ default_expiry_time
60
+ end
61
+
62
+ def custom_expiry_time
63
+ @params[:expires_at]
64
+ end
65
+
66
+ def preset_duration
67
+ @params[:duration]
68
+ end
69
+
70
+ def preset_duration_expiry
71
+ Time.current + preset_duration.to_i.seconds
72
+ end
73
+
74
+ def default_expiry_time
75
+ 24.hours.from_now
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,259 @@
1
+ <% content_for :page_title, "Edit Ban: #{@banned_ip.ip_address}" %>
2
+
3
+ <% content_for :header_actions do %>
4
+ <%= link_to "← Cancel", banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
5
+ <% end %>
6
+
7
+ <div class="card">
8
+ <div class="card-header">
9
+ <h3 class="card-title">Edit IP Ban</h3>
10
+ <div class="card-subtitle">Modify ban settings for <%= @banned_ip.ip_address %></div>
11
+ </div>
12
+ <div class="card-body">
13
+ <%= form_with model: @banned_ip, url: banned_ip_path(@banned_ip), method: :patch, local: true do |f| %>
14
+ <% if @banned_ip.errors.any? %>
15
+ <div class="alert alert-danger">
16
+ <h4 style="font-size: 14px; font-weight: 600; margin-bottom: 0.5rem;">
17
+ <%= pluralize(@banned_ip.errors.count, "error") %> prohibited this ban from being saved:
18
+ </h4>
19
+ <ul style="margin: 0; padding-left: 1.5rem;">
20
+ <% @banned_ip.errors.full_messages.each do |message| %>
21
+ <li style="font-size: 13px;"><%= message %></li>
22
+ <% end %>
23
+ </ul>
24
+ </div>
25
+ <% end %>
26
+
27
+ <div style="display: grid; grid-template-columns: 1fr; gap: 1.5rem; max-width: 600px;">
28
+ <!-- IP Address (Read-only) -->
29
+ <div class="form-group">
30
+ <%= f.label :ip_address, "IP Address" %>
31
+ <%= f.text_field :ip_address,
32
+ disabled: true,
33
+ style: "font-family: monospace; background: #F4F5F7; cursor: not-allowed;" %>
34
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
35
+ IP addresses cannot be changed. To ban a different IP, create a new ban.
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Current Status -->
40
+ <div class="form-group">
41
+ <label>Current Status</label>
42
+ <div style="display: flex; align-items: center; gap: 1rem;">
43
+ <% if @banned_ip.permanent? %>
44
+ <span class="badge badge-critical">Permanent Ban</span>
45
+ <% elsif @banned_ip.active? %>
46
+ <span class="badge badge-danger">Active</span>
47
+ <span style="font-size: 13px; color: #697386;">
48
+ Expires in <%= distance_of_time_in_words(Time.current, @banned_ip.expires_at) %>
49
+ </span>
50
+ <% else %>
51
+ <span class="badge badge-neutral">Expired</span>
52
+ <span style="font-size: 13px; color: #697386;">
53
+ Expired <%= time_ago_in_words(@banned_ip.expires_at) %> ago
54
+ </span>
55
+ <% end %>
56
+ </div>
57
+ </div>
58
+
59
+ <!-- Reason -->
60
+ <div class="form-group">
61
+ <%= f.label :reason, "Ban Reason *" %>
62
+ <%= f.select :reason,
63
+ options_for_select([
64
+ ['Rate Limit Abuse', 'rate_limit_abuse'],
65
+ ['Authentication Abuse', 'authentication_abuse'],
66
+ ['WAF Violation', 'waf_violation'],
67
+ ['Brute Force Attack', 'brute_force_attack'],
68
+ ['Suspicious Activity', 'suspicious_activity'],
69
+ ['Manual Ban', 'manual_ban'],
70
+ ['Other', 'other']
71
+ ], @banned_ip.reason),
72
+ { prompt: 'Select a reason...' },
73
+ { required: true } %>
74
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
75
+ Update the reason for this ban if needed.
76
+ </div>
77
+ </div>
78
+
79
+ <!-- Ban Duration -->
80
+ <div class="form-group">
81
+ <label>Ban Duration</label>
82
+ <div style="display: flex; flex-direction: column; gap: 0.5rem;">
83
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
84
+ <%= f.radio_button :permanent, false,
85
+ onchange: "toggleDurationFields()",
86
+ checked: !@banned_ip.permanent? %>
87
+ <span style="font-weight: normal;">Temporary Ban</span>
88
+ </label>
89
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
90
+ <%= f.radio_button :permanent, true,
91
+ onchange: "toggleDurationFields()",
92
+ checked: @banned_ip.permanent? %>
93
+ <span style="font-weight: normal; color: #D32F2F;">Permanent Ban</span>
94
+ </label>
95
+ </div>
96
+ <% if @banned_ip.permanent? %>
97
+ <div class="alert alert-warning" style="margin-top: 0.5rem;">
98
+ <strong>Warning:</strong> This is currently a permanent ban. Changing it to temporary will set an expiration date.
99
+ </div>
100
+ <% end %>
101
+ </div>
102
+
103
+ <!-- Temporary Ban Options -->
104
+ <div id="temporary-options" style="<%= @banned_ip.permanent? ? 'display: none;' : '' %>">
105
+ <div class="form-group">
106
+ <%= f.label :expires_at, "Expiry Date/Time" %>
107
+ <%= f.datetime_field :expires_at,
108
+ value: @banned_ip.expires_at&.strftime('%Y-%m-%dT%H:%M'),
109
+ min: DateTime.now.strftime('%Y-%m-%dT%H:%M') %>
110
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
111
+ Set when this ban should expire. Leave empty for default duration.
112
+ </div>
113
+ </div>
114
+
115
+ <!-- Quick Extension Options -->
116
+ <div class="form-group">
117
+ <label>Quick Extension</label>
118
+ <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem;">
119
+ <button type="button" onclick="extendBan(1)" class="btn btn-sm btn-secondary">+1 Hour</button>
120
+ <button type="button" onclick="extendBan(24)" class="btn btn-sm btn-secondary">+1 Day</button>
121
+ <button type="button" onclick="extendBan(168)" class="btn btn-sm btn-secondary">+7 Days</button>
122
+ <button type="button" onclick="extendBan(720)" class="btn btn-sm btn-secondary">+30 Days</button>
123
+ <button type="button" onclick="extendBan(2160)" class="btn btn-sm btn-secondary">+90 Days</button>
124
+ <button type="button" onclick="makePermanent()" class="btn btn-sm btn-danger">Make Permanent</button>
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ <!-- Details -->
130
+ <div class="form-group">
131
+ <%= f.label :details, "Additional Details" %>
132
+ <%= f.text_area :details,
133
+ rows: 3,
134
+ placeholder: "Provide any additional context or details about this ban..." %>
135
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
136
+ Update any relevant information about why this IP is banned.
137
+ </div>
138
+ </div>
139
+
140
+ <!-- Violation Count -->
141
+ <div class="form-group">
142
+ <%= f.label :violation_count, "Violation Count" %>
143
+ <%= f.number_field :violation_count, min: 0 %>
144
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
145
+ Current violation count: <%= @banned_ip.violation_count %>. Higher counts may result in longer bans for repeat offenders.
146
+ </div>
147
+ </div>
148
+
149
+ <hr style="border: none; border-top: 1px solid #E3E8EE; margin: 1.5rem 0;">
150
+
151
+ <!-- Ban History -->
152
+ <div class="form-group">
153
+ <label>Ban History</label>
154
+ <div style="background: #FAFBFC; border: 1px solid #E3E8EE; border-radius: 6px; padding: 1rem;">
155
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem;">
156
+ <div>
157
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
158
+ Originally Banned
159
+ </div>
160
+ <div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
161
+ <%= @banned_ip.banned_at.strftime("%Y-%m-%d %H:%M:%S") %>
162
+ </div>
163
+ </div>
164
+ <div>
165
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
166
+ Time Banned
167
+ </div>
168
+ <div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
169
+ <%= time_ago_in_words(@banned_ip.banned_at) %>
170
+ </div>
171
+ </div>
172
+ <% if @banned_ip.updated_at != @banned_ip.created_at %>
173
+ <div>
174
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
175
+ Last Modified
176
+ </div>
177
+ <div style="font-size: 13px; color: #1A1F36; margin-top: 0.25rem;">
178
+ <%= @banned_ip.updated_at.strftime("%Y-%m-%d %H:%M:%S") %>
179
+ </div>
180
+ </div>
181
+ <% end %>
182
+ </div>
183
+ </div>
184
+ </div>
185
+
186
+ <!-- Submit Buttons -->
187
+ <div style="display: flex; gap: 0.5rem;">
188
+ <%= f.submit "Update Ban", class: "btn btn-primary" %>
189
+ <%= link_to "Cancel", banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
190
+ <%= link_to "Delete Ban", banned_ip_path(@banned_ip),
191
+ data: {
192
+ "turbo-method": "delete",
193
+ "turbo-confirm": "Are you sure you want to unban #{@banned_ip.ip_address}?"
194
+ },
195
+ class: "btn btn-danger" %>
196
+ </div>
197
+ </div>
198
+ <% end %>
199
+ </div>
200
+ </div>
201
+
202
+ <script>
203
+ function toggleDurationFields() {
204
+ const isPermanent = document.querySelector('input[name="banned_ip[permanent]"]:checked').value === 'true';
205
+ const temporaryOptions = document.getElementById('temporary-options');
206
+
207
+ if (isPermanent) {
208
+ temporaryOptions.style.display = 'none';
209
+ document.getElementById('banned_ip_expires_at').value = '';
210
+ } else {
211
+ temporaryOptions.style.display = 'block';
212
+ // If switching from permanent to temporary, set a default expiry
213
+ if (!document.getElementById('banned_ip_expires_at').value) {
214
+ const defaultExpiry = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours from now
215
+ document.getElementById('banned_ip_expires_at').value = defaultExpiry.toISOString().slice(0, 16);
216
+ }
217
+ }
218
+ }
219
+
220
+ function extendBan(hours) {
221
+ const expiresField = document.getElementById('banned_ip_expires_at');
222
+ let currentExpiry;
223
+
224
+ if (expiresField.value) {
225
+ currentExpiry = new Date(expiresField.value);
226
+ } else {
227
+ // Use current ban expiry or current time
228
+ <% if @banned_ip.expires_at %>
229
+ currentExpiry = new Date('<%= @banned_ip.expires_at.iso8601 %>');
230
+ <% else %>
231
+ currentExpiry = new Date();
232
+ <% end %>
233
+ }
234
+
235
+ // Add the specified hours
236
+ currentExpiry.setHours(currentExpiry.getHours() + hours);
237
+
238
+ // Update the field
239
+ expiresField.value = currentExpiry.toISOString().slice(0, 16);
240
+
241
+ // Show a visual feedback
242
+ const originalBg = expiresField.style.background;
243
+ expiresField.style.background = '#E8F5E9';
244
+ setTimeout(() => {
245
+ expiresField.style.background = originalBg;
246
+ }, 500);
247
+ }
248
+
249
+ function makePermanent() {
250
+ document.querySelector('input[name="banned_ip[permanent]"][value="true"]').checked = true;
251
+ toggleDurationFields();
252
+ }
253
+
254
+ // Initialize on page load
255
+ document.addEventListener('DOMContentLoaded', function() {
256
+ // Set initial state
257
+ toggleDurationFields();
258
+ });
259
+ </script>