beskar 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +143 -0
  3. data/README.md +298 -110
  4. data/app/controllers/beskar/application_controller.rb +170 -0
  5. data/app/controllers/beskar/banned_ips_controller.rb +280 -0
  6. data/app/controllers/beskar/dashboard_controller.rb +70 -0
  7. data/app/controllers/beskar/security_events_controller.rb +182 -0
  8. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +6 -6
  9. data/app/models/beskar/banned_ip.rb +68 -27
  10. data/app/models/beskar/security_event.rb +14 -0
  11. data/app/services/beskar/banned_ip_manager.rb +78 -0
  12. data/app/views/beskar/banned_ips/edit.html.erb +259 -0
  13. data/app/views/beskar/banned_ips/index.html.erb +361 -0
  14. data/app/views/beskar/banned_ips/new.html.erb +310 -0
  15. data/app/views/beskar/banned_ips/show.html.erb +310 -0
  16. data/app/views/beskar/dashboard/index.html.erb +280 -0
  17. data/app/views/beskar/security_events/index.html.erb +309 -0
  18. data/app/views/beskar/security_events/show.html.erb +307 -0
  19. data/app/views/layouts/beskar/application.html.erb +647 -5
  20. data/config/routes.rb +41 -0
  21. data/lib/beskar/configuration.rb +24 -10
  22. data/lib/beskar/engine.rb +4 -4
  23. data/lib/beskar/logger.rb +293 -0
  24. data/lib/beskar/middleware/request_analyzer.rb +128 -53
  25. data/lib/beskar/models/security_trackable_authenticable.rb +11 -11
  26. data/lib/beskar/models/security_trackable_devise.rb +5 -5
  27. data/lib/beskar/models/security_trackable_generic.rb +12 -12
  28. data/lib/beskar/services/account_locker.rb +12 -12
  29. data/lib/beskar/services/geolocation_service.rb +8 -8
  30. data/lib/beskar/services/ip_whitelist.rb +2 -2
  31. data/lib/beskar/services/waf.rb +307 -78
  32. data/lib/beskar/version.rb +1 -1
  33. data/lib/beskar.rb +1 -0
  34. data/lib/generators/beskar/install/install_generator.rb +158 -0
  35. data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
  36. data/lib/tasks/beskar_tasks.rake +11 -2
  37. metadata +35 -6
  38. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -0,0 +1,310 @@
