beskar 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d34b14d657d818c36c3c2a91210c4ad4ab04ffd6b1087eeab9bb18bf3486c56c
4
- data.tar.gz: a42dc485e08761ff3a6260fa21e7f280f7655f59e9acf3bc20edd056c9878988
3
+ metadata.gz: d9899541c9baa5b2c2a0ac7106dba36f9fff668962e5b70e11d34efa483a7d76
4
+ data.tar.gz: 6f7e9d0f5f725ea486974958a517ec32a725e5a2723efa7bb5e8738e794b39ca
5
5
  SHA512:
6
- metadata.gz: 50bf70930298ab5fc0e31d803ce8f12245155a1f8d590efb5b8f6322256dc54928dbc1991ff2733c88d69958033f037918d2af57d6eb63d681ab1279b0c4c88c
7
- data.tar.gz: 202b467246bd2797218131c462a1b8f9fa6aa3a0712b7126abfcb0192247a459aa50eedbaa20c340f9b8d608fb85320a24bbe575fa90d796ecf91d6ccd5326e1
6
+ metadata.gz: 6f79580e66cc3c2e777d10b65fb14e4e7bc6bc7ea2abb86763cea85b059f439867a2a8167f41896bbed907b6d8cfdcbf9ca00407407cc22881ac42ea69e09b63
7
+ data.tar.gz: 1cede8ef21ba6c4a3704cac25b055e42719c543fdfdefd10916e56576f0b18f644723ac73528979a20a4a0c489d8178b656d38cf8d0bc072f1d2d1c901354ebe
data/CHANGELOG.md CHANGED
@@ -11,6 +11,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
11
11
 
12
12
  ## Unreleased
13
13
 
14
+ ## 0.2.1 - 2026-11-04
15
+
16
+ Bug fixes for monitor mode and requests without a client address, plus
17
+ requested-path evidence for WAF threats in the dashboard.
18
+
19
+ ### Changed
20
+
21
+ - WAF events and ban metadata now retain `waf_analysis.request_path`: the
22
+ requested path with its original percent encoding, limited to 1,024 characters.
23
+ Queries and fragments are removed and host `request_path` filters apply. Event
24
+ and ban details show it beside the matched rules, and the events list has a
25
+ Requested Path column. Records created before this release show "Not recorded".
26
+ - The dashboard overview labels top threat IPs as "in the selected period".
27
+
28
+ ### Fixed
29
+
30
+ - Monitor mode and whitelisted addresses no longer deny a login when one request
31
+ sets a second user on a scope. Each user is recorded as a login of its own.
32
+ Enforcement still denies it as `identity_changed`.
33
+ - A request with no usable client address no longer raises
34
+ `IPAddr::InvalidAddressError` in the middleware. `RequestContext.ip` returns nil,
35
+ and the middleware passes the request to the application without ban or WAF
36
+ checks. A login from such a request succeeds without an audit row. Logins
37
+ through non-password Warden strategies (such as OAuth) from such a request also
38
+ reach authentication; rate limiting with no applicable tier is now an allowed
39
+ no-op instead of raising `ArgumentError`.
40
+
14
41
  ## 0.2.0 - 2026-09-21
15
42
 
16
43
  Security hardening, coordinated enforcement, and auditable administration.
@@ -181,6 +181,10 @@
181
181
  </div>
182
182
  </div>
183
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
+
184
188
  <!-- Metadata -->
185
189
  <% if @banned_ip.metadata.present? && @banned_ip.metadata.any? %>
186
190
  <div class="card">
@@ -210,6 +214,7 @@
210
214
  <th>User</th>
211
215
  <th>Risk Score</th>
212
216
  <th>Details</th>
217
+ <th>Requested Path</th>
213
218
  <th>Actions</th>
214
219
  </tr>
215
220
  </thead>
@@ -255,6 +260,10 @@
255
260
  <%= event.details || event.metadata&.dig("message") || "-" %>
256
261
  </div>
257
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>
258
267
  <td>
259
268
  <%= link_to "View", security_event_path(event), class: "btn btn-sm btn-secondary" %>
