beskar 0.1.0 → 0.2.1

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 +177 -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 +18 -15
  27. data/app/views/beskar/dashboard/index.html.erb +6 -6
  28. data/app/views/beskar/security_events/index.html.erb +18 -16
  29. data/app/views/beskar/security_events/show.html.erb +10 -20
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/beskar/shared/_waf_analysis.html.erb +25 -0
  32. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  33. data/app/views/layouts/beskar/application.html.erb +9 -76
  34. data/config/routes.rb +10 -21
  35. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  36. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  37. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  38. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  39. data/docs/README.md +73 -0
  40. data/docs/archive/project-documentation.md +659 -0
  41. data/docs/audits/project-review.md +437 -0
  42. data/docs/audits/repair-status.md +216 -0
  43. data/docs/guides/audit-and-waf.md +188 -0
  44. data/docs/guides/audit-lifecycle.md +172 -0
  45. data/docs/guides/authentication.md +213 -0
  46. data/docs/guides/configuration.md +182 -0
  47. data/docs/guides/dashboard-and-search.md +251 -0
  48. data/docs/guides/notifications-and-recovery.md +157 -0
  49. data/docs/guides/risk-scoring.md +116 -0
  50. data/docs/operations/monitor-only-mode.md +85 -0
  51. data/docs/operations/security-hardening.md +167 -0
  52. data/docs/operations/state-storage.md +144 -0
  53. data/docs/research/rust-performance-assessment.md +69 -0
  54. data/lib/beskar/configuration.rb +84 -13
  55. data/lib/beskar/configuration_validator.rb +188 -0
  56. data/lib/beskar/devise_authentication.rb +24 -0
  57. data/lib/beskar/engine.rb +21 -88
  58. data/lib/beskar/logger.rb +30 -35
  59. data/lib/beskar/middleware/request_analyzer.rb +49 -82
  60. data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
  61. data/lib/beskar/models/security_trackable_devise.rb +32 -23
  62. data/lib/beskar/models/security_trackable_generic.rb +169 -212
  63. data/lib/beskar/risk_level.rb +22 -0
  64. data/lib/beskar/services/account_locker.rb +85 -76
  65. data/lib/beskar/services/administrative_audit.rb +36 -0
  66. data/lib/beskar/services/administrative_bans.rb +104 -0
  67. data/lib/beskar/services/audit_data.rb +72 -0
  68. data/lib/beskar/services/authentication.rb +31 -0
  69. data/lib/beskar/services/authentication_attempt.rb +148 -0
  70. data/lib/beskar/services/ban_expiry.rb +28 -0
  71. data/lib/beskar/services/device_detector.rb +32 -41
  72. data/lib/beskar/services/event_search.rb +58 -0
  73. data/lib/beskar/services/geolocation_service.rb +83 -114
  74. data/lib/beskar/services/ip_whitelist.rb +31 -40
  75. data/lib/beskar/services/location_assessment.rb +109 -0
  76. data/lib/beskar/services/native_account_lock.rb +82 -0
  77. data/lib/beskar/services/notifications.rb +46 -0
  78. data/lib/beskar/services/rate_limiter.rb +103 -125
  79. data/lib/beskar/services/request_context.rb +69 -0
  80. data/lib/beskar/services/risk_assessment.rb +58 -0
  81. data/lib/beskar/services/session_revocation.rb +62 -0
  82. data/lib/beskar/services/waf.rb +166 -280
  83. data/lib/beskar/services/waf_request.rb +70 -0
  84. data/lib/beskar/version.rb +1 -1
  85. data/lib/beskar/warden_authentication.rb +53 -0
  86. data/lib/beskar.rb +53 -4
  87. data/lib/generators/beskar/install/install_generator.rb +36 -36
  88. data/lib/generators/beskar/install/templates/initializer.rb.tt +105 -20
  89. data/lib/tasks/beskar_tasks.rake +15 -19
  90. metadata +61 -8
@@ -3,12 +3,8 @@
3
3
  <% content_for :header_actions do %>
4
4
  <%= link_to "← Back to Banned IPs", banned_ips_path, class: "btn btn-secondary" %>
5
5
  <%= link_to "Edit", edit_banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
