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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +150 -19
- data/README.md +142 -122
- data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
- data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
- data/app/controllers/beskar/application_controller.rb +69 -25
- data/app/controllers/beskar/banned_ips_controller.rb +116 -141
- data/app/controllers/beskar/dashboard_controller.rb +20 -28
- data/app/controllers/beskar/security_events_controller.rb +37 -55
- data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
- data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
- data/app/jobs/beskar/notification_job.rb +33 -0
- data/app/mailers/beskar/security_mailer.rb +59 -0
- data/app/models/beskar/administrative_action.rb +41 -0
- data/app/models/beskar/banned_ip.rb +91 -132
- data/app/models/beskar/security_event.rb +37 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +16 -6
- data/app/views/beskar/administrative_actions/index.html.erb +33 -0
- data/app/views/beskar/administrative_actions/show.html.erb +21 -0
- data/app/views/beskar/banned_ips/edit.html.erb +25 -89
- data/app/views/beskar/banned_ips/index.html.erb +20 -62
- data/app/views/beskar/banned_ips/new.html.erb +18 -138
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +9 -15
- data/app/views/beskar/dashboard/index.html.erb +4 -4
- data/app/views/beskar/security_events/index.html.erb +8 -15
- data/app/views/beskar/security_events/show.html.erb +6 -20
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/layouts/beskar/_behavior.html.erb +121 -0
- data/app/views/layouts/beskar/application.html.erb +9 -76
- data/config/routes.rb +10 -21
- data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
- data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
- data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
- data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
- data/docs/README.md +73 -0
- data/docs/archive/project-documentation.md +659 -0
- data/docs/audits/project-review.md +437 -0
- data/docs/audits/repair-status.md +216 -0
- data/docs/guides/audit-and-waf.md +175 -0
- data/docs/guides/audit-lifecycle.md +172 -0
- data/docs/guides/authentication.md +213 -0
- data/docs/guides/configuration.md +182 -0
- data/docs/guides/dashboard-and-search.md +251 -0
- data/docs/guides/notifications-and-recovery.md +157 -0
- data/docs/guides/risk-scoring.md +116 -0
- data/docs/operations/monitor-only-mode.md +85 -0
- data/docs/operations/security-hardening.md +167 -0
- data/docs/operations/state-storage.md +144 -0
- data/docs/research/rust-performance-assessment.md +69 -0
- data/lib/beskar/configuration.rb +84 -13
- data/lib/beskar/configuration_validator.rb +188 -0
- data/lib/beskar/devise_authentication.rb +24 -0
- data/lib/beskar/engine.rb +21 -88
- data/lib/beskar/logger.rb +30 -35
- data/lib/beskar/middleware/request_analyzer.rb +41 -82
- data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
- data/lib/beskar/models/security_trackable_devise.rb +32 -23
- data/lib/beskar/models/security_trackable_generic.rb +169 -212
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +85 -76
- data/lib/beskar/services/administrative_audit.rb +36 -0
- data/lib/beskar/services/administrative_bans.rb +104 -0
- data/lib/beskar/services/audit_data.rb +72 -0
- data/lib/beskar/services/authentication.rb +31 -0
- data/lib/beskar/services/authentication_attempt.rb +141 -0
- data/lib/beskar/services/ban_expiry.rb +28 -0
- data/lib/beskar/services/device_detector.rb +32 -41
- data/lib/beskar/services/event_search.rb +58 -0
- data/lib/beskar/services/geolocation_service.rb +83 -114
- data/lib/beskar/services/ip_whitelist.rb +31 -40
- data/lib/beskar/services/location_assessment.rb +109 -0
- data/lib/beskar/services/native_account_lock.rb +82 -0
- data/lib/beskar/services/notifications.rb +46 -0
- data/lib/beskar/services/rate_limiter.rb +99 -125
- data/lib/beskar/services/request_context.rb +64 -0
- data/lib/beskar/services/risk_assessment.rb +58 -0
- data/lib/beskar/services/session_revocation.rb +62 -0
- data/lib/beskar/services/waf.rb +164 -280
- data/lib/beskar/services/waf_request.rb +60 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar/warden_authentication.rb +53 -0
- data/lib/beskar.rb +53 -4
- data/lib/generators/beskar/install/install_generator.rb +36 -36
- data/lib/generators/beskar/install/templates/initializer.rb.tt +104 -20
- data/lib/tasks/beskar_tasks.rake +15 -19
- metadata +60 -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 "
|
|
7
|
-
|
|
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]
|
|
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]
|
|
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;">
|
|
@@ -239,11 +235,11 @@
|
|
|
239
235
|
<td>
|
|
240
236
|
<% if event.user %>
|
|
241
237
|
<div style="font-size: 13px;">
|
|
242
|
-
<%= event
|
|
238
|
+
<%= audit_user_label(event) %>
|
|
243
239
|
</div>
|
|
244
240
|
<% elsif event.attempted_email %>
|
|
245
241
|
<div style="font-size: 13px; color: #697386;">
|
|
246
|
-
<%= event
|
|
242
|
+
<%= audit_user_label(event) %>
|
|
247
243
|
</div>
|
|
248
244
|
<% else %>
|
|
249
245
|
<span style="color: #C1C7D0;">-</span>
|
|
@@ -295,16 +291,14 @@
|
|
|
295
291
|
['Make Permanent', 'permanent']
|
|
296
292
|
]),
|
|
297
293
|
style: "padding: 0.5rem 0.75rem; font-size: 14px;" %>
|
|
294
|
+
<%= label_tag :audit_reason, "Administrative reason" %>
|
|
295
|
+
<%= text_field_tag :audit_reason, nil, required: true, maxlength: 1000 %>
|
|
298
296
|
<%= submit_tag "Extend Ban", class: "btn btn-warning" %>
|
|
299
297
|
<% end %>
|
|
300
298
|
</div>
|
|
301
299
|
<% end %>
|
|
302
300
|
|
|
303
|
-
<%= link_to "Unban IP",
|
|
304
|
-
data: {
|
|
305
|
-
"turbo-method": "delete",
|
|
306
|
-
"turbo-confirm": "Are you sure you want to unban #{@banned_ip.ip_address}?"
|
|
307
|
-
},
|
|
301
|
+
<%= link_to "Unban IP", review_banned_ip_path(@banned_ip, operation: "unban"),
|
|
308
302
|
class: "btn btn-danger",
|
|
309
303
|
style: "margin-left: #{ @banned_ip.active? && !@banned_ip.permanent? ? '0' : 'auto' };" %>
|
|
310
304
|
</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
|
|
41
|
+
<div class="stat-change">Score ≥ <%= 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
|
|
49
|
+
<div class="stat-change">Score ≥ <%= 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
|
|
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
|
|
179
|
+
<%= audit_user_label(event) %>
|
|
180
180
|
</div>
|
|
181
181
|
<% else %>
|
|
182
182
|
<span style="color: #C1C7D0;">-</span>
|
|
@@ -2,10 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<% content_for :header_actions do %>
|
|
4
4
|
<div style="display: flex; gap: 0.5rem;">
|
|
5
|
-
<%=
|
|
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(
|
|
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
|
-
|
|
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>
|
|
@@ -178,14 +171,14 @@
|
|
|
178
171
|
<td>
|
|
179
172
|
<% if event.user %>
|
|
180
173
|
<div style="font-size: 13px;">
|
|
181
|
-
<%= event
|
|
174
|
+
<%= audit_user_label(event) %>
|
|
182
175
|
</div>
|
|
183
176
|
<div style="font-size: 11px; color: #697386;">
|
|
184
177
|
ID: <%= event.user_id %>
|
|
185
178
|
</div>
|
|
186
179
|
<% elsif event.attempted_email %>
|
|
187
180
|
<div style="font-size: 13px; color: #697386;">
|
|
188
|
-
<%= event
|
|
181
|
+
<%= audit_user_label(event) %>
|
|
189
182
|
</div>
|
|
190
183
|
<div style="font-size: 11px; color: #C1C7D0;">
|
|
191
184
|
(attempted)
|
|
@@ -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
|
|
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
|
|
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>
|
|
@@ -214,9 +200,9 @@
|
|
|
214
200
|
</td>
|
|
215
201
|
<td>
|
|
216
202
|
<% if event.user %>
|
|
217
|
-
<%= event
|
|
203
|
+
<%= audit_user_label(event) %>
|
|
218
204
|
<% elsif event.attempted_email %>
|
|
219
|
-
<span style="color: #697386;"><%= event
|
|
205
|
+
<span style="color: #697386;"><%= audit_user_label(event) %></span>
|
|
220
206
|
<% else %>
|
|
221
207
|
<span style="color: #C1C7D0;">-</span>
|
|
222
208
|
<% 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,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
|
-
<%=
|
|
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:
|
|
3
|
+
root to: "dashboard#index"
|
|
4
4
|
|
|
5
5
|
# Dashboard
|
|
6
|
-
get
|
|
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
|
|
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
|
|
18
|
+
post "extend"
|
|
19
|
+
get "review"
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
collection do
|
|
22
|
-
post
|
|
23
|
-
get
|
|
23
|
+
post "bulk_action"
|
|
24
|
+
get "export"
|
|
24
25
|
end
|
|
25
26
|
end
|
|
26
27
|
|
|
27
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
class CreateBeskarSecurityStates < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :beskar_security_states do |t|
|
|
4
|
+
t.string :key, null: false
|
|
5
|
+
t.json :data, null: false, default: -> { "('{}')" }
|
|
6
|
+
t.datetime :expires_at
|
|
7
|
+
t.integer :lock_version, null: false, default: 0
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
add_index :beskar_security_states, :key, unique: true
|
|
11
|
+
add_index :beskar_security_states, :expires_at
|
|
12
|
+
add_index :beskar_security_events, [:user_type, :user_id, :event_type, :created_at], name: "index_beskar_events_on_user_event_time"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CreateBeskarAdministrativeActions < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :beskar_administrative_actions do |t|
|
|
4
|
+
t.string :actor, null: false, limit: 200
|
|
5
|
+
t.string :action, null: false
|
|
6
|
+
t.bigint :target_id, null: false
|
|
7
|
+
t.string :operation_id, null: false, limit: 36
|
|
8
|
+
t.string :request_id, null: false, limit: 200
|
|
9
|
+
t.text :reason, null: false
|
|
10
|
+
# MySQL requires JSON defaults to be expressions, even for literals.
|
|
11
|
+
t.json :before_state, null: false, default: -> { "('{}')" }
|
|
12
|
+
t.json :after_state, null: false, default: -> { "('{}')" }
|
|
13
|
+
t.datetime :created_at, null: false
|
|
14
|
+
end
|
|
15
|
+
# No user/ban foreign key or cascading association: the action must survive
|
|
16
|
+
# deletion of its live target. Retention is a separate, explicit operation.
|
|
17
|
+
add_index :beskar_administrative_actions, [:operation_id, :target_id], unique: true,
|
|
18
|
+
name: "index_beskar_actions_on_operation_target"
|
|
19
|
+
add_index :beskar_administrative_actions, [:target_id, :id]
|
|
20
|
+
add_index :beskar_administrative_actions, :created_at
|
|
21
|
+
end
|
|
22
|
+
end
|
data/docs/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# Beskar documentation
|
|
2
|
+
|
|
3
|
+
Start with the current guides and operational contracts below. The original
|
|
4
|
+
review and archived overview describe earlier code; they are not current setup
|
|
5
|
+
instructions. [Repair status](audits/repair-status.md) distinguishes verified
|
|
6
|
+
changes from open findings and production validation still required.
|
|
7
|
+
|
|
8
|
+
## Start here
|
|
9
|
+
|
|
10
|
+
- **Installing or integrating:** follow the [project README](../README.md#installation),
|
|
11
|
+
then read [configuration](guides/configuration.md),
|
|
12
|
+
[authentication](guides/authentication.md), and [state storage](operations/state-storage.md).
|
|
13
|
+
- **Upgrading an existing installation:** use the
|
|
14
|
+
[security hardening and rollout checklist](operations/security-hardening.md#rollout).
|
|
15
|
+
- **Working on repairs:** consult [repair status](audits/repair-status.md) and the
|
|
16
|
+
[original findings](audits/project-review.md) before selecting work.
|
|
17
|
+
|
|
18
|
+
## Guides
|
|
19
|
+
|
|
20
|
+
- [Configuration](guides/configuration.md) — validation, supported capabilities,
|
|
21
|
+
defaults, and audited runtime changes.
|
|
22
|
+
- [Authentication](guides/authentication.md) — Devise/Warden admission, native
|
|
23
|
+
session integration, account locks, and revocation boundaries.
|
|
24
|
+
- [Risk scoring](guides/risk-scoring.md) — factors, geographic evidence, and
|
|
25
|
+
heuristic limitations.
|
|
26
|
+
- [Audit data and WAF](guides/audit-and-waf.md) — filtering, exports, scanner
|
|
27
|
+
matching, and privacy boundaries.
|
|
28
|
+
- [Audit lifecycle](guides/audit-lifecycle.md) — unchanged event retention,
|
|
29
|
+
administrative permissions, actor/reason history, and migrations.
|
|
30
|
+
- [Dashboard and search](guides/dashboard-and-search.md) — reporting, search,
|
|
31
|
+
routes, forms, timezones, and browser/CSP behavior.
|
|
32
|
+
- [Notifications and recovery](guides/notifications-and-recovery.md) — opt-in
|
|
33
|
+
delivery jobs and host-owned recovery flows.
|
|
34
|
+
|
|
35
|
+
## Operations
|
|
36
|
+
|
|
37
|
+
- [State storage](operations/state-storage.md) — database authority, cache
|
|
38
|
+
independence, concurrency, rate limits, cleanup, and rollout.
|
|
39
|
+
- [Monitor-only mode](operations/monitor-only-mode.md) — observation behavior and
|
|
40
|
+
the transition to enforcement.
|
|
41
|
+
- [Security hardening](operations/security-hardening.md) — authentication
|
|
42
|
+
coverage, deployment requirements, availability tradeoffs, and validation gates.
|
|
43
|
+
|
|
44
|
+
## Audits and repair tracking
|
|
45
|
+
|
|
46
|
+
- [Repair status](audits/repair-status.md) — completed batches, test evidence,
|
|
47
|
+
unresolved findings, and next verification gates.
|
|
48
|
+
- [Original project review](audits/project-review.md) — the September 2026
|
|
49
|
+
diagnostic baseline and F01–F26 findings; preserved as historical evidence.
|
|
50
|
+
|
|
51
|
+
## Research and historical reference
|
|
52
|
+
|
|
53
|
+
- [Rust performance assessment](research/rust-performance-assessment.md) — a
|
|
54
|
+
measured investigation and proposal, not an implemented feature or performance
|
|
55
|
+
guarantee.
|
|
56
|
+
- [Archived project overview](archive/project-documentation.md) — the
|
|
57
|
+
pre-remediation architecture and examples; do not use it as current guidance.
|
|
58
|
+
- [Changelog](../CHANGELOG.md) — historical release notes.
|
|
59
|
+
|
|
60
|
+
## Maintaining these docs
|
|
61
|
+
|
|
62
|
+
Keep `README.md` and `CHANGELOG.md` at the repository root. Put current feature
|
|
63
|
+
guides in `guides/`, deployment/runtime contracts in `operations/`, findings and
|
|
64
|
+
progress in `audits/`, proposals in `research/`, and superseded references in
|
|
65
|
+
`archive/`. Use lowercase, hyphenated filenames and relative Markdown links.
|
|
66
|
+
Source paths mentioned in prose are relative to the repository root.
|
|
67
|
+
|
|
68
|
+
Add new documents to this index. The gem includes `docs/**/*.md`; link/index and
|
|
69
|
+
packaging regressions run with:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
mise exec -- env PARALLEL_WORKERS=1 bin/rails test test/documentation_test.rb
|
|
73
|
+
```
|