260
269
  </td>
@@ -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;">
@@ -125,6 +125,7 @@
125
125
  <th>ID</th>
126
126
  <th>Timestamp</th>
127
127
  <th>Event Type</th>
128
+ <th>Requested Path</th>
128
129
  <th>IP Address</th>
129
130
  <th>User</th>
130
131
  <th>Risk Score</th>
@@ -157,6 +158,14 @@
157
158
  <%= event.event_type %>
158
159
  </div>
159
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>
160
169
  <td>
161
170
  <div style="font-family: monospace; font-size: 13px;">
162
171
  <%= event.ip_address %>
@@ -223,7 +232,7 @@
223
232
  <% end %>
224
233
  <% else %>
225
234
  <tr>
226
- <td colspan="8" style="text-align: center; padding: 3rem;">
235
+ <td colspan="9" style="text-align: center; padding: 3rem;">
227
236
  <div style="color: #697386;">
228
237
  <svg width="48" height="48" fill="currentColor" viewBox="0 0 24 24" style="margin: 0 auto 1rem;">
229
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"/>
@@ -151,6 +151,10 @@
151
151
  </div>
152
152
  </div>
153
153
 
154
+ <% if @event.event_type == "waf_violation" %>
155
+ <%= render "beskar/shared/waf_analysis", analysis: @event.metadata["waf_analysis"] %>
156
+ <% end %>
157
+
154
158
  <!-- Metadata -->
155
159
  <% if @event.metadata.present? && @event.metadata.any? %>
156
160
  <div class="card">
@@ -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>
data/docs/README.md CHANGED
@@ -24,7 +24,7 @@ changes from open findings and production validation still required.
24
24
  - [Risk scoring](guides/risk-scoring.md) — factors, geographic evidence, and
25
25
  heuristic limitations.
26
26
  - [Audit data and WAF](guides/audit-and-waf.md) — filtering, exports, scanner
27
- matching, and privacy boundaries.
27
+ matching, requested-path evidence, and privacy boundaries.
28
28
  - [Audit lifecycle](guides/audit-lifecycle.md) — unchanged event retention,
29
29
  administrative permissions, actor/reason history, and migrations.
30
30
  - [Dashboard and search](guides/dashboard-and-search.md) — reporting, search,
@@ -11,10 +11,22 @@ historical. See [Repair status](../audits/repair-status.md) for remaining findin
11
11
 
12
12
  WAF matching uses request data transiently. Persisted WAF evidence contains rule
13
13
  IDs, static descriptions/categories, severity, rules version, decoding-pass count,
14
- an allowlisted HTTP method/exception class, timestamps, and scores. The event
15
- retains the Rails-resolved IP for attribution, subject to host audit filters.
16
- Raw/canonical paths, query strings, exception messages, and User-Agent headers are
17
- not copied into WAF events, ban metadata, state, or WAF messages. Public
14
+ an allowlisted HTTP method/exception class, a bounded requested path, timestamps,
15
+ and scores. The event retains the Rails-resolved IP for attribution, subject to
16
+ host audit filters.
17
+ Events and ban metadata retain `waf_analysis.request_path`: the requested path
18
+ with its original percent encoding, limited to 1,024 characters with a trailing
19
+ ellipsis when truncated. Queries and fragments are removed, invalid UTF-8 is
20
+ repaired, controls are replaced, and host `request_path` filters apply. Event
21
+ and ban details show this path beside the matched rules; the events list includes
22
+ a Requested Path column, as do related events on ban details. Older records show
23
+ "Not recorded" because discarded
24
+ paths cannot be recovered. The ban's analysis identifies the triggering request,
25
+ not every request contributing to the cumulative score.
26
+
27
+ Canonical matching paths, query strings, exception messages, and User-Agent headers
28
+ are not copied into WAF evidence. Paths remain excluded from enforcement state
29
+ and WAF log messages. Public
18
30
  `record_violation` calls also project old-style analysis onto this bounded schema.
19
31
  The state API strips legacy raw-path/description fields on reads and on the next