6
- <%= link_to "Unban", banned_ip_path(@banned_ip),
7
- data: {
8
- "turbo-method": "delete",
9
- "turbo-confirm": "Are you sure you want to unban #{@banned_ip.ip_address}?"
10
- },
11
- class: "btn btn-danger" %>
6
+ <%= link_to "Action History", administrative_actions_path(target_id: @banned_ip.id), class: "btn btn-secondary" %>
7
+ <%= link_to "Unban", review_banned_ip_path(@banned_ip, operation: "unban"), class: "btn btn-danger" %>
12
8
  <% end %>
13
9
 
14
10
  <!-- Ban Overview -->
@@ -143,7 +139,7 @@
143
139
  </div>
144
140
 
145
141
  <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
146
- <div style="font-size: 24px; font-weight: 600; color: <%= @stats[:avg_risk_score] > 70 ? '#E91E63' : '#1A1F36' %>;">
142
+ <div style="font-size: 24px; font-weight: 600; color: <%= risk_level_color(@stats[:avg_risk_score]) %>;">
147
143
  <%= @stats[:avg_risk_score] %>
148
144
  </div>
149
145
  <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
@@ -152,7 +148,7 @@
152
148
  </div>
153
149
 
154
150
  <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
155
- <div style="font-size: 24px; font-weight: 600; color: <%= @stats[:max_risk_score] > 85 ? '#D32F2F' : '#1A1F36' %>;">
151
+ <div style="font-size: 24px; font-weight: 600; color: <%= risk_level_color(@stats[:max_risk_score]) %>;">
156
152
  <%= @stats[:max_risk_score] %>
157
153
  </div>
158
154
  <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
@@ -185,6 +181,10 @@
185
181
  </div>
186
182
  </div>
187
183
 
184
+ <% if @banned_ip.reason == "waf_violation" %>
185
+ <%= render "beskar/shared/waf_analysis", analysis: @banned_ip.metadata["waf_analysis"], banned: true %>
186
+ <% end %>
187
+
188
188
  <!-- Metadata -->
189
189
  <% if @banned_ip.metadata.present? && @banned_ip.metadata.any? %>
190
190
  <div class="card">
@@ -214,6 +214,7 @@
214
214
  <th>User</th>
215
215
  <th>Risk Score</th>
216
216
  <th>Details</th>
217
+ <th>Requested Path</th>
217
218
  <th>Actions</th>
218
219
  </tr>
219
220
  </thead>
@@ -239,11 +240,11 @@
239
240
  <td>
240
241
  <% if event.user %>
241
242
  <div style="font-size: 13px;">
242
- <%= event.user.try(:email) || "User ##{event.user_id}" %>
243
+ <%= audit_user_label(event) %>
243
244
  </div>
244
245
  <% elsif event.attempted_email %>
245
246
  <div style="font-size: 13px; color: #697386;">
246
- <%= event.attempted_email %>
247
+ <%= audit_user_label(event) %>
247
248
  </div>
248
249
  <% else %>
249
250
  <span style="color: #C1C7D0;">-</span>
@@ -259,6 +260,10 @@
259
260
  <%= event.details || event.metadata&.dig("message") || "-" %>
260
261
  </div>
261
262
  </td>
263
+ <td style="font-family: monospace; font-size: 12px; overflow-wrap: anywhere; max-width: 300px;">
264
+ <% analysis = event.metadata["waf_analysis"] %>
265
+ <%= analysis.is_a?(Hash) ? analysis["request_path"].presence || "Not recorded" : "-" %>
266
+ </td>
262
267
  <td>
263
268
  <%= link_to "View", security_event_path(event), class: "btn btn-sm btn-secondary" %>
264
269
  </td>
@@ -295,16 +300,14 @@
295
300
  ['Make Permanent', 'permanent']
296
301
  ]),
297
302
  style: "padding: 0.5rem 0.75rem; font-size: 14px;" %>
303
+ <%= label_tag :audit_reason, "Administrative reason" %>
304
+ <%= text_field_tag :audit_reason, nil, required: true, maxlength: 1000 %>
298
305
  <%= submit_tag "Extend Ban", class: "btn btn-warning" %>
299
306
  <% end %>
300
307
  </div>
301
308
  <% end %>
302
309
 
303
- <%= link_to "Unban IP", banned_ip_path(@banned_ip),
304
- data: {
305
- "turbo-method": "delete",
306
- "turbo-confirm": "Are you sure you want to unban #{@banned_ip.ip_address}?"
307
- },
310
+ <%= link_to "Unban IP", review_banned_ip_path(@banned_ip, operation: "unban"),
308
311
  class: "btn btn-danger",
