beskar 0.0.2 → 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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +274 -0
  3. data/README.md +412 -204
  4. data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
  5. data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
  6. data/app/controllers/beskar/application_controller.rb +214 -0
  7. data/app/controllers/beskar/banned_ips_controller.rb +255 -0
  8. data/app/controllers/beskar/dashboard_controller.rb +62 -0
  9. data/app/controllers/beskar/security_events_controller.rb +164 -0
  10. data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
  11. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
  12. data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
  13. data/app/jobs/beskar/notification_job.rb +33 -0
  14. data/app/mailers/beskar/security_mailer.rb +59 -0
  15. data/app/models/beskar/administrative_action.rb +41 -0
  16. data/app/models/beskar/banned_ip.rb +105 -105
  17. data/app/models/beskar/security_event.rb +51 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +88 -0
  20. data/app/views/beskar/administrative_actions/index.html.erb +33 -0
  21. data/app/views/beskar/administrative_actions/show.html.erb +21 -0
  22. data/app/views/beskar/banned_ips/edit.html.erb +195 -0
  23. data/app/views/beskar/banned_ips/index.html.erb +319 -0
  24. data/app/views/beskar/banned_ips/new.html.erb +190 -0
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +304 -0
  27. data/app/views/beskar/dashboard/index.html.erb +280 -0
  28. data/app/views/beskar/security_events/index.html.erb +302 -0
  29. data/app/views/beskar/security_events/show.html.erb +293 -0
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  32. data/app/views/layouts/beskar/application.html.erb +581 -6
  33. data/config/routes.rb +30 -0
  34. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  35. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  36. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  37. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  38. data/docs/README.md +73 -0
  39. data/docs/archive/project-documentation.md +659 -0
  40. data/docs/audits/project-review.md +437 -0
  41. data/docs/audits/repair-status.md +216 -0
  42. data/docs/guides/audit-and-waf.md +175 -0
  43. data/docs/guides/audit-lifecycle.md +172 -0
  44. data/docs/guides/authentication.md +213 -0
  45. data/docs/guides/configuration.md +182 -0
  46. data/docs/guides/dashboard-and-search.md +251 -0
  47. data/docs/guides/notifications-and-recovery.md +157 -0
  48. data/docs/guides/risk-scoring.md +116 -0
  49. data/docs/operations/monitor-only-mode.md +85 -0
  50. data/docs/operations/security-hardening.md +167 -0
  51. data/docs/operations/state-storage.md +144 -0
  52. data/docs/research/rust-performance-assessment.md +69 -0
  53. data/lib/beskar/configuration.rb +105 -20
  54. data/lib/beskar/configuration_validator.rb +188 -0
  55. data/lib/beskar/devise_authentication.rb +24 -0
  56. data/lib/beskar/engine.rb +21 -88
  57. data/lib/beskar/logger.rb +288 -0
  58. data/lib/beskar/middleware/request_analyzer.rb +133 -99
  59. data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
  60. data/lib/beskar/models/security_trackable_devise.rb +34 -25
  61. data/lib/beskar/models/security_trackable_generic.rb +171 -214
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +90 -81
  64. data/lib/beskar/services/administrative_audit.rb +36 -0
  65. data/lib/beskar/services/administrative_bans.rb +104 -0
  66. data/lib/beskar/services/audit_data.rb +72 -0
  67. data/lib/beskar/services/authentication.rb +31 -0
  68. data/lib/beskar/services/authentication_attempt.rb +141 -0
  69. data/lib/beskar/services/ban_expiry.rb +28 -0
  70. data/lib/beskar/services/device_detector.rb +32 -41
  71. data/lib/beskar/services/event_search.rb +58 -0
  72. data/lib/beskar/services/geolocation_service.rb +83 -114
  73. data/lib/beskar/services/ip_whitelist.rb +31 -40
  74. data/lib/beskar/services/location_assessment.rb +109 -0
  75. data/lib/beskar/services/native_account_lock.rb +82 -0
  76. data/lib/beskar/services/notifications.rb +46 -0
  77. data/lib/beskar/services/rate_limiter.rb +99 -125
  78. data/lib/beskar/services/request_context.rb +64 -0
  79. data/lib/beskar/services/risk_assessment.rb +58 -0
  80. data/lib/beskar/services/session_revocation.rb +62 -0
  81. data/lib/beskar/services/waf.rb +311 -198
  82. data/lib/beskar/services/waf_request.rb +60 -0
  83. data/lib/beskar/version.rb +1 -1
  84. data/lib/beskar/warden_authentication.rb +53 -0
  85. data/lib/beskar.rb +54 -4
  86. data/lib/generators/beskar/install/install_generator.rb +158 -0
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
  88. data/lib/tasks/beskar_tasks.rake +25 -20
  89. metadata +93 -12
  90. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -0,0 +1,190 @@