1
+ <% content_for :page_title, "Banned IP: #{@banned_ip.ip_address}" %>
2
+
3
+ <% content_for :header_actions do %>
4
+ <%= link_to "← Back to Banned IPs", banned_ips_path, class: "btn btn-secondary" %>
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" %>
12
+ <% end %>
13
+
14
+ <!-- Ban Overview -->
15
+ <div class="card">
16
+ <div class="card-header">
17
+ <h3 class="card-title">Ban Details</h3>
18
+ <div class="card-subtitle">IP Address: <%= @banned_ip.ip_address %></div>
19
+ </div>
20
+ <div class="card-body">
21
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem;">
22
+ <!-- IP Address -->
23
+ <div>
24
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
25
+ IP Address
26
+ </div>
27
+ <div style="font-family: monospace; font-size: 18px; font-weight: 500; color: #1A1F36;">
28
+ <%= @banned_ip.ip_address %>
29
+ </div>
30
+ <% if @banned_ip.metadata&.dig("geolocation") %>
31
+ <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
32
+ <%= [@banned_ip.metadata.dig("geolocation", "city"),
33
+ @banned_ip.metadata.dig("geolocation", "region"),
34
+ @banned_ip.metadata.dig("geolocation", "country")].compact.join(", ") %>
35
+ </div>
36
+ <% end %>
37
+ </div>
38
+
39
+ <!-- Status -->
40
+ <div>
41
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
42
+ Status
43
+ </div>
44
+ <div style="display: flex; align-items: center; gap: 0.75rem;">
45
+ <% if @banned_ip.permanent? %>
46
+ <span class="badge badge-critical">Permanent Ban</span>
47
+ <% elsif @banned_ip.active? %>
48
+ <span class="badge badge-danger">Active</span>
49
+ <% else %>
50
+ <span class="badge badge-neutral">Expired</span>
51
+ <% end %>
52
+ </div>
53
+ <% if @banned_ip.active? && !@banned_ip.permanent? && @banned_ip.expires_at %>
54
+ <div style="font-size: 13px; color: #697386; margin-top: 0.5rem;">
55
+ Expires in <%= distance_of_time_in_words(Time.current, @banned_ip.expires_at) %>
56
+ </div>
57
+ <% end %>
58
+ </div>
59
+
60
+ <!-- Reason -->
61
+ <div>
62
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
63
+ Ban Reason
64
+ </div>
65
+ <div style="font-size: 14px; color: #1A1F36;">
66
+ <%= @banned_ip.reason.humanize %>
67
+ </div>
68
+ <% if @banned_ip.details.present? %>
69
+ <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
70
+ <%= @banned_ip.details %>
71
+ </div>
72
+ <% end %>
73
+ </div>
74
+
75
+ <!-- Banned At -->
76
+ <div>
77
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
78
+ Banned At
79
+ </div>
80
+ <div style="font-size: 14px; color: #1A1F36;">
81
+ <%= @banned_ip.banned_at.strftime("%B %d, %Y") %>
82
+ </div>
83
+ <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
84
+ <%= @banned_ip.banned_at.strftime("%H:%M:%S %Z") %>
85
+ (<%= time_ago_in_words(@banned_ip.banned_at) %> ago)
86
+ </div>
87
+ </div>
88
+
89
+ <!-- Expires At -->
90
+ <div>
91
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
92
+ Expires At
93
+ </div>
94
+ <% if @banned_ip.permanent? %>
95
+ <div style="font-size: 14px; color: #D32F2F; font-weight: 500;">
96
+ Never (Permanent Ban)
97
+ </div>
98
+ <% elsif @banned_ip.expires_at %>
99
+ <div style="font-size: 14px; color: #1A1F36;">
100
+ <%= @banned_ip.expires_at.strftime("%B %d, %Y") %>
101
+ </div>
102
+ <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
103
+ <%= @banned_ip.expires_at.strftime("%H:%M:%S %Z") %>
104
+ </div>
105
+ <% else %>
106
+ <div style="color: #C1C7D0;">Not set</div>
107
+ <% end %>
108
+ </div>
109
+
110
+ <!-- Violation Count -->
111
+ <div>
112
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
113
+ Violation Count
114
+ </div>
115
+ <div style="font-size: 24px; font-weight: 600; color: <%= @banned_ip.violation_count > 3 ? '#D32F2F' : '#1A1F36' %>;">
116
+ <%= @banned_ip.violation_count %>
117
+ </div>
118
+ <% if @banned_ip.violation_count > 0 %>
119
+ <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
120
+ <%= @banned_ip.violation_count == 1 ? 'violation' : 'violations' %> recorded
121
+ </div>
122
+ <% end %>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <!-- Statistics -->
129
+ <div class="card">
130
+ <div class="card-header">
131
+ <h3 class="card-title">IP Statistics</h3>
132
+ <div class="card-subtitle">Activity summary for <%= @banned_ip.ip_address %></div>
133
+ </div>
134
+ <div class="card-body">
135
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem;">
136
+ <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
137
+ <div style="font-size: 24px; font-weight: 600; color: #1A1F36;">
138
+ <%= @stats[:total_events] %>
139
+ </div>
140
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
141
+ Total Events
142
+ </div>
143
+ </div>
144
+
145
+ <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' %>;">
147
+ <%= @stats[:avg_risk_score] %>
148
+ </div>
149
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
150
+ Avg Risk Score
151
+ </div>
152
+ </div>
153
+
154
+ <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' %>;">
156
+ <%= @stats[:max_risk_score] %>
157
+ </div>
158
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
159
+ Max Risk Score
160
+ </div>
161
+ </div>
162
+
163
+ <% if @stats[:first_seen] %>
164
+ <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
165
+ <div style="font-size: 13px; font-weight: 500; color: #1A1F36;">
166
+ <%= @stats[:first_seen].strftime("%Y-%m-%d") %>
167
+ </div>
168
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
169
+ First Seen
170
+ </div>
171
+ </div>
172
+ <% end %>
173
+
174
+ <% if @stats[:last_seen] %>
175
+ <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
176
+ <div style="font-size: 13px; font-weight: 500; color: #1A1F36;">
177
+ <%= @stats[:last_seen].strftime("%Y-%m-%d") %>
178
+ </div>
179
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
180
+ Last Seen
181
+ </div>
182
+ </div>
183
+ <% end %>
184
+ </div>
185
+ </div>
186
+ </div>
187
+
188
+ <!-- Metadata -->
189
+ <% if @banned_ip.metadata.present? && @banned_ip.metadata.any? %>
190
+ <div class="card">
191
+ <div class="card-header">
192
+ <h3 class="card-title">Additional Metadata</h3>
193
+ </div>
194
+ <div class="card-body">
195
+ <pre style="font-family: monospace; font-size: 12px; background: #F4F5F7; padding: 1rem; border-radius: 6px; overflow-x: auto;"><%= JSON.pretty_generate(@banned_ip.metadata) %></pre>
196
+ </div>
197
+ </div>
198
+ <% end %>
199
+
200
+ <!-- Related Security Events -->
201
+ <% if @related_events.any? %>
202
+ <div class="card">
203
+ <div class="card-header">
204
+ <h3 class="card-title">Related Security Events</h3>
205
+ <div class="card-subtitle"><%= @related_events.count %> events from this IP address</div>
206
+ </div>
207
+ <div class="card-body" style="padding: 0;">
208
+ <div class="table-container">
209
+ <table>
210
+ <thead>
211
+ <tr>
212
+ <th>Time</th>
213
+ <th>Event Type</th>
214
+ <th>User</th>
215
+ <th>Risk Score</th>
216
+ <th>Details</th>
217
+ <th>Actions</th>
218
+ </tr>
219
+ </thead>
220
+ <tbody>
221
+ <% @related_events.each do |event| %>
222
+ <tr>
223
+ <td>
224
+ <div style="font-size: 13px;">
225
+ <%= event.created_at.strftime("%Y-%m-%d") %>
226
+ </div>
227
+ <div style="font-size: 11px; color: #697386;">
228
+ <%= event.created_at.strftime("%H:%M:%S") %>
229
+ </div>
230
+ </td>
231
+ <td>
232
+ <div style="font-size: 13px;">
233
+ <%= format_event_type(event.event_type) %>
234
+ </div>
235
+ <div style="font-size: 11px; color: #697386;">
236
+ <%= event.event_type %>
237
+ </div>
238
+ </td>
239
+ <td>
240
+ <% if event.user %>
241
+ <div style="font-size: 13px;">
242
+ <%= event.user.try(:email) || "User ##{event.user_id}" %>
243
+ </div>
244
+ <% elsif event.attempted_email %>
245
+ <div style="font-size: 13px; color: #697386;">
246
+ <%= event.attempted_email %>
247
+ </div>
248
+ <% else %>
249
+ <span style="color: #C1C7D0;">-</span>
250
+ <% end %>
251
+ </td>
252
+ <td>
253
+ <span class="badge badge-<%= risk_level_class(event.risk_score) %>">
254
+ <%= event.risk_score %>
255
+ </span>
256
+ </td>
257
+ <td>
258
+ <div style="font-size: 12px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
259
+ <%= event.details || event.metadata&.dig("message") || "-" %>
260
+ </div>
261
+ </td>
262
+ <td>
263
+ <%= link_to "View", security_event_path(event), class: "btn btn-sm btn-secondary" %>
264
+ </td>
265
+ </tr>
266
+ <% end %>
267
+ </tbody>
268
+ </table>
269
+ </div>
270
+ <% if @related_events.count >= 20 %>
271
+ <div style="padding: 1rem; text-align: center; border-top: 1px solid #E3E8EE;">
272
+ <%= link_to "View All Events from this IP →", security_events_path(ip_address: @banned_ip.ip_address),
273
+ style: "color: var(--primary); text-decoration: none; font-size: 13px; font-weight: 500;" %>
274
+ </div>
275
+ <% end %>
276
+ </div>
277
+ </div>
278
+ <% end %>
279
+
280
+ <!-- Actions -->
281
+ <div style="display: flex; gap: 1rem; margin-top: 2rem;">
282
+ <%= link_to "← Back to Banned IPs", banned_ips_path, class: "btn btn-secondary" %>
283
+ <%= link_to "Edit Ban", edit_banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
284
+
285
+ <% if @banned_ip.active? && !@banned_ip.permanent? %>
286
+ <div style="display: flex; gap: 0.5rem; margin-left: auto;">
287
+ <%= form_with url: extend_banned_ip_path(@banned_ip), method: :post, local: true, style: "display: flex; gap: 0.5rem;" do |f| %>
288
+ <%= select_tag :duration,
289
+ options_for_select([
290
+ ['Extend 1 hour', '1h'],
291
+ ['Extend 6 hours', '6h'],
292
+ ['Extend 24 hours', '24h'],
293
+ ['Extend 7 days', '7d'],
294
+ ['Extend 30 days', '30d'],
295
+ ['Make Permanent', 'permanent']
296
+ ]),
297
+ style: "padding: 0.5rem 0.75rem; font-size: 14px;" %>
298
+ <%= submit_tag "Extend Ban", class: "btn btn-warning" %>
299
+ <% end %>
300
+ </div>
301
+ <% end %>
302
+
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
+ },
308
+ class: "btn btn-danger",
309
+ style: "margin-left: #{ @banned_ip.active? && !@banned_ip.permanent? ? '0' : 'auto' };" %>
310
+ </div>
@@ -0,0 +1,280 @@
1
+ <% content_for :page_title, "Security Dashboard" %>
2
+
3
+ <% content_for :header_actions do %>
4
+ <div style="display: flex; gap: 0.5rem;">
5
+ <% ['1h', '6h', '24h', '7d', '30d'].each do |range| %>
6
+ <%= link_to range.upcase, dashboard_path(time_range: range),
7
+ class: "btn btn-sm #{@time_range == range ? 'btn-primary' : 'btn-secondary'}" %>
8
+ <% end %>
9
+ </div>
10
+ <% end %>
11
+
12
+ <!-- Stats Grid -->
13
+ <div class="stats-grid">
14
+ <div class="stat-card">
15
+ <div class="stat-label">Total Events</div>
16
+ <div class="stat-value"><%= number_with_delimiter(@stats[:total_events]) %></div>
17
+ <div class="stat-change">In selected period</div>
18
+ </div>
19
+
20
+ <div class="stat-card">
21
+ <div class="stat-label">Failed Logins</div>
22
+ <div class="stat-value" style="color: var(--warning);">
23
+ <%= number_with_delimiter(@stats[:failed_logins]) %>
24
+ </div>
25
+ <div class="stat-change">Authentication failures</div>
26
+ </div>
27
+
28
+ <div class="stat-card">
29
+ <div class="stat-label">Blocked IPs</div>
30
+ <div class="stat-value" style="color: var(--danger);">
31
+ <%= number_with_delimiter(@stats[:blocked_ips]) %>
32
+ </div>
33
+ <div class="stat-change">Currently active</div>
34
+ </div>
35
+
36
+ <div class="stat-card">
37
+ <div class="stat-label">High Risk Events</div>
38
+ <div class="stat-value" style="color: var(--primary);">
39
+ <%= number_with_delimiter(@stats[:high_risk_events]) %>
40
+ </div>
41
+ <div class="stat-change">Score > 60</div>
42
+ </div>
43
+
44
+ <div class="stat-card">
45
+ <div class="stat-label">Critical Threats</div>
46
+ <div class="stat-value" style="color: #D32F2F;">
47
+ <%= number_with_delimiter(@stats[:critical_threats]) %>
48
+ </div>
49
+ <div class="stat-change">Score > 85</div>
50
+ </div>
51
+ </div>
52
+
53
+ <!-- Main Grid -->
54
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
55
+ <!-- Risk Distribution -->
56
+ <div class="card">
57
+ <div class="card-header">
58
+ <h3 class="card-title">Risk Distribution</h3>
59
+ <div class="card-subtitle">Events by risk level</div>
60
+ </div>
61
+ <div class="card-body">
62
+ <div style="display: flex; flex-direction: column; gap: 1rem;">
63
+ <% total_risk_events = @risk_distribution.values.sum.to_f %>
64
+ <% @risk_distribution.each do |level, count| %>
65
+ <div style="display: flex; align-items: center; gap: 1rem;">
66
+ <div style="width: 80px; font-size: 12px; font-weight: 600; text-transform: uppercase; color: #697386;">
67
+ <%= level.to_s.capitalize %>
68
+ </div>
69
+ <div style="flex: 1; position: relative;">
70
+ <div style="background: #F4F5F7; height: 24px; border-radius: 4px; overflow: hidden;">
71
+ <% percentage = total_risk_events > 0 ? (count / total_risk_events * 100) : 0 %>
72
+ <div style="
73
+ background: <%= case level
74
+ when :low then '#4CAF50'
75
+ when :medium then '#FF9800'
76
+ when :high then '#E91E63'
77
+ when :critical then '#D32F2F'
78
+ end %>;
79
+ width: <%= percentage %>%;
80
+ height: 100%;
81
+ transition: width 0.3s ease;
82
+ "></div>
83
+ </div>
84
+ </div>
85
+ <div style="width: 60px; text-align: right; font-size: 13px; font-weight: 600;">
86
+ <%= number_with_delimiter(count) %>
87
+ </div>
88
+ </div>
89
+ <% end %>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- Event Types -->
95
+ <div class="card">
96
+ <div class="card-header">
97
+ <h3 class="card-title">Event Types</h3>
98
+ <div class="card-subtitle">Distribution by type</div>
99
+ </div>
100
+ <div class="card-body">
101
+ <div style="display: flex; flex-direction: column; gap: 0.75rem;">
102
+ <% @event_distribution.first(5).each do |event_type, count| %>
103
+ <div style="display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #F4F5F7;">
104
+ <div>
105
+ <div style="font-size: 14px; color: #1A1F36;">
106
+ <%= format_event_type(event_type) %>
107
+ </div>
108
+ <div style="font-size: 12px; color: #697386; margin-top: 2px;">
109
+ <%= event_type %>
110
+ </div>
111
+ </div>
112
+ <div style="display: flex; align-items: center; gap: 0.75rem;">
113
+ <div style="font-size: 16px; font-weight: 600; color: #1A1F36;">
114
+ <%= number_with_delimiter(count) %>
115
+ </div>
116
+ </div>
117
+ </div>
118
+ <% end %>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+
124
+ <!-- Recent Activity and Top Threats -->
125
+ <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem;">
126
+ <!-- Recent Security Events -->
127
+ <div class="card">
128
+ <div class="card-header">
129
+ <h3 class="card-title">Recent Security Events</h3>
130
+ <div class="card-subtitle">Latest activity across all monitors</div>
131
+ </div>
132
+ <div class="card-body" style="padding: 0;">
133
+ <div class="table-container">
134
+ <table>
135
+ <thead>
136
+ <tr>
137
+ <th>Time</th>
138
+ <th>Event Type</th>
139
+ <th>IP Address</th>
140
+ <th>User</th>
141
+ <th>Risk</th>
142
+ <th></th>
143
+ </tr>
144
+ </thead>
145
+ <tbody>
146
+ <% @recent_events.each do |event| %>
147
+ <tr>
148
+ <td>
149
+ <div style="font-size: 13px;">
150
+ <%= event.created_at.strftime("%H:%M:%S") %>
151
+ </div>
152
+ <div style="font-size: 11px; color: #697386;">
153
+ <%= event.created_at.strftime("%Y-%m-%d") %>
154
+ </div>
155
+ </td>
156
+ <td>
157
+ <div style="font-size: 13px;">
158
+ <%= format_event_type(event.event_type) %>
159
+ </div>
160
+ </td>
161
+ <td>
162
+ <div style="font-family: monospace; font-size: 13px;">
163
+ <%= event.ip_address %>
164
+ </div>
165
+ <% if event.metadata&.dig("geolocation", "country") %>
166
+ <div style="font-size: 11px; color: #697386;">
167
+ <%= [event.metadata.dig("geolocation", "city"),
168
+ event.metadata.dig("geolocation", "country")].compact.join(", ") %>
169
+ </div>
170
+ <% end %>
171
+ </td>
172
+ <td>
173
+ <% if event.user %>
174
+ <div style="font-size: 13px;">
175
+ <%= event.user.try(:email) || "User ##{event.user_id}" %>
176
+ </div>
177
+ <% elsif event.attempted_email %>
178
+ <div style="font-size: 13px; color: #697386;">
179
+ <%= event.attempted_email %>
180
+ </div>
181
+ <% else %>
182
+ <span style="color: #C1C7D0;">-</span>
183
+ <% end %>
184
+ </td>
185
+ <td>
186
+ <span class="badge badge-<%= risk_level_class(event.risk_score) %>">
187
+ <%= event.risk_score %>
188
+ </span>
189
+ </td>
190
+ <td>
191
+ <%= link_to "View", security_event_path(event),
192
+ class: "btn btn-sm btn-secondary" %>
193
+ </td>
194
+ </tr>
195
+ <% end %>
196
+ </tbody>
197
+ </table>
198
+ </div>
199
+ <div style="padding: 1rem; text-align: center; border-top: 1px solid #E3E8EE;">
200
+ <%= link_to "View All Security Events →", security_events_path,
201
+ style: "color: var(--primary); text-decoration: none; font-size: 13px; font-weight: 500;" %>
202
+ </div>
203
+ </div>
204
+ </div>
205
+
206
+ <!-- Top Threats & Active Bans -->
207
+ <div style="display: flex; flex-direction: column; gap: 1.5rem;">
208
+ <!-- Top Threat IPs -->
209
+ <div class="card">
210
+ <div class="card-header">
211
+ <h3 class="card-title">Top Threat IPs</h3>
212
+ <div class="card-subtitle">Most active suspicious IPs</div>
213
+ </div>
214
+ <div class="card-body">
215
+ <div style="display: flex; flex-direction: column; gap: 0.75rem;">
216
+ <% @top_threat_ips.each do |threat| %>
217
+ <div style="padding: 0.75rem; background: #FAFBFC; border-radius: 6px;">
218
+ <div style="display: flex; justify-content: space-between; align-items: start;">
219
+ <div>
220
+ <div style="font-family: monospace; font-size: 13px; font-weight: 600; color: #1A1F36;">
221
+ <%= threat.ip_address %>
222
+ </div>
223
+ <div style="font-size: 11px; color: #697386; margin-top: 4px;">
224
+ <%= threat.event_count %> events ·
225
+ Avg risk: <%= threat.avg_risk_score.round(1) %>
226
+ </div>
227
+ </div>
228
+ <div style="display: flex; gap: 0.25rem;">
229
+ <% if Beskar::BannedIp.banned?(threat.ip_address) %>
230
+ <span class="badge badge-danger">Banned</span>
231
+ <% else %>
232
+ <%= link_to "Ban", new_banned_ip_path(ip_address: threat.ip_address, reason: 'high_threat_activity'),
233
+ class: "btn btn-sm btn-danger", style: "padding: 0.25rem 0.5rem; font-size: 11px;" %>
234
+ <% end %>
235
+ </div>
236
+ </div>
237
+ </div>
238
+ <% end %>
239
+ </div>
240
+ </div>
241
+ </div>
242
+
243
+ <!-- Active Bans -->
244
+ <div class="card">
245
+ <div class="card-header">
246
+ <h3 class="card-title">Active Bans</h3>
247
+ <div class="card-subtitle">Recently banned IPs</div>
248
+ </div>
249
+ <div class="card-body">
250
+ <div style="display: flex; flex-direction: column; gap: 0.5rem;">
251
+ <% @active_bans.each do |ban| %>
252
+ <div style="padding: 0.5rem 0; border-bottom: 1px solid #F4F5F7;">
253
+ <div style="display: flex; justify-content: space-between; align-items: start;">
254
+ <div>
255
+ <div style="font-family: monospace; font-size: 13px; color: #1A1F36;">
256
+ <%= ban.ip_address %>
257
+ </div>
258
+ <div style="font-size: 11px; color: #697386; margin-top: 2px;">
259
+ <%= ban.reason.humanize %> ·
260
+ <% if ban.permanent? %>
261
+ Permanent
262
+ <% else %>
263
+ Expires <%= distance_of_time_in_words(Time.current, ban.expires_at) %>
264
+ <% end %>
265
+ </div>
266
+ </div>
267
+ <%= link_to "→", banned_ip_path(ban),
268
+ style: "color: #697386; text-decoration: none; font-size: 14px;" %>
269
+ </div>
270
+ </div>
271
+ <% end %>
272
+ </div>
273
+ <div style="padding-top: 1rem; text-align: center;">
274
+ <%= link_to "Manage All Bans →", banned_ips_path,
275
+ style: "color: var(--primary); text-decoration: none; font-size: 13px; font-weight: 500;" %>
276
+ </div>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ </div>