309
312
  style: "margin-left: #{ @banned_ip.active? && !@banned_ip.permanent? ? '0' : 'auto' };" %>
310
313
  </div>
@@ -38,7 +38,7 @@
38
38
  <div class="stat-value" style="color: var(--primary);">
39
39
  <%= number_with_delimiter(@stats[:high_risk_events]) %>
40
40
  </div>
41
- <div class="stat-change">Score > 60</div>
41
+ <div class="stat-change">Score &ge; <%= Beskar::RiskLevel::RANGES[:high].begin %></div>
42
42
  </div>
43
43
 
44
44
  <div class="stat-card">
@@ -46,7 +46,7 @@
46
46
  <div class="stat-value" style="color: #D32F2F;">
47
47
  <%= number_with_delimiter(@stats[:critical_threats]) %>
48
48
  </div>
49
- <div class="stat-change">Score > 85</div>
49
+ <div class="stat-change">Score &ge; <%= Beskar::RiskLevel::RANGES[:critical].begin %></div>
50
50
  </div>
51
51
  </div>
52
52
 
@@ -172,11 +172,11 @@
172
172
  <td>
173
173
  <% if event.user %>
174
174
  <div style="font-size: 13px;">
175
- <%= event.user.try(:email) || "User ##{event.user_id}" %>
175
+ <%= audit_user_label(event) %>
176
176
  </div>
177
177
  <% elsif event.attempted_email %>
178
178
  <div style="font-size: 13px; color: #697386;">
179
- <%= event.attempted_email %>
179
+ <%= audit_user_label(event) %>
180
180
  </div>
181
181
  <% else %>
182
182
  <span style="color: #C1C7D0;">-</span>
@@ -206,10 +206,10 @@
206
206
  <!-- Top Threats & Active Bans -->
207
207
  <div style="display: flex; flex-direction: column; gap: 1.5rem;">
208
208
  <!-- Top Threat IPs -->
209
- <div class="card">
209
+ <div class="card" id="top-threat-ips">
210
210
  <div class="card-header">
211
211
  <h3 class="card-title">Top Threat IPs</h3>
212
- <div class="card-subtitle">Most active suspicious IPs</div>
212
+ <div class="card-subtitle">Most active suspicious IPs in the selected period</div>
213
213
  </div>
214
214
  <div class="card-body">
215
215
  <div style="display: flex; flex-direction: column; gap: 0.75rem;">
@@ -2,10 +2,7 @@
2
2
 
3
3
  <% content_for :header_actions do %>
4
4
  <div style="display: flex; gap: 0.5rem;">
5
- <%= link_to "Export CSV", export_security_events_path(request.query_parameters.merge(format: :csv)),
6
- class: "btn btn-secondary" %>
7
- <%= link_to "Export JSON", export_security_events_path(request.query_parameters.merge(format: :json)),
8
- class: "btn btn-secondary" %>
5
+ <%= render "beskar/shared/export_form", export_url: export_security_events_path %>
9
6
  </div>
10
7
  <% end %>
11
8
 
@@ -46,12 +43,7 @@
46
43
  <div class="form-group">
47
44
  <label>Risk Level</label>
48
45
  <%= select_tag :risk_level,
49
- options_for_select([
50
- ['Low (0-30)', 'low'],
51
- ['Medium (31-60)', 'medium'],
52
- ['High (61-85)', 'high'],
53
- ['Critical (86-100)', 'critical']
54
- ], params[:risk_level]),
46
+ options_for_select(@risk_levels, params[:risk_level]),
55
47
  prompt: 'All risk levels',
56
48
  style: "width: 100%;" %>
57
49
  </div>
@@ -109,15 +101,16 @@
109
101
  </div>
110
102
 
111
103
  <div style="display: flex; align-items: center; gap: 0.5rem;">
112
- <label style="margin: 0; font-size: 13px; color: #697386;">Per page:</label>
104
+ <label for="per_page" style="margin: 0; font-size: 13px; color: #697386;">Per page:</label>
113
105
  <%= form_with url: security_events_path, method: :get, local: true, style: "display: inline;" do |f| %>