20
32
  normal write; it does not bulk-rewrite historical database contents.
@@ -119,7 +131,8 @@ It does not parse request bodies or implement JavaScript challenges/honeypots.
119
131
  Ordinary `.well-known` routes are not scanner signatures.
120
132
  - Ignore arbitrary query text. Only a flat, exact `format` query key with an
121
133
  allowlisted executable value is checked; queries over 8,192 bytes are skipped.
122
- - Retain matched rule IDs with `rules_version: 1`, not the matching input.
134
+ - Retain matched rule IDs with `rules_version: 1` and a bounded requested path,
135
+ without the decoded matching input or query values.
123
136
 
124
137
  Use narrow exclusions for legitimate host routes:
125
138
 
@@ -11,6 +11,12 @@ module Beskar
11
11
 
12
12
  Beskar::Logger.debug("[RequestAnalyzer] Processing request from IP: #{ip_address}", component: :Middleware)
13
13
 
14
+ # A request with no usable address has nothing to ban or score.
15
+ unless ip_address
16
+ processing_host = true
17
+ return @app.call(env)
18
+ end
19
+
14
20
  # 1. Check if IP is whitelisted (whitelisted IPs skip blocking but still get logged)
15
21
  is_whitelisted = Beskar::Services::IpWhitelist.whitelisted?(ip_address)
16
22
 
@@ -153,6 +159,8 @@ module Beskar
153
159
  end
154
160
 
155
161
  def handle_rails_exception(request, exception, ip_address, is_whitelisted)
162
+ return unless ip_address
163
+
156
164
  # Analyze the exception using WAF
157
165
  waf_analysis = Beskar::Services::Waf.analyze_exception(exception, request)
158
166
 
@@ -73,8 +73,15 @@ module Beskar
73
73
  # Charge that account once, independently of the already reserved IP tier.
74
74
  def bind_user!(resource)
75
75
  if user && user != resource
76
- deny!(:identity_changed)
77
- return
76
+ if RequestContext.enforce?(ip_address)
77
+ deny!(:identity_changed)
78
+ return
79
+ end
80
+ # Observation never denies a login: the second identity is recorded
81
+ # as a login of its own, with its own account count and audit event.
82
+ @user = nil
83
+ self.event = nil
84
+ self.completed = false
78
85
  end
79
86
  unless user
80
87
  @user = resource
@@ -95,6 +95,10 @@ module Beskar
95
95
  end
96
96
 
97
97
  def reserve(keys)
98
+ # No applicable tier (for example an addressless request with global
99
+ # limiting disabled): nothing to count, so admission is a no-op.
100
+ return most_restrictive_result([]) if keys.empty?
101
+
98
102
  configs = keys.to_h { |tier, _| [tier, config_for(tier)] }
99
103
  ttl = [configs.values.map { |config| config[:period].to_f }.max, BACKOFF_DELAYS.last].max + 60
100
104
  SecurityState.mutate(keys.values, ttl: ttl) do |state|
@@ -6,6 +6,9 @@ module Beskar
6
6
  module RequestContext
7
7
  module_function
8
8
 
9
+ # Nil when the request has no usable address, for example an in-process Rack
10
+ # client with a blank REMOTE_ADDR. Rails falls back to REMOTE_ADDR when
11
+ # forwarded headers are invalid, so a remote client cannot cause this.
9
12
  def ip(request)
10
13
  value = if request.respond_to?(:remote_ip)
11
14
  request.remote_ip
@@ -15,6 +18,8 @@ module Beskar
15
18
  request.ip
16
19
  end
17
20
  IPAddr.new(value.to_s).to_s
21
+ rescue IPAddr::Error
22
+ nil
18
23
  end
19
24
 
20
25
  def text(value, limit: 500)
@@ -213,7 +213,7 @@ module Beskar
213
213
  def analysis_for(request, input, patterns)
