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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +177 -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 +18 -15
- data/app/views/beskar/dashboard/index.html.erb +6 -6
- data/app/views/beskar/security_events/index.html.erb +18 -16
- data/app/views/beskar/security_events/show.html.erb +10 -20
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/beskar/shared/_waf_analysis.html.erb +25 -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 +188 -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 +49 -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 +148 -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 +103 -125
- data/lib/beskar/services/request_context.rb +69 -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 +166 -280
- data/lib/beskar/services/waf_request.rb +70 -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 +105 -20
- data/lib/tasks/beskar_tasks.rake +15 -19
- 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 "
|
|
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;">
|
|
@@ -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
|
|
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
|
|
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",
|
|
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
|
|
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>
|
|
@@ -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
|
-
<%=
|
|
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>
|
|
@@ -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
|
|
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
|
|
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="
|
|
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
|
|
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>
|
|
@@ -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
|
|
207
|
+
<%= audit_user_label(event) %>
|
|
218
208
|
<% elsif event.attempted_email %>
|
|
219
|
-
<span style="color: #697386;"><%= event
|
|
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
|
-
<%=
|
|
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
|