114
- <% request.query_parameters.except(:per_page).each do |key, value| %>
106
+ <% request.query_parameters.except("per_page", "page").each do |key, value| %>
115
107
  <%= hidden_field_tag key, value %>
116
108
  <% end %>
117
109
  <%= select_tag :per_page,
118
110
  options_for_select([10, 25, 50, 100], params[:per_page]&.to_i || 25),
119
- onchange: "this.form.submit();",
111
+ data: { beskar_auto_submit: true },
120
112
  style: "padding: 0.25rem 0.5rem; font-size: 13px;" %>
113
+ <%= submit_tag "Apply page size", class: "btn btn-sm btn-secondary" %>
121
114
  <% end %>
122
115
  </div>
123
116
  </div>
@@ -132,6 +125,7 @@
132
125
  <th>ID</th>
133
126
  <th>Timestamp</th>
134
127
  <th>Event Type</th>
128
+ <th>Requested Path</th>
135
129
  <th>IP Address</th>
136
130
  <th>User</th>
137
131
  <th>Risk Score</th>
@@ -164,6 +158,14 @@
164
158
  <%= event.event_type %>
165
159
  </div>
166
160
  </td>
161
+ <td data-request-path style="font-family: monospace; font-size: 12px; max-width: 300px; overflow-wrap: anywhere;">
162
+ <% analysis = event.metadata["waf_analysis"] %>
163
+ <% if event.event_type == "waf_violation" %>
164
+ <%= analysis.is_a?(Hash) ? analysis["request_path"].presence || "Not recorded" : "Not recorded" %>
165
+ <% else %>
166
+ <%= event.metadata["request_path"].presence || "-" %>
167
+ <% end %>
168
+ </td>
167
169
  <td>
168
170
  <div style="font-family: monospace; font-size: 13px;">
169
171
  <%= event.ip_address %>
@@ -178,14 +180,14 @@
178
180
  <td>
179
181
  <% if event.user %>
180
182
  <div style="font-size: 13px;">
181
- <%= event.user.try(:email) || "User ##{event.user_id}" %>
183
+ <%= audit_user_label(event) %>
182
184
  </div>
183
185
  <div style="font-size: 11px; color: #697386;">
184
186
  ID: <%= event.user_id %>
185
187
  </div>
186
188
  <% elsif event.attempted_email %>
187
189
  <div style="font-size: 13px; color: #697386;">
188
- <%= event.attempted_email %>
190
+ <%= audit_user_label(event) %>
189
191
  </div>
190
192
  <div style="font-size: 11px; color: #C1C7D0;">
191
193
  (attempted)
@@ -230,7 +232,7 @@
230
232
  <% end %>
231
233
  <% else %>
232
234
  <tr>
233
- <td colspan="8" style="text-align: center; padding: 3rem;">
235
+ <td colspan="9" style="text-align: center; padding: 3rem;">
234
236
  <div style="color: #697386;">
235
237
  <svg width="48" height="48" fill="currentColor" viewBox="0 0 24 24" style="margin: 0 auto 1rem;">
236
238
  <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
@@ -35,25 +35,11 @@
35
35
  Risk Score
36
36
  </div>
37
37
  <div style="display: flex; align-items: center; gap: 0.75rem;">
38
- <span style="font-size: 24px; font-weight: 600; color: <%=
39
- case @event.risk_score
40
- when 0..30 then '#4CAF50'
41
- when 31..60 then '#FF9800'
42
- when 61..85 then '#E91E63'
43
- else '#D32F2F'
44
- end
45
- %>;">
38
+ <span style="font-size: 24px; font-weight: 600; color: <%= risk_level_color(@event.risk_score) %>;">
46
39
  <%= @event.risk_score %>
47
40
  </span>
48
41
  <span class="badge badge-<%= risk_level_class(@event.risk_score) %>">
49
- <%=
50
- case @event.risk_score
51
- when 0..30 then 'Low Risk'
52
- when 31..60 then 'Medium Risk'
53
- when 61..85 then 'High Risk'
54
- else 'Critical'
55
- end
56
- %>
42
+ <%= risk_level_label(@event.risk_score) %>
57
43
  </span>
58
44
  </div>
59
45
  </div>
@@ -101,14 +87,14 @@
101
87
  </div>
102
88
  <% if @event.user %>
103
89
  <div style="font-size: 14px; color: #1A1F36;">