214
214
  {threat_detected: true, patterns: patterns, highest_severity: calculate_highest_severity(patterns),
215
215
  ip_address: RequestContext.ip(request), request_method: input.method, rules_version: RULES_VERSION,
216
- decoding_passes: input.decoding_passes, timestamp: Time.current}
216
+ request_path: input.audit_path, decoding_passes: input.decoding_passes, timestamp: Time.current}
217
217
  end
218
218
 
219
219
  # A public caller may supply an old-style analysis containing raw URLs
@@ -241,6 +241,7 @@ module Beskar
241
241
  raise ArgumentError, "WAF analysis requires matched rules" if result[:patterns].empty?
242
242
  result[:exception_class] = analysis[:exception_class] if EXCEPTION_RULES.key?(analysis[:exception_class])
243
243
  result[:request_method] = analysis[:request_method] if %w[GET HEAD POST PUT PATCH DELETE OPTIONS CONNECT TRACE OTHER].include?(analysis[:request_method])
244
+ result[:request_path] = WafRequest.audit_path(analysis[:request_path]) if analysis[:request_path].is_a?(String)
244
245
  result
245
246
  end
246
247
 
@@ -383,6 +384,7 @@ module Beskar
383
384
  permanent: duration.nil?,
384
385
  details: "WAF score: #{current_score.round(2)} (#{violation_count} violations) - #{analysis_result[:patterns].map { |p| p[:description] }.join(", ")}",
385
386
  metadata: {
387
+ waf_analysis: analysis_result,
386
388
  violation_count: violation_count,
387
389
  risk_score: current_score.round(2),
388
390
  patterns: analysis_result[:patterns],
@@ -2,12 +2,22 @@ require "uri"
2
2
 
3
3
  module Beskar
4
4
  module Services
5
- # Matching input only. Never copy these raw/canonical paths or query values
6
- # into audit records, state, or logs; those contain rule identifiers instead.
5
+ # Canonical paths are for matching only. Audit paths preserve the requested
6
+ # encoding, are bounded and filtered, and exclude queries and fragments.
7
7
  class WafRequest
8
8
  MAX_BYTES = 8192
9
9
  attr_reader :path, :problem, :method, :decoding_passes
10
10
 
11
+ def self.audit_path(value)
12
+ return unless value.is_a?(String)
13
+ path = value.byteslice(0, 4096).b.split(/[?#]/, 2).first.to_s.force_encoding(Encoding::UTF_8)
14
+ AuditData.field(:request_path, path, limit: 1024)
15
+ end
16
+
17
+ def audit_path
18
+ self.class.audit_path(@request.path)
19
+ end
20
+
11
21
  def initialize(request)
12
22
  @request = request
13
23
  @method = request.respond_to?(:request_method) ? request.request_method.to_s.upcase : "GET"
@@ -1,3 +1,3 @@
1
1
  module Beskar
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.1"
3
3
  end
@@ -143,8 +143,9 @@ Beskar.configure do |config|
143
143
  # config.waf[:request_exclusions] = [
144
144
  # {path: %r{\A/wp-content/}, methods: ["GET", "HEAD"], categories: [:wordpress_static]}
145
145
  # ]
146
- # WAF evidence excludes raw URLs and exception messages. Audit fields honor
147
- # Rails filter_parameters. See docs/guides/audit-and-waf.md before opting into :all errors.
146
+ # WAF evidence retains bounded paths, excluding queries and exception messages.
147
+ # Audit fields honor Rails filter_parameters. See docs/guides/audit-and-waf.md
148
+ # before opting into :all errors.
148
149
  #
149
150
  # === Exponential Decay Configuration ===
150
151
  # Violations decay over time based on severity (reduces false positives)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: beskar
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Maciej Litwiniuk
@@ -169,6 +169,7 @@ files:
169
169
  - app/views/beskar/security_events/index.html.erb
170
170
  - app/views/beskar/security_events/show.html.erb
171
171
  - app/views/beskar/shared/_export_form.html.erb
172
+ - app/views/beskar/shared/_waf_analysis.html.erb
172
173
  - app/views/layouts/beskar/_behavior.html.erb
173
174
  - app/views/layouts/beskar/application.html.erb
174
175
  - config/locales/en.yml