1
+ <% content_for :page_title, "New IP Ban" %>
2
+
3
+ <% content_for :header_actions do %>
4
+ <%= link_to "← Cancel", banned_ips_path, class: "btn btn-secondary" %>
5
+ <% end %>
6
+
7
+ <div class="card">
8
+ <div class="card-header">
9
+ <h3 class="card-title">Ban IP Address</h3>
10
+ <div class="card-subtitle">Add a new IP address to the ban list</div>
11
+ </div>
12
+ <div class="card-body">
13
+ <%= form_with model: @banned_ip, url: banned_ips_path, local: true, data: { beskar_ban_form: true } do |f| %>
14
+ <% if @banned_ip.errors.any? %>
15
+ <div class="alert alert-danger">
16
+ <h4 style="font-size: 14px; font-weight: 600; margin-bottom: 0.5rem;">
17
+ <%= pluralize(@banned_ip.errors.count, "error") %> prohibited this ban from being saved:
18
+ </h4>
19
+ <ul style="margin: 0; padding-left: 1.5rem;">
20
+ <% @banned_ip.errors.full_messages.each do |message| %>
21
+ <li style="font-size: 13px;"><%= message %></li>
22
+ <% end %>
23
+ </ul>
24
+ </div>
25
+ <% end %>
26
+
27
+ <div style="display: grid; grid-template-columns: 1fr; gap: 1.5rem; max-width: 600px;">
28
+ <!-- IP Address -->
29
+ <div class="form-group">
30
+ <%= f.label :ip_address, "IP Address *" %>
31
+ <%= f.text_field :ip_address,
32
+ value: @suggested_ip || @banned_ip.ip_address,
33
+ placeholder: "e.g., 192.168.1.1",
34
+ required: true,
35
+ style: "font-family: monospace;" %>
36
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
37
+ Enter the IP address to ban. IPv4 and IPv6 formats are supported.
38
+ </div>
39
+ </div>
40
+
41
+ <!-- Reason -->
42
+ <div class="form-group">
43
+ <%= f.label :reason, "Ban Reason *" %>
44
+ <%= f.select :reason,
45
+ options_for_select(ban_reason_options, @suggested_reason || @banned_ip.reason),
46
+ { prompt: 'Select a reason...' },
47
+ { required: true } %>
48
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
49
+ Select the primary reason for banning this IP address.
50
+ </div>
51
+ </div>
52
+
53
+ <!-- Ban Duration -->
54
+ <div class="form-group">
55
+ <label>Ban Duration *</label>
56
+ <div style="display: flex; flex-direction: column; gap: 0.5rem;">
57
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
58
+ <%= radio_button_tag :ban_type, 'temporary', !@banned_ip.permanent? %>
59
+ <span style="font-weight: normal;">Temporary Ban</span>
60
+ </label>
61
+ <label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
62
+ <%= radio_button_tag :ban_type, 'permanent', @banned_ip.permanent? %>
63
+ <span style="font-weight: normal; color: #D32F2F;">Permanent Ban</span>
64
+ </label>
65
+ </div>
66
+ </div>
67
+
68
+ <!-- Temporary Ban Options -->
69
+ <div id="temporary-options">
70
+ <div class="form-group">
71
+ <label>Duration</label>
72
+ <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem;">
73
+ <% [
74
+ ['1 Hour', 1.hour],
75
+ ['6 Hours', 6.hours],
76
+ ['24 Hours', 24.hours],
77
+ ['7 Days', 7.days],
78
+ ['30 Days', 30.days],
79
+ ['90 Days', 90.days]
80
+ ].each do |label, duration| %>
81
+ <label style="display: flex; align-items: center; padding: 0.75rem; background: #FAFBFC; border: 1px solid #E3E8EE; border-radius: 6px; cursor: pointer;">
82
+ <%= radio_button_tag :duration, duration.to_i, params[:duration].to_s == duration.to_i.to_s %>
83
+ <span style="margin-left: 0.5rem; font-size: 13px; font-weight: normal;"><%= label %></span>
84
+ </label>
85
+ <% end %>
86
+ </div>
87
+ <div style="font-size: 12px; color: #697386; margin-top: 0.5rem;">
88
+ Or specify a custom expiry in UTC. Without either, the server applies 24 hours:
89
+ </div>
90
+ </div>
91
+
92
+ <div class="form-group">
93
+ <%= f.label :expires_at, "Custom Expiry Date/Time (UTC)" %>
94
+ <%= f.datetime_field :expires_at,
95
+ value: (params.dig(:banned_ip, :expires_at).present? ? ban_expiry_input_value(@banned_ip.expires_at) : ""),
96
+ step: "0.001" %>
97
+ </div>
98
+ </div>
99
+
100
+ <!-- Details -->
101
+ <div class="form-group">
102
+ <%= f.label :details, "Additional Details" %>
103
+ <%= f.text_area :details,
104
+ rows: 3,
105
+ placeholder: "Provide any additional context or details about this ban..." %>
106
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
107
+ Optional. Add any relevant information about why this IP was banned.
108
+ </div>
109
+ </div>
110
+
111
+ <!-- Violation Count -->
112
+ <div class="form-group">
113
+ <%= f.label :violation_count, "Initial Violation Count" %>
114
+ <%= f.number_field :violation_count,
115
+ value: @banned_ip.violation_count || 1,
116
+ min: 1 %>
117
+ <div style="font-size: 12px; color: #697386; margin-top: 0.25rem;">
118
+ Set the initial violation count. This affects ban escalation for repeat offenders.
119
+ </div>
120
+ </div>
121
+
122
+ <hr style="border: none; border-top: 1px solid #E3E8EE; margin: 1.5rem 0;">
123
+
124
+ <!-- Related Security Event (if coming from an event) -->
125
+ <% if params[:event_id].present? %>
126
+ <div class="alert alert-info">
127
+ <strong>Related Event:</strong> This ban is being created in response to security event #<%= params[:event_id] %>.
128
+ <%= link_to "View Event", security_event_path(params[:event_id]), target: "_blank" %>
129
+ </div>
130
+ <% end %>
131
+
132
+ <div class="form-group">
133
+ <%= label_tag :audit_reason, "Administrative reason *" %>
134
+ <%= text_area_tag :audit_reason, params[:audit_reason], required: true, maxlength: 1000, rows: 2 %>
135
+ <p>Use a case reference or brief explanation, without credentials or other secrets.</p>
136
+ </div>
137
+
138
+ <!-- Submit Buttons -->
139
+ <div style="display: flex; gap: 0.5rem;">
140
+ <%= f.submit "Ban IP Address", class: "btn btn-danger" %>
141
+ <%= link_to "Cancel", banned_ips_path, class: "btn btn-secondary" %>
142
+ </div>
143
+ </div>
144
+ <% end %>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- Preview Card -->
149
+ <div class="card" id="ban-preview" hidden>
150
+ <div class="card-header">
151
+ <h3 class="card-title">Ban Preview</h3>
152
+ <div class="card-subtitle">This is how the ban will appear</div>
153
+ </div>
154
+ <div class="card-body">
155
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
156
+ <div>
157
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
158
+ IP Address
159
+ </div>
160
+ <div style="font-family: monospace; font-size: 14px; color: #1A1F36; margin-top: 0.25rem;" id="preview-ip">
161
+ -
162
+ </div>
163
+ </div>
164
+ <div>
165
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
166
+ Reason
167
+ </div>
168
+ <div style="font-size: 14px; color: #1A1F36; margin-top: 0.25rem;" id="preview-reason">
169
+ -
170
+ </div>
171
+ </div>
172
+ <div>
173
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
174
+ Duration
175
+ </div>
176
+ <div style="font-size: 14px; color: #1A1F36; margin-top: 0.25rem;" id="preview-duration">
177
+ -
178
+ </div>
179
+ </div>
180
+ <div>
181
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386;">
182
+ Status
183
+ </div>
184
+ <div style="margin-top: 0.25rem;">
185
+ <span class="badge badge-danger" id="preview-status">Active</span>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
@@ -0,0 +1,24 @@
1
+ <% content_for :page_title, "Review ban action" %>
2
+ <div class="card">
3
+ <div class="card-header"><h3 class="card-title"><%= @operation.titleize %> <%= @banned_ip.ip_address %></h3></div>
4
+ <div class="card-body">
5
+ <p>This change will be recorded with your administrative identity and its previous and resulting state.</p>
6
+ <%= form_with url: (@operation == "unban" ? banned_ip_path(@banned_ip) : extend_banned_ip_path(@banned_ip)),
7
+ method: (@operation == "unban" ? :delete : :post), local: true do %>
8
+ <% if @operation == "extend" %>
9
+ <div class="form-group">
10
+ <%= label_tag :duration, "Extension" %>
11
+ <%= select_tag :duration, options_for_select([['1 hour', '1h'], ['6 hours', '6h'], ['24 hours', '24h'],
12
+ ['7 days', '7d'], ['30 days', '30d'], ['Make permanent', 'permanent']], params[:duration] || '24h') %>
13
+ </div>
14
+ <% end %>
15
+ <div class="form-group">
16
+ <%= label_tag :audit_reason, "Reason for this administrative action" %>
17
+ <%= text_area_tag :audit_reason, nil, required: true, maxlength: 1000, rows: 3 %>
18
+ <p>Use a case reference or brief explanation. Do not include credentials or other secrets.</p>
19
+ </div>
20
+ <%= submit_tag "Confirm #{@operation}", class: "btn btn-danger" %>
21
+ <%= link_to "Cancel", banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
22
+ <% end %>
23
+ </div>
24
+ </div>
@@ -0,0 +1,304 @@
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 "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" %>
8
+ <% end %>
9
+
10
+ <!-- Ban Overview -->
11
+ <div class="card">
12
+ <div class="card-header">
13
+ <h3 class="card-title">Ban Details</h3>
14
+ <div class="card-subtitle">IP Address: <%= @banned_ip.ip_address %></div>
15
+ </div>
16
+ <div class="card-body">
17
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1.5rem;">
18
+ <!-- IP Address -->
19
+ <div>
20
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
21
+ IP Address
22
+ </div>
23
+ <div style="font-family: monospace; font-size: 18px; font-weight: 500; color: #1A1F36;">
24
+ <%= @banned_ip.ip_address %>
25
+ </div>
26
+ <% if @banned_ip.metadata&.dig("geolocation") %>
27
+ <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
28
+ <%= [@banned_ip.metadata.dig("geolocation", "city"),
29
+ @banned_ip.metadata.dig("geolocation", "region"),
30
+ @banned_ip.metadata.dig("geolocation", "country")].compact.join(", ") %>
31
+ </div>
32
+ <% end %>
33
+ </div>
34
+
35
+ <!-- Status -->
36
+ <div>
37
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
38
+ Status
39
+ </div>
40
+ <div style="display: flex; align-items: center; gap: 0.75rem;">
41
+ <% if @banned_ip.permanent? %>
42
+ <span class="badge badge-critical">Permanent Ban</span>
43
+ <% elsif @banned_ip.active? %>
44
+ <span class="badge badge-danger">Active</span>
45
+ <% else %>
46
+ <span class="badge badge-neutral">Expired</span>
47
+ <% end %>
48
+ </div>
49
+ <% if @banned_ip.active? && !@banned_ip.permanent? && @banned_ip.expires_at %>
50
+ <div style="font-size: 13px; color: #697386; margin-top: 0.5rem;">
51
+ Expires in <%= distance_of_time_in_words(Time.current, @banned_ip.expires_at) %>
52
+ </div>
53
+ <% end %>
54
+ </div>
55
+
56
+ <!-- Reason -->
57
+ <div>
58
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
59
+ Ban Reason
60
+ </div>
61
+ <div style="font-size: 14px; color: #1A1F36;">
62
+ <%= @banned_ip.reason.humanize %>
63
+ </div>
64
+ <% if @banned_ip.details.present? %>
65
+ <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
66
+ <%= @banned_ip.details %>
67
+ </div>
68
+ <% end %>
69
+ </div>
70
+
71
+ <!-- Banned At -->
72
+ <div>
73
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
74
+ Banned At
75
+ </div>
76
+ <div style="font-size: 14px; color: #1A1F36;">
77
+ <%= @banned_ip.banned_at.strftime("%B %d, %Y") %>
78
+ </div>
79
+ <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
80
+ <%= @banned_ip.banned_at.strftime("%H:%M:%S %Z") %>
81
+ (<%= time_ago_in_words(@banned_ip.banned_at) %> ago)
82
+ </div>
83
+ </div>
84
+
85
+ <!-- Expires At -->
86
+ <div>
87
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
88
+ Expires At
89
+ </div>
90
+ <% if @banned_ip.permanent? %>
91
+ <div style="font-size: 14px; color: #D32F2F; font-weight: 500;">
92
+ Never (Permanent Ban)
93
+ </div>
94
+ <% elsif @banned_ip.expires_at %>
95
+ <div style="font-size: 14px; color: #1A1F36;">
96
+ <%= @banned_ip.expires_at.strftime("%B %d, %Y") %>
97
+ </div>
98
+ <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
99
+ <%= @banned_ip.expires_at.strftime("%H:%M:%S %Z") %>
100
+ </div>
101
+ <% else %>
102
+ <div style="color: #C1C7D0;">Not set</div>
103
+ <% end %>
104
+ </div>
105
+
106
+ <!-- Violation Count -->
107
+ <div>
108
+ <div style="font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-bottom: 0.5rem;">
109
+ Violation Count
110
+ </div>
111
+ <div style="font-size: 24px; font-weight: 600; color: <%= @banned_ip.violation_count > 3 ? '#D32F2F' : '#1A1F36' %>;">
112
+ <%= @banned_ip.violation_count %>
113
+ </div>
114
+ <% if @banned_ip.violation_count > 0 %>
115
+ <div style="font-size: 13px; color: #697386; margin-top: 0.25rem;">
116
+ <%= @banned_ip.violation_count == 1 ? 'violation' : 'violations' %> recorded
117
+ </div>
118
+ <% end %>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ </div>
123
+
124
+ <!-- Statistics -->
125
+ <div class="card">
126
+ <div class="card-header">
127
+ <h3 class="card-title">IP Statistics</h3>
128
+ <div class="card-subtitle">Activity summary for <%= @banned_ip.ip_address %></div>
129
+ </div>
130
+ <div class="card-body">
131
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem;">
132
+ <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
133
+ <div style="font-size: 24px; font-weight: 600; color: #1A1F36;">
134
+ <%= @stats[:total_events] %>
135
+ </div>
136
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
137
+ Total Events
138
+ </div>
139
+ </div>
140
+
141
+ <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
142
+ <div style="font-size: 24px; font-weight: 600; color: <%= risk_level_color(@stats[:avg_risk_score]) %>;">
143
+ <%= @stats[:avg_risk_score] %>
144
+ </div>
145
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
146
+ Avg Risk Score
147
+ </div>
148
+ </div>
149
+
150
+ <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
151
+ <div style="font-size: 24px; font-weight: 600; color: <%= risk_level_color(@stats[:max_risk_score]) %>;">
152
+ <%= @stats[:max_risk_score] %>
153
+ </div>
154
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
155
+ Max Risk Score
156
+ </div>
157
+ </div>
158
+
159
+ <% if @stats[:first_seen] %>
160
+ <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
161
+ <div style="font-size: 13px; font-weight: 500; color: #1A1F36;">
162
+ <%= @stats[:first_seen].strftime("%Y-%m-%d") %>
163
+ </div>
164
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
165
+ First Seen
166
+ </div>
167
+ </div>
168
+ <% end %>
169
+
170
+ <% if @stats[:last_seen] %>
171
+ <div style="text-align: center; padding: 1rem; background: #FAFBFC; border-radius: 6px;">
172
+ <div style="font-size: 13px; font-weight: 500; color: #1A1F36;">
173
+ <%= @stats[:last_seen].strftime("%Y-%m-%d") %>
174
+ </div>
175
+ <div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.025em; color: #697386; margin-top: 0.25rem;">
176
+ Last Seen
177
+ </div>
178
+ </div>
179
+ <% end %>
180
+ </div>
181
+ </div>
182
+ </div>
183
+
184
+ <!-- Metadata -->
185
+ <% if @banned_ip.metadata.present? && @banned_ip.metadata.any? %>
186
+ <div class="card">
187
+ <div class="card-header">
188
+ <h3 class="card-title">Additional Metadata</h3>
189
+ </div>
190
+ <div class="card-body">
191
+ <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>
192
+ </div>
193
+ </div>
194
+ <% end %>
195
+
196
+ <!-- Related Security Events -->
197
+ <% if @related_events.any? %>
198
+ <div class="card">
199
+ <div class="card-header">
200
+ <h3 class="card-title">Related Security Events</h3>
201
+ <div class="card-subtitle"><%= @related_events.count %> events from this IP address</div>
202
+ </div>
203
+ <div class="card-body" style="padding: 0;">
204
+ <div class="table-container">
205
+ <table>
206
+ <thead>
207
+ <tr>
208
+ <th>Time</th>
209
+ <th>Event Type</th>
210
+ <th>User</th>
211
+ <th>Risk Score</th>
212
+ <th>Details</th>
213
+ <th>Actions</th>
214
+ </tr>
215
+ </thead>
216
+ <tbody>
217
+ <% @related_events.each do |event| %>
218
+ <tr>
219
+ <td>
220
+ <div style="font-size: 13px;">
221
+ <%= event.created_at.strftime("%Y-%m-%d") %>
222
+ </div>
223
+ <div style="font-size: 11px; color: #697386;">
224
+ <%= event.created_at.strftime("%H:%M:%S") %>
225
+ </div>
226
+ </td>
227
+ <td>
228
+ <div style="font-size: 13px;">
229
+ <%= format_event_type(event.event_type) %>
230
+ </div>
231
+ <div style="font-size: 11px; color: #697386;">
232
+ <%= event.event_type %>
233
+ </div>
234
+ </td>
235
+ <td>
236
+ <% if event.user %>
237
+ <div style="font-size: 13px;">
238
+ <%= audit_user_label(event) %>
239
+ </div>
240
+ <% elsif event.attempted_email %>
241
+ <div style="font-size: 13px; color: #697386;">
242
+ <%= audit_user_label(event) %>
243
+ </div>
244
+ <% else %>
245
+ <span style="color: #C1C7D0;">-</span>
246
+ <% end %>
247
+ </td>
248
+ <td>
249
+ <span class="badge badge-<%= risk_level_class(event.risk_score) %>">
250
+ <%= event.risk_score %>
251
+ </span>
252
+ </td>
253
+ <td>
254
+ <div style="font-size: 12px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
255
+ <%= event.details || event.metadata&.dig("message") || "-" %>
256
+ </div>
257
+ </td>
258
+ <td>
259
+ <%= link_to "View", security_event_path(event), class: "btn btn-sm btn-secondary" %>
260
+ </td>
261
+ </tr>
262
+ <% end %>
263
+ </tbody>
264
+ </table>
265
+ </div>
266
+ <% if @related_events.count >= 20 %>
267
+ <div style="padding: 1rem; text-align: center; border-top: 1px solid #E3E8EE;">
268
+ <%= link_to "View All Events from this IP →", security_events_path(ip_address: @banned_ip.ip_address),
269
+ style: "color: var(--primary); text-decoration: none; font-size: 13px; font-weight: 500;" %>
270
+ </div>
271
+ <% end %>
272
+ </div>
273
+ </div>
274
+ <% end %>
275
+
276
+ <!-- Actions -->
277
+ <div style="display: flex; gap: 1rem; margin-top: 2rem;">
278
+ <%= link_to "← Back to Banned IPs", banned_ips_path, class: "btn btn-secondary" %>
279
+ <%= link_to "Edit Ban", edit_banned_ip_path(@banned_ip), class: "btn btn-secondary" %>
280
+
281
+ <% if @banned_ip.active? && !@banned_ip.permanent? %>
282
+ <div style="display: flex; gap: 0.5rem; margin-left: auto;">
283
+ <%= form_with url: extend_banned_ip_path(@banned_ip), method: :post, local: true, style: "display: flex; gap: 0.5rem;" do |f| %>
284
+ <%= select_tag :duration,
285
+ options_for_select([
286
+ ['Extend 1 hour', '1h'],
287
+ ['Extend 6 hours', '6h'],
288
+ ['Extend 24 hours', '24h'],
289
+ ['Extend 7 days', '7d'],
290
+ ['Extend 30 days', '30d'],
291
+ ['Make Permanent', 'permanent']
292
+ ]),
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 %>
296
+ <%= submit_tag "Extend Ban", class: "btn btn-warning" %>
297
+ <% end %>
298
+ </div>
299
+ <% end %>
300
+
301
+ <%= link_to "Unban IP", review_banned_ip_path(@banned_ip, operation: "unban"),
302
+ class: "btn btn-danger",
303
+ style: "margin-left: #{ @banned_ip.active? && !@banned_ip.permanent? ? '0' : 'auto' };" %>
304
+ </div>