104
- <%= @event.user.try(:email) || "User ##{@event.user_id}" %>
90
+ <%= audit_user_label(@event) %>
105
91
  </div>
106
92
  <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
107
93
  ID: <%= @event.user_id %> • Type: <%= @event.user_type %>
108
94
  </div>
109
95
  <% elsif @event.attempted_email %>
110
96
  <div style="font-size: 14px; color: #697386;">
111
- <%= @event.attempted_email %> <span style="font-size: 12px;">(attempted)</span>
97
+ <%= audit_user_label(@event) %> <span style="font-size: 12px;">(attempted)</span>
112
98
  </div>
113
99
  <% else %>
114
100
  <div style="color: #C1C7D0;">No user associated</div>
@@ -165,6 +151,10 @@
165
151
  </div>
166
152
  </div>
167
153
 
154
+ <% if @event.event_type == "waf_violation" %>
155
+ <%= render "beskar/shared/waf_analysis", analysis: @event.metadata["waf_analysis"] %>
156
+ <% end %>
157
+
168
158
  <!-- Metadata -->
169
159
  <% if @event.metadata.present? && @event.metadata.any? %>
170
160
  <div class="card">
@@ -214,9 +204,9 @@
214
204
  </td>
215
205
  <td>
216
206
  <% if event.user %>
217
- <%= event.user.try(:email) || "User ##{event.user_id}" %>
207
+ <%= audit_user_label(event) %>
218
208
  <% elsif event.attempted_email %>
219
- <span style="color: #697386;"><%= event.attempted_email %></span>
209
+ <span style="color: #697386;"><%= audit_user_label(event) %></span>
220
210
  <% else %>
221
211
  <span style="color: #C1C7D0;">-</span>
222
212
  <% end %>
