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 +4 -4
- data/CHANGELOG.md +27 -0
- data/app/views/beskar/banned_ips/show.html.erb +9 -0
- data/app/views/beskar/dashboard/index.html.erb +2 -2
- data/app/views/beskar/security_events/index.html.erb +10 -1
- data/app/views/beskar/security_events/show.html.erb +4 -0
- data/app/views/beskar/shared/_waf_analysis.html.erb +25 -0
- data/docs/README.md +1 -1
- data/docs/guides/audit-and-waf.md +18 -5
- data/lib/beskar/middleware/request_analyzer.rb +8 -0
- data/lib/beskar/services/authentication_attempt.rb +9 -2
- data/lib/beskar/services/rate_limiter.rb +4 -0
- data/lib/beskar/services/request_context.rb +5 -0
- data/lib/beskar/services/waf.rb +3 -1
- data/lib/beskar/services/waf_request.rb +12 -2
- data/lib/beskar/version.rb +1 -1
- data/lib/generators/beskar/install/templates/initializer.rb.tt +3 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9899541c9baa5b2c2a0ac7106dba36f9fff668962e5b70e11d34efa483a7d76
|
|
4
|
+
data.tar.gz: 6f7e9d0f5f725ea486974958a517ec32a725e5a2723efa7bb5e8738e794b39ca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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="
|
|
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,
|
|
15
|
-
retains the Rails-resolved IP for attribution, subject to
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
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)
|
data/lib/beskar/services/waf.rb
CHANGED
|
@@ -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
|
-
#
|
|
6
|
-
#
|
|
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"
|
data/lib/beskar/version.rb
CHANGED
|
@@ -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
|
|
147
|
-
# Rails filter_parameters. See docs/guides/audit-and-waf.md
|
|
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.
|
|
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
|