@@ -0,0 +1,10 @@
1
+ <%= form_with url: export_url, method: :get, local: true do %>
2
+ <% request.query_parameters.except("format", "audit_reason", "page", "per_page").each do |key, value| %>
3
+ <%= hidden_field_tag key, value if value.is_a?(String) %>
4
+ <% end %>
5
+ <%= label_tag :export_audit_reason, "Export reason" %>
6
+ <%= text_field_tag :audit_reason, nil, id: :export_audit_reason, required: true, maxlength: 1000 %>
7
+ <%= label_tag :export_format, "Export format" %>
8
+ <%= select_tag :format, options_for_select([["CSV", "csv"], ["JSON", "json"]]), id: :export_format %>
9
+ <%= submit_tag "Export", class: "btn btn-secondary" %>
10
+ <% end %>
@@ -0,0 +1,25 @@
1
+ <% analysis = analysis.is_a?(Hash) ? analysis : {} %>
2
+ <div class="card" data-waf-analysis>
3
+ <div class="card-header">
4
+ <h3 class="card-title">WAF Analysis</h3>
5
+ </div>
6
+ <div class="card-body">
7
+ <p><strong><%= local_assigns[:banned] ? "Request that triggered the ban" : "Requested path" %></strong></p>
8
+ <div style="font-family: monospace; font-size: 13px; background: #F4F5F7; padding: 0.75rem; border-radius: 6px; overflow-wrap: anywhere;">
9
+ <% if analysis["request_path"].present? %>
10
+ <span><%= analysis["request_method"] %></span>
11
+ <span data-request-path><%= analysis["request_path"] %></span>
12
+ <% else %>
13
+ <span data-request-path>Not recorded</span>
14
+ <% end %>
15
+ </div>
16
+ <% if analysis["patterns"].is_a?(Array) %>
17
+ <ul>
18
+ <% analysis["patterns"].each do |pattern| %>
19
+ <% next unless pattern.is_a?(Hash) %>
20
+ <li><%= pattern["description"] %> (<%= pattern["rule_id"] %>, <%= pattern["severity"] %>)</li>
21
+ <% end %>
22
+ </ul>
23
+ <% end %>
24
+ </div>
25
+ </div>
@@ -0,0 +1,121 @@
1
+ <%= javascript_tag nonce: true do %>
2
+ (() => {
3
+ // One delegated installation per document, even if a host evaluates the
4
+ // layout again. Native navigation is the dashboard's supported boundary.
5
+ if (window.beskarDashboardBehaviorInstalled) return;
6
+ window.beskarDashboardBehaviorInstalled = true;
7
+
8
+ function isPermanent(form) {
9
+ return form.querySelector('[name="ban_type"]:checked')?.value === 'permanent' ||
10
+ form.querySelector('[name="banned_ip[permanent]"]:checked')?.value === 'true';
11
+ }
12
+
13
+ function updateBanForm(form) {
14
+ const permanent = isPermanent(form);
15
+ const options = form.querySelector('#temporary-options');
16
+ options.hidden = permanent;
17
+ options.querySelectorAll('input, select, button').forEach(input => { input.disabled = permanent; });
18
+ const expiry = form.querySelector('#banned_ip_expires_at');
19
+ expiry.required = !permanent && !!form.querySelector('[name="banned_ip[permanent]"]');
20
+ const preview = document.getElementById('ban-preview');
21
+ if (!preview) return;
22
+ const ip = form.querySelector('#banned_ip_ip_address');
23
+ const reason = form.querySelector('#banned_ip_reason');
24
+ preview.hidden = !(ip.value || reason.value);
25
+ document.getElementById('preview-ip').textContent = ip.value || '-';
26
+ document.getElementById('preview-reason').textContent = reason.selectedOptions[0]?.textContent || '-';
27
+ const duration = form.querySelector('[name="duration"]:checked');
28
+ document.getElementById('preview-duration').textContent = permanent ? 'Permanent' :
29
+ (expiry.value ? `${expiry.value.replace('T', ' ')} UTC` : (duration?.parentElement.textContent.trim() || '24 hours (server default)'));
30
+ const status = document.getElementById('preview-status');
31
+ status.textContent = permanent ? 'Permanent' : 'Temporary';
32
+ status.className = permanent ? 'badge badge-critical' : 'badge badge-danger';
33
+ }
34
+
35
+ function updateSelection(form) {
36
+ const boxes = Array.from(form.querySelectorAll('.ban-checkbox'));
37
+ const count = boxes.filter(box => box.checked).length;
38
+ form.querySelector('#selected-count').textContent = count;
39
+ form.querySelector('#bulk-actions-bar').hidden = count === 0;
40
+ const all = form.querySelector('#select-all');
41
+ all.checked = boxes.length > 0 && count === boxes.length;
42
+ all.indeterminate = count > 0 && count < boxes.length;
43
+ }
44
+
45
+ function initialize() {
46
+ if (!document.querySelector('[data-beskar-dashboard]')) return;
47
+ document.querySelectorAll('[data-beskar-ban-form]').forEach(updateBanForm);
48
+ document.querySelectorAll('#bulk-actions-form').forEach(updateSelection);
49
+ }
50
+
51
+ document.addEventListener('change', event => {
52
+ if (!event.target.closest('[data-beskar-dashboard]')) return;
53
+ if (event.target.matches('[data-beskar-auto-submit]')) event.target.form.requestSubmit();
54
+ const form = event.target.closest('[data-beskar-ban-form]');
55
+ if (form) {
56
+ const expiry = form.querySelector('#banned_ip_expires_at');
57
+ if (event.target.name === 'duration') expiry.value = '';
58
+ if (event.target.name === 'banned_ip[permanent]' && !isPermanent(form) && !expiry.value) {
59
+ expiry.value = new Date(Date.now() + 86400000).toISOString().slice(0, -1);
60
+ }
61
+ updateBanForm(form);
62
+ }
63
+ const bulk = event.target.closest('#bulk-actions-form');
64
+ if (bulk) {
65
+ if (event.target.id === 'select-all') {
66
+ bulk.querySelectorAll('.ban-checkbox').forEach(box => { box.checked = event.target.checked; });
67
+ }
68
+ updateSelection(bulk);
69
+ }
70
+ });
71
+
72
+ document.addEventListener('input', event => {
73
+ const form = event.target.closest('[data-beskar-ban-form]');
74
+ if (!form) return;
75
+ if (event.target.id === 'banned_ip_expires_at' && event.target.value) {
76
+ form.querySelectorAll('[name="duration"]').forEach(input => { input.checked = false; });
77
+ }
78
+ updateBanForm(form);
79
+ });
80
+
81
+ document.addEventListener('click', event => {
82
+ if (!event.target.closest('[data-beskar-dashboard]')) return;
83
+ const clear = event.target.closest('[data-beskar-clear-selection]');
84
+ if (clear) {
85
+ const form = clear.closest('form');
86
+ form.querySelectorAll('.ban-checkbox').forEach(box => { box.checked = false; });
87
+ updateSelection(form);
88
+ }
89
+ const extend = event.target.closest('[data-beskar-extend-hours]');
90
+ if (extend) {
91
+ const form = extend.closest('form');
92
+ const expiry = form.querySelector('#banned_ip_expires_at');
93
+ // The displayed wall-clock value is UTC, never browser-local time.
94
+ const base = expiry.value ? Date.parse(`${expiry.value}Z`) : Date.now();
95
+ if (Number.isFinite(base)) {
96
+ expiry.value = new Date(Math.max(base, Date.now()) + Number(extend.dataset.beskarExtendHours) * 3600000).toISOString().slice(0, -1);
97
+ updateBanForm(form);
98
+ }
99
+ }
100
+ const permanent = event.target.closest('[data-beskar-make-permanent]');
101
+ if (permanent) {
102
+ const form = permanent.closest('form');
103
+ form.querySelector('[name="banned_ip[permanent]"][value="true"]').checked = true;
104
+ updateBanForm(form);
105
+ }
106
+ });
107
+
108
+ document.addEventListener('submit', event => {
109
+ if (!event.target.closest('[data-beskar-dashboard]')) return;
110
+ const message = event.submitter?.dataset.beskarConfirm;
111
+ if (message && !window.confirm(message)) event.preventDefault();
112
+ // Do not synthesize forms, bypass validation, or convert duration presets
113
+ // to client-clock timestamps. Rails forms carry CSRF and method fields.
114
+ });
115
+
116
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialize, { once: true });
117
+ else initialize();
118
+ document.addEventListener('pageshow', initialize);
119
+ document.addEventListener('turbo:load', initialize);
120
+ })();
121
+ <% end %>
@@ -8,7 +8,7 @@
8
8
  <%= csp_meta_tag %>
9
9
 
10
10
  <!-- Embedded Styles (No external dependencies) -->
11
- <style>
11
+ <style nonce="<%= content_security_policy_nonce %>">
12
12
  :root {
13
13
  --primary: #635BFF;
14
14
  --primary-dark: #5243CC;
@@ -384,6 +384,7 @@
384
384
  input[type="password"],
385
385
  input[type="number"],
386
386
  input[type="date"],
387
+ input[type="datetime-local"],
387
388
  select,
388
389
  textarea {
389
390
  width: 100%;
@@ -492,7 +493,7 @@
492
493
 
493
494
  <%= yield :head %>
494
495
  </head>
495
- <body>
496
+ <body data-beskar-dashboard data-turbo="false">
496
497
  <div class="dashboard-container">
497
498
  <!-- Sidebar -->
498
499
  <aside class="sidebar">
@@ -531,6 +532,11 @@
531
532
  <% end %>
532
533
  </div>
533
534
 
535
+ <div class="nav-section">
536
+ <%= link_to "Administrative Actions", administrative_actions_path,
537
+ class: "nav-link #{controller_name == 'administrative_actions' ? 'active' : ''}" %>
538
+ </div>
539
+
534
540
  <div class="nav-section">
535
541
  <div class="nav-section-title">Configuration</div>
536
542
  <% if Beskar.configuration.monitor_only? %>
@@ -581,79 +587,6 @@
581
587
  </main>
582
588
  </div>
583
589
 
584
- <%= javascript_tag nonce: true do %>
585
- // Add active state to current nav link
586
- document.addEventListener('DOMContentLoaded', function() {
587
- // Handle nav link clicks
588
- document.querySelectorAll('.nav-link').forEach(link => {
589
- if (link.href === window.location.href) {
590
- link.classList.add('active');
591
- }
592
- });
593
-
594
- // Auto-dismiss alerts after 5 seconds
595
- setTimeout(() => {
596
- document.querySelectorAll('.alert').forEach(alert => {
597
- alert.style.transition = 'opacity 0.5s';
598
- alert.style.opacity = '0';
599
- setTimeout(() => alert.remove(), 500);
600
- });
601
- }, 5000);
602
-
603
- // Handle data-turbo-method links (Rails UJS replacement)
604
- document.addEventListener('click', function(e) {
605
- const link = e.target.closest('a[data-turbo-method]');
606
- if (!link) return;
607
-
608
- e.preventDefault();
609
-
610
- const method = link.dataset.turboMethod || link.dataset['turbo-method'];
611
- const confirm = link.dataset.turboConfirm || link.dataset['turbo-confirm'];
612
-
613
- if (confirm && !window.confirm(confirm)) {
614
- return;
615
- }
616
-
617
- const form = document.createElement('form');
618
- form.method = 'POST';
619
- form.action = link.href;
620
- form.style.display = 'none';
621
-
622
- // Add CSRF token
623
- const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
624
- const csrfParam = document.querySelector('meta[name="csrf-param"]').content;
625
-
626
- if (csrfToken && csrfParam) {
627
- const input = document.createElement('input');
628
- input.type = 'hidden';
629
- input.name = csrfParam;
630
- input.value = csrfToken;
631
- form.appendChild(input);
632
- }
633
-
634
- // Add method override for DELETE, PUT, PATCH
635
- if (method && method.toLowerCase() !== 'post') {
636
- const methodInput = document.createElement('input');
637
- methodInput.type = 'hidden';
638
- methodInput.name = '_method';
639
- methodInput.value = method;
640
- form.appendChild(methodInput);
641
- }
642
-
643
- // Add any URL parameters as form inputs
644
- const url = new URL(link.href);
645
- url.searchParams.forEach((value, key) => {
646
- const input = document.createElement('input');
647
- input.type = 'hidden';
648
- input.name = key;
649
- input.value = value;
650
- form.appendChild(input);
651
- });
652
-
653
- document.body.appendChild(form);
654
- form.submit();
655
- });
656
- });
657
- <% end %>
590
+ <%= render "layouts/beskar/behavior" %>
658
591
  </body>
659
592
  </html>
data/config/routes.rb CHANGED
@@ -1,43 +1,32 @@
1
1
  Beskar::Engine.routes.draw do
2
2
  # Root route - dashboard
3
- root to: 'dashboard#index'
3
+ root to: "dashboard#index"
4
4
 
5
5
  # Dashboard
6
- get 'dashboard', to: 'dashboard#index', as: :dashboard
6
+ get "dashboard", to: "dashboard#index", as: :dashboard
7
7
 
8
8
  # Security Events
9
9
  resources :security_events, only: [:index, :show] do
10
10
  collection do
11
- get 'export'
11
+ get "export"
12
12
  end
13
13
  end
14
14
 
15
15
  # Banned IPs
16
16
  resources :banned_ips do
17
17
  member do
18
- post 'extend'
18
+ post "extend"
19
+ get "review"
19
20
  end
20
21
 
21
22
  collection do
22
- post 'bulk_action'
23
- get 'export'
23
+ post "bulk_action"
24
+ get "export"
24
25
  end
25
26
  end
26
27
 
27
- # API endpoints (optional, for future AJAX calls)
28
- namespace :api do
29
- namespace :v1 do
30
- resources :security_events, only: [:index, :show] do
31
- collection do
32
- get 'stats'
33
- end
34
- end
28
+ resources :administrative_actions, only: [:index, :show]
35
29
 
36
- resources :banned_ips, only: [:index, :show, :create, :destroy] do
37
- member do
38
- post 'extend'
39
- end
40
- end
41
- end
42
- end
30
+ # No versioned JSON API is implemented. Authenticated CSV/JSON exports live
31
+ # on the resources above; do not expose routes to nonexistent controllers.
43
32
  end
@@ -8,7 +8,7 @@ class CreateBeskarSecurityEvents < ActiveRecord::Migration[8.0]
8
8
  t.string :ip_address
9
9
  t.string :attempted_email
10
10
  t.text :user_agent
11
- t.json :metadata, default: {}
11
+ t.json :metadata, default: -> { "('{}')" }
12
12
  t.integer :risk_score
13
13
 
14
14
  t.timestamps
@@ -19,7 +19,7 @@ class CreateBeskarSecurityEvents < ActiveRecord::Migration[8.0]
19
19
  add_index :beskar_security_events, :attempted_email
20
20
  add_index :beskar_security_events, :created_at
21
21
  add_index :beskar_security_events, :risk_score
22
- add_index :beskar_security_events, [:ip_address, :event_type, :created_at],
23
- name: 'index_security_events_on_ip_event_time'
22
+ add_index :beskar_security_events, [:ip_address, :event_type, :created_at],
23
+ name: "index_security_events_on_ip_event_time"
24
24
  end
25
25
  end