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,437 @@
1
+ # Beskar: architecture, behavior, and repair map
2
+
3
+ Historical baseline, preserved from the original review. Its findings and source
4
+ references describe the code at review time; see [repair status](repair-status.md)
5
+ for subsequent fixes and the [documentation index](../README.md) for current guides.
6
+
7
+ Reviewed 2026-09-10 against commit `f9f82c9` and the current working tree. Runtime checks used the requested mise default, Ruby **4.0.6**, with Rails **8.0.2.1**, Rack **3.2.1**, and Devise **4.9.4**. The working `Gemfile.lock` changed during the review; its current Minitest version is **6.0.6**. Those dependency changes were preserved.
8
+
9
+ This is a diagnostic document, not an implementation change or a claim of complete security assurance. It distinguishes reproduced behavior, conclusions from source, and deployment questions still requiring evidence. No application fixes have been applied.
10
+
11
+ ## Assessment
12
+
13
+ Beskar has a workable structure for a Rails security engine: two small persistent models, an isolated dashboard, a request middleware, and shared authentication concerns with separate adapters. That structure is worth keeping.
14
+
15
+ The main weakness is the connection between components. A detector may produce a result that no caller enforces; a protection may consume history that the real authentication flow never produces; an audit setting may control an enforcement decision; and the cache can disagree with the database about whether a ban exists. The test suite frequently exercises individual components with hand-built state, which conceals these gaps.
16
+
17
+ Several advertised capabilities are incomplete. The implemented WAF is a URL-pattern and exception scanner. Device detection is based on user-agent text. There is no implemented JavaScript challenge, honeypot, general SQL injection/XSS inspection, or background security-analysis job.
18
+
19
+ The initial repair should make existing behavior coherent and testable before expanding detection features.
20
+
21
+ ## Architecture and ownership
22
+
23
+ ```mermaid
24
+ flowchart TD
25
+ Host[Host Rails application and initializer] --> Config[Beskar Configuration]
26
+ Request[Incoming request] --> Outer[Host Rack middleware, sessions, Warden]
27
+ Outer --> Analyzer[RequestAnalyzer]
28
+ Analyzer --> Whitelist[IpWhitelist]
29
+ Analyzer --> BanModel[BannedIp]
30
+ Analyzer --> Limits[RateLimiter]
31
+ Analyzer --> WAF[Waf]
32
+ Analyzer --> Routes[Host and engine routes]
33
+ Routes --> Auth[Host authentication]
34
+ Auth --> Hooks[Warden callbacks or explicit Rails controller concern]
35
+ Hooks --> Tracking[SecurityTrackableGeneric]
36
+ Tracking --> Device[DeviceDetector]
37
+ Tracking --> Geo[GeolocationService]
38
+ Tracking --> Limits
39
+ Tracking --> Locker[AccountLocker and auth adapter]
40
+ Tracking --> Events[SecurityEvent]
41
+ WAF --> Events
42
+ WAF --> BanModel
43
+ Locker --> Events
44
+ Routes --> Dashboard[Authenticated dashboard controllers]
45
+ Dashboard --> Events
46
+ Dashboard --> BanModel
47
+ BanModel --> Cache[Rails.cache]
48
+ Limits --> Cache
49
+ WAF --> Cache
50
+ Geo --> Cache
51
+ ```
52
+
53
+ | Area | Primary files | Responsibility and important boundaries |
54
+ | --- | --- | --- |
55
+ | Package/bootstrap | [lib/beskar.rb](../../lib/beskar.rb), [engine.rb](../../lib/beskar/engine.rb), [gemspec](../../beskar.gemspec) | Requires components, installs middleware and global Warden callbacks, preloads bans after initialization. Mounting controls dashboard routes; middleware installation happens independently. |
56
+ | Configuration | [configuration.rb](../../lib/beskar/configuration.rb) | Mutable global configuration made of nested hashes. Defaults are instantiated through `Beskar.configure`; there is no comprehensive boot-time validator. |
57
+ | Request enforcement | [request_analyzer.rb](../../lib/beskar/middleware/request_analyzer.rb) | Resolves IP; checks whitelist, existing ban, authentication counters, WAF; returns 403/429 or calls the host app. Also catches selected downstream exceptions. |
58
+ | Authentication integration | [engine.rb](../../lib/beskar/engine.rb), [security_tracking.rb](../../app/controllers/concerns/beskar/controllers/security_tracking.rb) | Warden success/failure hooks are automatic; Rails-native controllers must call tracking methods explicitly. |
59
+ | Shared account analysis | [security_trackable_generic.rb](../../lib/beskar/models/security_trackable_generic.rb) | Builds events, extracts request context, calculates risk, records attempts, consults account history, and initiates locking. This is the most coupled component. |
60
+ | Auth adapters | [security_trackable_devise.rb](../../lib/beskar/models/security_trackable_devise.rb), [security_trackable_authenticable.rb](../../lib/beskar/models/security_trackable_authenticable.rb) | Devise helpers versus Rails-native session destruction/password reset. `SecurityTrackable` is a wrapper around the Devise concern. |
61
+ | Detection/services | [services](../../lib/beskar/services) | URL/exception patterns, sliding authentication counters, user-agent parsing, geolocation, whitelist parsing, account locking. These mostly access global config/cache directly. |
62
+ | Persistence | [security_event.rb](../../app/models/beskar/security_event.rb), [banned_ip.rb](../../app/models/beskar/banned_ip.rb), [migrations](../../db/migrate) | Audit/history and one mutable ban record per IP. Ban model callbacks also maintain cache. |
63
+ | Administration | [controllers](../../app/controllers/beskar), [banned_ip_manager.rb](../../app/services/beskar/banned_ip_manager.rb), [views](../../app/views/beskar) | Authentication callback, dashboard aggregates, event search/export, ban creation/update/bulk actions. Server-rendered, with handwritten JavaScript. |
64
+ | Installation | [install_generator.rb](../../lib/generators/beskar/install/install_generator.rb), [beskar_tasks.rake](../../lib/tasks/beskar_tasks.rake), [initializer template](../../lib/generators/beskar/install/templates/initializer.rb.tt) | Two separate installation paths with different behavior and stale instructions. |
65
+ | Verification | [test](../../test), [test helper](../../test/test_helper.rb), [.github/workflows](../../.github/workflows), [benchmark](../../benchmark) | SQLite dummy app containing both authentication systems; Minitest/FactoryBot/Mocha; duplicate CI workflows; component microbenchmarks. |
66
+
67
+ The actual dummy middleware stack places Beskar **after Warden**, close to the router. Static/asset middleware and other outer middleware can answer requests before Beskar sees them. Exceptions raised upstream cannot be caught by Beskar. This is application-layer protection, not protection of the whole ingress path.
68
+
69
+ ### Request workflow
70
+
71
+ 1. Build `ActionDispatch::Request` and take `request.ip`.
72
+ 2. Resolve the memoized IP whitelist.
73
+ 3. Check `BannedIp.banned?`: cache first, database fallback. A cached `false` is authoritative for five minutes.
74
+ 4. Check the IP authentication-attempt counter and a separate authentication-failure key. A denied request increments another counter; five denials create/extend a one-hour ban.
75
+ 5. If WAF is enabled, match the raw `fullpath`, append a violation to cache, compute its cumulative decayed score, create a database event, and possibly create/extend a ban.
76
+ 6. Apply monitor/whitelist behavior and either respond or call the application.
77
+ 7. Selected downstream Rails exceptions produce additional WAF events; exceptions are then re-raised for Rails to handle. A path match plus an exception can count twice.
78
+
79
+ Ordinary page requests do **not** increment the main authentication-attempt counter. But once that counter is over its limit, the middleware can deny **every** request from that IP, including non-authentication pages.
80
+
81
+ ### Authentication workflow
82
+
83
+ **Devise success:** Warden `after_set_user`, excluding session fetches, calls the user's shared tracker. It persists `login_success`, optionally attempts to queue analysis, records counters, and asks `AccountLocker` to lock. The Warden callback then optionally tries immediate sign-out. A “success” event therefore means credentials/set-user succeeded; it does not necessarily mean Beskar permitted the completed session.
84
+
85
+ **Devise failure:** Warden `before_failure` resolves a model by camelizing the scope, extracts an email, creates a `login_failure` with `user: nil`, and records IP/global attempts. Warden failures also occur for unauthenticated protected-page access, not just rejected submitted credentials.
86
+
87
+ **Rails-native authentication:** The host verifies the password, calls `track_authentication_success`, and then creates a session. The tracking concern rescues errors and does not return an enforceable allow/deny decision to the sample controller. Failures use the same anonymous-event path as Devise.
88
+
89
+ ### State and persistence
90
+
91
+ | State | Storage | Lifetime/invalidation | Consequence |
92
+ | --- | --- | --- | --- |
93
+ | Security events | `beskar_security_events` | No automatic retention job; linked events are destroyed with their user | This table is simultaneously audit storage, adaptive-trust history, and risk-analysis input. Retention/deletion changes protection behavior. |
94
+ | Bans | `beskar_banned_ips` | One unique string IP; rows deleted on unban | Current state is preserved, but administrative history is not append-only. `permanent` and `expires_at` can contradict each other. |
95
+ | Effective ban | `beskar:banned_ip:<ip>` | Positive TTL varies by write path; negative TTL five minutes | Cache/database agreement is part of enforcement correctness. |
96
+ | IP/account attempts | `beskar:ip_attempts:<ip>`, `beskar:account_attempts:<class>:<id>` | Hash of second timestamps to counts; writes use a hard-coded one-hour TTL plus 60 seconds | Shared cache alone does not make updates atomic; custom long windows can lose history early. |
97
+ | Global attempts | `beskar:global_attempts` | Same hash, hard-coded one-minute TTL plus 60 seconds | A single shared key becomes a contention point. Global backoff has a key collision. |
98
+ | Backoff | `beskar:ip_backoff:<ip>`, account equivalent | Incremented by limit checks; one-hour TTL | It produces a number, not an enforced deadline. |
99
+ | Authentication abuse | `beskar:ip_auth_failures:<ip>` | Read by middleware; never written by production code | This detection branch is disconnected. Tests populate it manually. |
100
+ | Rate-limit denials | `beskar:rate_limit_violations:<ip>` | Integer, TTL refreshed to one hour on each write | Not a true “last hour” counter; recurring traffic can keep old violations alive. |
101
+ | WAF history | `beskar:waf_violations:<ip>` | Array, configured window (six hours), at most 50 entries | Eviction/restart can erase unbanned attack history; database events do not reconstruct it. |
102
+ | Geolocation | `beskar:geolocation:<ip>` | Four hours by default | Cache key omits provider/database version. Mock results can survive a provider change. |
103
+ | Whitelist | Service instance variables | Manual `clear_cache!` | Independent from `Rails.cache`; changing config or clearing Rails cache does not refresh it. |
104
+
105
+ With a process-local cache, workers can disagree about rate limits, WAF history, newly added bans, and unbans. A shared store is an operational prerequisite for consistent deployment, but still does not fix the read/modify/write races.
106
+
107
+ ## Configuration and risk semantics
108
+
109
+ | Setting | Effective behavior |
110
+ | --- | --- |
111
+ | `monitor_only` | Defaults to `false` in `Configuration`. The generated initializer writes a literal `true` only if generated in development; generation in production/test writes `false`. It bypasses middleware denials but still persists/enlarges real ban records and does not guard account locking. |
112
+ | `waf[:enabled]` | Defaults to `false` in the class and is set to `true` by the initializer template. It does not disable persistent bans or authentication-rate enforcement. |
113
+ | `security_tracking[:enabled]` | Disables auth event production and associated new auth-counter writes. It is not a master switch for WAF, existing bans, or preexisting rate-limit state. |
114
+ | Per-outcome tracking | Turning off failed-login auditing also turns off its rate-limit accounting. Recording and protection cannot currently be configured independently. |
115
+ | `waf[:auto_block]` | Controls WAF-triggered ban creation and threshold denials. Previously persisted bans remain enforceable. |
116
+ | IP whitelist | Bypasses the middleware's blocking checks; WAF audit still runs. AccountLocker and public rate-limiter APIs do not apply this policy. |
117
+ | Risk-based locking | Off by default; threshold 75; implemented strategy is Devise lockable. `:custom` is a stub. |
118
+ | `immediate_signout` | Off by default. Enabling it together with risk locking currently reaches a broken method call in the Warden callback. |
119
+ | `auto_unlock_time` | Exposed as configuration and logged, but it does not set Devise's actual `unlock_in` period or schedule an unlock. |
120
+ | Geolocation provider | Defaults to generated mock geography in all environments. Real MaxMind operation requires a database file. |
121
+ | Authentication models | Scope lists exist, but failure resolution simply camelizes the scope; it does not use `Devise.mappings[scope].to`. |
122
+
123
+ **Authentication scoring:** successful login starts at 1; user-associated failure starts at 25; anonymous failure starts at 10. User-agent risk contributes up to 50. Two recent *user-associated* failures add 20. Established success patterns scale the score to 30%, capped at 25, before geographic risk is added. Geographic risk is capped at 30. The final result is capped at 100.
124
+
125
+ The normal failure callbacks do not associate events with the user, and persisted geographic data does not match the geolocation service's input contract. Consequently, in the stock flow the missing failure/travel contributions commonly leave successful-login risk at no more than 61, below the default lock threshold of 75. This is an inference from the scoring paths, not a measured production distribution. Hand-created associated events in tests bypass the problem.
126
+
127
+ **WAF scoring:** each analysis contributes one score based on its highest severity: low 30, medium 60, high 80, critical 95. Scores decay exponentially with severity-specific half-lives of 15, 45, 120, and 360 minutes. The default block threshold is 150; permanent threshold 500. Its cumulative score is different from the individual event's `risk_score`. WAF duration selection and `BannedIp`'s separate violation-count escalation both modify ban lifetime.
128
+
129
+ ## What is worth preserving
130
+
131
+ | Strength | Why it helps | Tradeoff to manage |
132
+ | --- | --- | --- |
133
+ | Isolated Rails engine | Host integration and dashboard reuse are straightforward | Global middleware/callback installation still affects the whole host application |
134
+ | Small persistent schema | Easy to inspect, migrate, and operate; unique IP index is useful | Minimal constraints permit contradictory states; event history also drives security decisions |
135
+ | Generic concern plus adapters | Provides a reasonable starting boundary for Devise and native auth | Adapter capabilities need explicit contracts; native locking is not implemented |
136
+ | Persistent ban plus cache | Fast common path and recovery of committed bans after restarts | Consistency, commit timing, expiry, and multi-process invalidation must be designed together |
137
+ | Local geolocation option | No external per-login lookup service required | Database provisioning/updating and provider validation belong in the integration contract |
138
+ | WAF decay and bounded history | More nuanced than a permanent increment-only counter; bounded per-IP entries | Scores are uncalibrated, per-IP state is attacker-controlled, and benign traffic can match |
139
+ | Deny-by-default dashboard controller | Missing/false authentication results are denied; inherited CSRF protection is enabled | Shipped callback examples contain dangerous edge cases; browser and CSRF behavior need real tests |
140
+ | Bound SQL parameters and escaped ERB output | Most ordinary injection/XSS risks are reduced by Rails conventions | JSON filtering, CSV output, raw URL logging, and inline scripts need separate treatment |
141
+ | Substantial test inventory | 596 `test` declarations across service/model/controller/integration files provide material to improve | Number and names do not establish end-to-end coverage; the current runner cannot execute them |
142
+ | Modest dependency/UI footprint | No separate frontend build is needed for the dashboard | Handwritten JavaScript duplicates framework behaviors and must work with host CSP/navigation |
143
+
144
+ ## Findings and repair priorities
145
+
146
+ `P1` means prioritize before relying on the affected protection in production. `P2` means a meaningful correctness, operability, or compatibility issue. `P3` means lower-risk cleanup. These are repair priorities, not CVSS scores. **Runtime** means reproduced on Ruby 4.0.6; **Source** means established by reading the implementation; **Deployment** identifies an untested integration assumption.
147
+
148
+ ### F01 — P1 — Account/global rate limits do not govern authentication
149
+
150
+ **Runtime + Source.** `SecurityTrackableGeneric` lines 51–52 and 121–122 call `check_authentication_attempt` after event creation but discard its result. Middleware lines 133–138 only enforce the IP check. With account and global limits both set to 1 and the IP limit raised, three independent Devise logins succeeded and accessed a protected page (HTTP 200); both limit services reported `allowed: false` afterward.
151
+
152
+ Failures are always recorded with `user: nil`, so a real user's account counter remains zero during failed-password attempts. Three failures produced three audit events and three IP attempts, but zero user-associated failures and zero account attempts. That also disconnects risk scoring and distributed-account attack detection from actual login failures. Conversely, merely visiting a Devise-protected page without a session produced a `login_failure` with no attempted email and incremented the IP counter, despite no credentials being submitted.
153
+
154
+ **Repair:** define an admission decision before session creation; count attempts by a normalized, scoped account identifier even before user lookup; consume the decision in each adapter. Distinguish authentication denial from auditing. Regress with multiple IPs attacking one account and with a global budget crossed across unrelated users.
155
+
156
+ ### F02 — P1 — Enabling immediate sign-out breaks successful Devise login
157
+
158
+ **Runtime.** [engine.rb](../../lib/beskar/engine.rb) line 36 calls `user_was_just_locked?` on the initializer's engine instance, but the helper at line 72 is a class method. A real login with `immediate_signout: true` and risk locking enabled raises `NoMethodError: undefined method 'user_was_just_locked?' for an instance of Beskar::Engine`. A separate low-risk login with threshold 100 reproduced the same error; the account need not actually qualify for locking.
159
+
160
+ The existing sign-out tests call the class helper or a separate model helper directly; they do not enable this actual callback path. After fixing the receiver, the helper still infers a decision from any lock event in the last ten seconds, rather than this authentication attempt. Disabling/failing lock-event logging can then remove the evidence sign-out needs, and `auth.logout` has no scope argument.
161
+
162
+ **Repair:** have locking return a structured result tied to the current attempt; make the adapter act on that result. Audit persistence should not be a prerequisite for sign-out. Test the real Warden path with low/high risk, multiple scopes, and logging disabled.
163
+
164
+ ### F03 — P1 — Monitor/whitelist policy does not cover account actions
165
+
166
+ **Runtime + Source.** [account_locker.rb](../../lib/beskar/services/account_locker.rb) lines 39–83 never checks monitor mode or whitelist. A real tracked Devise success with monitor enabled and a deliberately low threshold locked the account and created `account_locked`.
167
+
168
+ The middleware's policy is therefore narrower than the global-mode promise. Lowering the threshold to test behavior in monitor mode can change actual accounts. Direct service callers can also obtain rate-limit denials despite monitor/whitelist settings.
169
+
170
+ **Repair:** centralize which actions monitor and whitelist suppress, apply that policy to every action, and record “would lock” distinctly from “locked.” Preserve necessary audit events. Decide whether trusted IPs should suppress account protection as a product rule, not an accidental implementation difference.
171
+
172
+ ### F04 — P1 — Rails-native locking is not connected to an implemented strategy
173
+
174
+ **Runtime + Source.** [security_trackable_generic.rb](../../lib/beskar/models/security_trackable_generic.rb) lines 266–270 calls the native adapter only when `AccountLocker` returns true. Its Devise strategy cannot lock `User`; its custom strategy returns false. With threshold 1, a native login recorded `lock_attempted`, kept the existing session, created another session, and accessed a protected page with HTTP 200.
175
+
176
+ The native session-removal method also compares Rails' Rack session identifier to the database session primary key. The supplied controller tracks before creating the new database session and never branches on a deny result. Even activating the current removal method would not establish a reliable login refusal contract.
177
+
178
+ **Repair:** define native adapter operations for refusing session creation, revoking sessions, account state, and recovery. Provide an implemented host hook/strategy or clearly limit the advertised capability to event tracking until one exists.
179
+
180
+ ### F05 — P1 — Impossible-travel inputs are incompatible with stored history
181
+
182
+ **Runtime + Source.** The generic tracker passes JSON-backed hashes with string keys to [geolocation_service.rb](../../lib/beskar/services/geolocation_service.rb) lines 192–205, which reads symbol keys. It also passes `last.created_at.to_i` at generic line 235, although the service expects *elapsed seconds*. Either defect independently defeats impossible-travel detection.
183
+
184
+ A 60-second trip between two different mock locations returned true with symbol-keyed coordinates; the identical persisted/string-keyed location returned false. Passing an epoch timestamp also returned false. Country comparisons use symbol keys too, so known persisted countries can appear different. The caller compares several old locations against one timestamp, and `.last` has no explicit chronological ordering.
185
+
186
+ **Repair:** normalize the data contract and pass timestamped location observations. Test actual database round trips and realistic elapsed times, then validate known/unknown/private location behavior before recalibrating thresholds.
187
+
188
+ ### F06 — P1 — Client-IP resolution bypasses Rails' configured proxy interpretation
189
+
190
+ **Runtime + Deployment.** Middleware and tracking use `request.ip`, while the host's native session code uses `request.remote_ip`. A proxy-chain probe with a public trusted proxy gave `203.0.113.10` to Beskar while Rails' configured resolver returned the actual client `198.51.100.213`.
191
+
192
+ This can attribute many clients to one proxy, ban that proxy, or apply the whitelist to the wrong address. Exploitability depends on ingress header handling; this review did not inspect a production proxy. Remote-IP calculation is lazy, so the mere presence of `ActionDispatch::RemoteIp` does not guarantee Beskar triggers its spoof checks. See the [Rails configuration guide](https://guides.rubyonrails.org/configuring.html) for the proxy configuration boundary.
193
+
194
+ **Repair:** resolve and validate a canonical client identity once, using the host's supported trust configuration. Test trusted/untrusted proxy chains, IPv6, conflicting headers, and direct requests.
195
+
196
+ ### F07 — P1 — Cached bans can outlive edits and transaction rollback
197
+
198
+ **Runtime.** [banned_ip.rb](../../app/models/beskar/banned_ip.rb) lines 15–17 and 172–181 write cache before commit. Updating a ban's IP leaves the old key behind. Updating its expiry into the past does not clear its positive cache entry. Creating a ban inside a rolled-back transaction still leaves the address blocked.
199
+
200
+ There are several independent cache writers: callbacks, `ban!`, lookup, and startup preload. `ban!` overwrites callback TTL with the requested duration rather than the final record expiry. Bulk permanent updates bypass callbacks; negative entries can remain stale. Startup preload imposes a minimum 60-second positive TTL even on nearly expired bans.
201
+
202
+ **Repair:** establish one cache synchronization contract after commit, invalidate previous and current identities, derive TTL from committed state, and cover rollback/edit/expiry/bulk operations. Shared-store coherence needs separate multi-worker verification.
203
+
204
+ ### F08 — P1 — Permanent-ban state is contradictory and can stop enforcing
205
+
206
+ **Runtime.** Calling `ban!(existing_ip, permanent: true)` does not set the existing record's permanent flag or clear its expiry. The cache can nevertheless be written with no expiry. Conversely, `extend_ban!` eventually sets `permanent: true` without clearing an old expiry; `.active` only tests expiry, whereas `active?` honors the permanent flag.
207
+
208
+ After automatic escalation and time travel beyond the old expiry, the record reported `permanent? == true` and `active? == true`, but disappeared from `.active` and was not blocked after cache loss. The expired cleanup scope can select such a record too.
209
+
210
+ **Repair:** define and enforce the invariant for permanent versus temporary bans in validation, database constraints where appropriate, query scopes, and transition methods. Test cache loss/restart as part of ban lifecycle tests.
211
+
212
+ ### F09 — P1 — Shared counters are not concurrency-safe
213
+
214
+ **Runtime + Source.** Rate attempts, backoff counts, middleware-denial counts, and WAF arrays use separate cache reads and writes. A controlled two-thread interleaving through the real rate-counter writer recorded **one attempt for two writes**, using MemoryStore; sharing the store does not make this multi-step operation atomic.
215
+
216
+ Existing-ban extension also reads/modifies/saves without a lock or atomic increment. New-ban retries only catch a validation error by matching its English message; the database's unique-index race can instead raise `RecordNotUnique`.
217
+
218
+ **Repair:** choose atomic storage operations for counters and WAF state; define concurrency-safe ban transitions. A generic Rails cache API may not provide all required primitives. Verify on the actual supported shared backend, with parallel callers targeting the same key.
219
+
220
+ ### F10 — P1 — Shipped dashboard authentication examples have unsafe edge cases
221
+
222
+ **Runtime + Source.** The cookie example in the [initializer template](../../lib/generators/beskar/install/templates/initializer.rb.tt), lines 34–37, compares an absent cookie to an environment secret without requiring that secret to exist. With a missing expected token, anonymous dashboard access returned HTTP 200 because `nil == nil`.
223
+
224
+ The default controller correctly denies an explicitly false callback (HTTP 404). The problem is the shipped integration recipe. The bearer example similarly lacks a nonempty-secret guard. The generator's `proc { authenticate_admin! }` example can recursively invoke Beskar's own method; its redirect example does not implement the truthy/falsey contract for an authorized user. Authentication helpers that already render/redirect can also collide with the controller's unconditional failure rendering.
225
+
226
+ Additionally, the missing-auth configuration message interpolates `ENV["BESKAR_ADMIN_TOKEN"]` into a logged example at [application_controller.rb](../../app/controllers/beskar/application_controller.rb) line 67, potentially logging a configured secret. No actual secret was retrieved during this review.
227
+
228
+ **Repair:** ship executable, tested callback recipes with required-secret checks and safe comparison; avoid recursive names and duplicate renders; remove secret interpolation from diagnostic examples.
229
+
230
+ ### F11 — P1 — Audit output can expose sensitive inputs and unsafe CSV cells
231
+
232
+ **Runtime + Source.** WAF copies raw `request.fullpath` into matched patterns, cache state, database metadata, and warning logs. A harmless review URL `/search?q=/wp-admin&token=REVIEW_SECRET` retained the entire token-bearing query. These custom writes do not apply Rails parameter filtering. Authentication metadata also retains session identifiers, referrers, and forwarded headers; top-level user agents are not truncated by the detector's nested-field truncation.
233
+
234
+ CSV export preserved the attacker-controlled user agent `=1+1` as a cell. Spreadsheet applications can interpret such cells as formulas; see [OWASP's CSV injection guidance](https://owasp.org/www-community/attacks/CSV_Injection). This was a text-level export reproduction, not execution inside a spreadsheet.
235
+
236
+ **Repair:** define a bounded, filtered audit schema at capture time, classify data retention/access, and make exports safe for their intended consumers. Keep sufficient decision evidence without retaining raw secrets.
237
+
238
+ ### F12 — P1 — Failure behavior can turn security plumbing into request outages
239
+
240
+ **Runtime + Source.** Middleware cache/database access has no general dependency-failure policy. WAF event persistence is rescued, but ban persistence is re-raised. Injecting a ban-storage failure caused even a monitor-only request to raise instead of reaching the app. Devise's direct generic tracking path is not rescued like the native controller concern, so similar faults have different effects by adapter.
241
+
242
+ The logger's rescue block calls its own `warn` method rather than `Kernel.warn`. A failing logger that also fails warnings recursed to `SystemStackError` in a probe ([logger.rb](../../lib/beskar/logger.rb), lines 34–42).
243
+
244
+ **Repair:** explicitly decide degraded behavior for audit loss, unavailable enforcement state, and failed account actions. Do not blanket-rescue everything into an allow decision. Keep failure reporting independent of the failed logging backend, and test errors on real request paths.
245
+
246
+ ### F13 — P1 — Verification tooling currently cannot establish a green baseline
247
+
248
+ **Runtime.** `mise exec -- env PARALLEL_WORKERS=1 bin/rails test` aborted before executing tests: Minitest 6 calls the suite runner with three arguments, whereas Rails 8.0.2.1's `Rails::LineFiltering#run` accepts one or two. The checked-in baseline lock had Minitest 5.25.5; the current working lock selects 6.0.6.
249
+
250
+ `bundle exec standardrb --format progress` also exited unsuccessfully: the locked RuboCop AST/Prism stack rejects target Ruby 4.0. “No offenses detected” in its trailing text is not a successful lint run; zero files were inspected.
251
+
252
+ CI only selects Ruby 3.4 and runs in two overlapping workflows. Neither establishes compatibility with the requested Ruby 4.0.6 runtime. The gemspec declares Rails `>= 8.0.0` without a tested upper bound or explicit Ruby requirement.
253
+
254
+ **Repair:** restore test/lint compatibility on Ruby 4.0.6, agree the supported Rails/Ruby matrix, then consolidate CI around it. Do not treat the runtime probes in this review as a replacement for a passing suite.
255
+
256
+ ### F14 — P2 — Brute-force detection reads nonexistent production state
257
+
258
+ **Runtime + Source.** Middleware line 154 reads `beskar:ip_auth_failures:<ip>`; no production writer exists. Tests in `middleware_blocking_test.rb` lines 220 and 249 inject it directly. The branch also counts timestamp buckets instead of summed attempts, so many failures in one second would be counted as one even if a writer were added.
259
+
260
+ IP attempt limiting still operates through a different key; the finding does not mean all brute-force resistance is absent. Devise's own Lockable behavior is another independent layer.
261
+
262
+ **Repair:** use the same authoritative attempt data and window semantics as F01/F09; remove the disconnected parallel representation.
263
+
264
+ ### F15 — P2 — Backoff is side-effectful reporting, not a delay contract
265
+
266
+ **Runtime + Source.** Two checks of an already-limited IP returned retries of 60 then 300 seconds without another authentication attempt. The request became allowed two seconds later when a configured one-second window elapsed; no backoff deadline was enforced. Middleware always responds `Retry-After: 3600` instead of the service result. `most_restrictive_result` returns the first denied tier, not the maximum delay.
267
+
268
+ Enabling the exposed global `exponential_backoff` option raises `NoMethodError` because substituting `_attempts:` does not change the key `beskar:global_attempts`: its attempt hash is read as the backoff integer. Recording TTLs also ignore custom windows, and the public reset method does not clear middleware-denial state, global attempts, WAF history, or bans.
269
+
270
+ **Repair:** separate pure inspection, atomic accounting, and actual retry eligibility. Document reset scope and make returned headers consistent with enforced timing.
271
+
272
+ ### F16 — P2 — WAF pattern scope causes false positives and coverage gaps
273
+
274
+ **Runtime + Source.** The unanchored patterns match the entire raw URL, including query values. `/.well-known/openid-configuration` was classified as a debug scan; a search query containing `/wp-admin` was classified as WordPress probing. `/%2e%2e%2fprivate` did not match the traversal patterns. URL normalization upstream will influence particular encodings, so this is a local matcher result, not a claim of exploitation of a host vulnerability.
275
+
276
+ Every uncaught `RecordNotFound`/`UnknownFormat` in scope is treated as suspicious by default. Exclusions only address `RecordNotFound`; they do not provide a general route/method/category policy. The first critical match remains below the default cumulative threshold, so this system is not a guarantee against one-shot exploitation. There is no request-body or general SQLi/XSS inspection despite the gem description.
277
+
278
+ **Repair:** define the supported threat model, canonicalized inputs, per-route exclusions, and whether certain confirmed threats require immediate decisions. Build a benign-traffic corpus alongside attack cases; remove unsupported marketing claims.
279
+
280
+ ### F17 — P2 — Risk explanations and adaptive trust are not well-founded
281
+
282
+ **Runtime + Source.** Device detection emits `bot`, while lock-reason classification looks for `bot_signature` or `suspicious`. Geolocation risk returns an integer, but lock-reason classification expects `impossible_travel`, `country_change`, and `high_risk_country` flags that ordinary lookup does not produce. Emergency-reset queries expect context at the metadata root, while lock events put it under `additional_context`.
283
+
284
+ Trust is based on repeated IP address use, not a verified device or user-confirmed recovery. `account_locked` and `lock_attempted` are treated like an unlock for parts of adaptive learning. Credential-success events are persisted before the enforcement result. A model-level probe showed that such history can mark an IP established; it does not establish that an attacker can authenticate an already locked Devise account through the real login endpoint.
285
+
286
+ Other scoring defects: `hour.between?(22, 6)` is false for every hour, and `match?` does not set `$1`, so a modern Chrome 120 user agent received the old-browser risk increment. `geographic_anomaly_detected?` is a placeholder returning false. User-agent parsing is spoofable metadata, not proof of device identity or bot legitimacy.
287
+
288
+ **Repair:** return a score with explicit normalized factors and evidence. Separate credential success, admitted session, confirmed recovery, and trusted context. Calibrate only after the missing history and geography contracts are repaired.
289
+
290
+ ### F18 — P2 — Lock recovery settings are partly placeholders
291
+
292
+ **Source.** `auto_unlock_time` only causes `locked_at` to be set to the current time; Devise actually consults its own `unlock_in`. Locking calls Devise with `send_instructions: false`, while Beskar's `notify_user` only logs intent. Native emergency-reset notifications also only log. `require_manual_unlock` is never consumed.
293
+
294
+ An emergency password reset can change the password before later event persistence fails, yet the broad rescue can describe the operation as a failed reset. The action is not transactional with its audit/recovery workflow.
295
+
296
+ **Repair:** make recovery and notifications real adapter capabilities with truthful configuration. Test user recovery, delivery failures, partial persistence failures, and session revocation as part of the same flow.
297
+
298
+ ### F19 — P2 — Monitor-to-enforcement transition changes live ban state
299
+
300
+ **Source; behavior partly intentional and documented.** Monitor mode uses the real ban table and repeatedly extends bans while traffic continues. Ban records themselves do not identify simulated versus enforced origin. Disabling monitor mode immediately enforces surviving bans, potentially with durations accumulated under observation conditions that differ from active blocking (where requests would have exited early).
301
+
302
+ WAF event `would_be_blocked` is only a score comparison; it ignores whitelist and `auto_block`. Monitor impact statistics can therefore count requests that policy would allow. Authentication/rate-limit events do not all carry equivalent mode/decision metadata.
303
+
304
+ **Repair:** model observed decisions separately from enforced ban state, or define and expose an explicit activation policy. Report final policy outcomes rather than just threshold crossings.
305
+
306
+ ### F20 — P2 — Installation paths and defaults disagree
307
+
308
+ **Runtime + Source.** The generator calculates its migration source one directory above the engine: `/home/mlitwiniuk/Sites/r8/db/migrate`, which does not exist, although the engine has two migrations. It silently skips copies and prints success. Its custom destination builder also prefixes the entire path (`<timestamp>_db/migrate/...`) instead of the migration basename.
309
+
310
+ The rake task is a separate implementation. Its instructions reference nonexistent `Beskar::SecurityTrackable` and the removed nested `waf[:monitor_only]`. The generator advertises a nonexistent `beskar:indexes` generator. Generator tests emphasize template content and mounting, not successful install/migrate/boot in a fresh host.
311
+
312
+ The template's monitor value is fixed at generation time, so the README's safe monitor default depends on where the initializer was generated, not where it runs.
313
+
314
+ **Repair:** one installation implementation and source of defaults, tested in a fresh host with Devise absent/present, followed by migration and request smoke checks. Use correct, executable next-step instructions.
315
+
316
+ ### F21 — P2 — Missing capabilities are exposed as though available
317
+
318
+ **Source.** `SecurityAnalysisJob` is never defined; `auto_analyze_patterns` therefore queues nothing. Only empty base job/mailer classes exist. API v1 routes are declared in [routes.rb](../../config/routes.rb) lines 27–41, but corresponding controllers do not exist. Generic custom locking, IP2Location lookup, and notification methods are placeholders. The Devise concern checks for a class-level `after_database_authentication` registration API, but Devise defines an instance method hook; actual success tracking comes from Warden.
319
+
320
+ **Repair:** explicitly classify implemented features, host extension points, and plans. Remove nonfunctional public routes and configuration promises or implement them with contract tests.
321
+
322
+ ### F22 — P2 — Database portability and growing history are unverified
323
+
324
+ **Source + Deployment.** `SecurityEvent.metadata` is a JSON column, but the email filter issues `metadata LIKE ?` without a text cast; that is not valid for PostgreSQL's JSON type. The separate general-search path explicitly casts for PostgreSQL, revealing the inconsistency. Native reset queries use adapter-specific JSON extraction; the suite only configures SQLite.
325
+
326
+ Auth scoring repeatedly queries and loads event history synchronously. One warm, established-login probe performed six noncached SELECTs plus the event write; other branches add more. The principal user/type/event/time queries lack a matching composite index. Dashboard aggregates scan overlapping ranges separately. Leading-wildcard search is expensive at scale, JSON export loads the whole result, and CSV generation accumulates the complete output in memory even though records are fetched in batches.
327
+
328
+ **Repair:** name supported adapters and exercise them; centralize adapter-sensitive filtering; measure realistic event volumes with query plans; add retention, appropriate indexes, bounded exports, and background work where justified. Do not move final admission decisions to delayed jobs.
329
+
330
+ ### F23 — P2 — Audit history is mutable and coupled to user lifetime
331
+
332
+ **Runtime + Source.** Deleting a user destroys its linked security events via `dependent: :destroy`. Unban destroys the ban row, and administrative changes are not recorded with actor, previous state, and reason in an immutable action history. Failed-login events with no user association have a different deletion lifetime from successful events.
333
+
334
+ This is a product/data-retention decision, not automatically a requirement to keep every personal field forever. It currently undermines the description of a comprehensive audit trail and makes incident reconstruction harder.
335
+
336
+ **Repair:** define audit retention/anonymization separately from live user and ban state, and record administrative actions with actor and request correlation.
337
+
338
+ ### F24 — P2 — Runtime configuration can be stale or invalid
339
+
340
+ **Runtime + Source.** Removing an IP from `configuration.ip_whitelist` left it whitelisted until the service cache was explicitly reset. The README does document that reset, but generic config replacement/test resets do not perform it. Geolocation caches omit provider identity, and the singleton reader is not automatically refreshed on a path change.
341
+
342
+ Nested hash replacement can drop required keys; unknown/deprecated keys are silently accepted. Thresholds, periods, durations, and severity half-lives are not validated. Model auto-detection depends on already loaded descendants; scope-to-class conversion does not support arbitrary Devise mappings. There is no declared startup validation of cache capabilities, locking strategy, or real-versus-mock geolocation.
343
+
344
+ **Repair:** validate configuration once, expose effective configuration, and make dynamic change semantics explicit. Prefer immutable runtime config if live reconfiguration is not a supported feature.
345
+
346
+ ### F25 — P2 — Rack response contract is violated
347
+
348
+ **Runtime.** Beskar's direct 403/429 responses contain title-cased header names. Wrapping the middleware in `Rack::Lint` produced `uppercase character in header name: Content-Type`. Rack 3 requires lowercase response header names; see the [Rack specification](https://rack.github.io/rack/3.2/SPEC_rdoc.html). Current integration tests assert the old casing directly.
349
+
350
+ **Repair:** return Rack-compatible headers, correct retry timing, and appropriate representations for API clients. Add Rack contract validation around direct middleware responses.
351
+
352
+ ### F26 — P2/P3 — Dashboard semantics and host integration need cleanup
353
+
354
+ **Source; browser behavior not exercised.** Risk labels disagree: models define high at 70 and critical at 90; dashboard/filter boundaries use 61 and 86; badges classify 30 differently from filters. Ban detail “total events” is counted on a relation limited to 20. User displays/JSON export generally assume `email`, while the native sample uses `email_address`.
355
+
356
+ Some inline scripts lack the nonce used by the layout's script helper and rely on `DOMContentLoaded`; strict host CSP or Turbo navigation can affect them. Datetime helpers convert values to UTC ISO text and put them in local datetime inputs, which can shift intended expiry. This needs browser/timezone validation. Handwritten method-link submission duplicates framework behavior. No browser system tests were found.
357
+
358
+ **Repair:** unify risk definitions and user presentation; distinguish totals from recent samples; test ban forms, CSRF, expiry timezone, CSP, and host navigation behavior in a real browser.
359
+
360
+ ## Why existing tests miss important defects
361
+
362
+ These are specific evidence gaps, not a dismissal of the suite:
363
+
364
+ - `test/integration/warden_signout_test.rb` tests the helper as a class method but leaves `immediate_signout` at its default false; the real callback's receiver error is missed.
365
+ - `test/integration/middleware_blocking_test.rb` writes the nonexistent authentication-failure cache state by hand.
366
+ - `test/integration/devise_rate_limiting_test.rb` calls a test “distributed rate limiting” while asserting that each IP stays allowed, without asserting a denied account decision.
367
+ - `test/integration/rails_auth_security_test.rb` calls a test “high risk login triggers account locking,” but its final assertion is only that an event exists. Emergency reset is tested by manually creating metadata and invoking methods.
368
+ - Factories usually associate failure events with a user, while real failure callbacks do not. That difference supplies risk-scoring inputs absent from production paths.
369
+ - Several concurrency checks are sequential requests or different-IP operations. They do not exercise competing writes to one security key.
370
+ - Cache/provider failure tests are commented out; MaxMind tests skip without a database; the background-job test skips because the job does not exist.
371
+ - The shared test helper resets configuration and Rails cache, but not all memoized service state. The IP helper maps hashes into only 200 buckets and is not guaranteed unique. Factory risk values are randomized.
372
+ - CSRF protection is globally disabled in the test environment. Request tests do not establish that the dashboard's browser mutation flows work with forgery protection enabled.
373
+ - Generator tests verify text references to documentation even when the referenced files/features do not exist.
374
+
375
+ Meaningful regression tests should start at the caller boundary, create history through real authentication attempts where possible, and assert the final HTTP/session/account/cache state. Component tests remain useful for algorithms once their input/output contracts agree.
376
+
377
+ ## Documentation drift
378
+
379
+ | Claim or example | Actual implementation |
380
+ | --- | --- |
381
+ | README/gemspec: advanced bot challenges and honeypots | User-agent regexes; no challenge/honeypot implementation |
382
+ | Gemspec: WAF blocks SQLi and XSS | No general SQLi/XSS payload inspection |
383
+ | Project docs: background analysis and graceful failure | Undefined analysis job; cache/DB faults can escape; logger fallback can recurse |
384
+ | Global monitor-only mode suppresses all blocking | Middleware honors it; account actions do not |
385
+ | Monitor docs and older README sections use `block_threshold` | Active WAF uses `score_threshold`; old keys are silently ignored |
386
+ | `GeolocationService.lookup`, `IpWhitelist.add/remove`, `calculate_authentication_risk` | These documented methods are absent; actual APIs differ |
387
+ | `Beskar::SecurityTrackable`, `:failed`, `login_failed` examples | Actual concern is under `Beskar::Models`; expected outcome is `:failure`; event is `login_failure` |
388
+ | Auto-unlock period and notifications | Devise controls unlock timing; Beskar notifications are log-only |
389
+ | Database-agnostic, real-time dashboard | Adapter-specific queries; ordinary server-rendered dashboard without polling/push |
390
+ | Missing configuration returns 401/helpful examples to browser | Controller returns 404 and logs examples |
391
+ | Links to `BREAKING_CHANGES.md`, `DASHBOARD.md`, `WAF_CONFIGURATION_PROFILES.md` | Files are absent in this checkout |
392
+ | Documentation release date/version narrative | Version is 0.1.0; project docs mention a 2024 release while changelog is Unreleased and migrations are dated 2025 |
393
+ | README performance claims include O(1) rate check | The counter is read and filtered/summed over timestamp buckets; benchmarks do not include full detection/persistence/admission cost |
394
+
395
+ The README contains both newer score-based guidance and older count-based guidance. It should be reconciled as a whole. Demos also reference the old User email schema and obsolete WAF keys; they are not reliable smoke checks.
396
+
397
+ ## Suggested repair sequence
398
+
399
+ | Step | Scope | Completion evidence |
400
+ | --- | --- | --- |
401
+ | 1. Establish Ruby 4.0.6 baseline | Compatible test runner/linter, supported dependency matrix, one CI workflow; retain current behavior initially | Full tests and lint execute on Ruby 4.0.6; exact baseline failures are recorded |
402
+ | 2. Close immediate safety/correctness holes | Warden receiver crash, global monitor policy, unsafe auth examples, secret logging, logger recursion, Rack headers | Real auth/dashboard requests and injected failures reproduce the old issue and verify the corrected behavior |
403
+ | 3. Make bans internally consistent | Permanent/expiry invariant; committed cache synchronization; edit/unban/rollback behavior; concurrency | Transition tests include time passage, restart/cache loss, same-IP parallel changes, multiple workers |
404
+ | 4. Define authentication admission | Canonical account/IP identity, pre-session decision, failure association/accounting, account/global limits, retry/reset semantics | Distributed attempts hit account budget; unrelated attempts hit global budget; denied logins cannot create usable sessions |
405
+ | 5. Repair risk data and adapters | Persisted geography/time contract, factor evidence, native lock/recovery, explicit trust establishment | Database-roundtrip travel tests; real Devise/native admission and recovery tests; score explanations match inputs |
406
+ | 6. Bound and harden detection/audit | WAF threat scope, benign corpus, input filtering, atomic state, retention, safe exports, admin audit | No secrets in emitted records; expected benign paths allowed; concurrency, volume, adapter, and export checks |
407
+ | 7. Reconcile installation/docs/UI | Single tested install path, effective defaults, remove unsupported routes/promises, browser behavior | Fresh host install/migrate/boot succeeds; documented examples execute; actual browser forms work |
408
+
409
+ Steps 2 and 3 can be split into small independent fixes once step 1 provides a reliable baseline. Step 4's identity/decision contract should precede substantial risk-engine refactoring. A broad rewrite is not necessary to start; fixing the observable contracts will reveal where deeper changes are justified.
410
+
411
+ The target direction is a small explicit pipeline: **capture normalized context → atomically account for the attempt → assess factors → decide under policy → act through an auth adapter → record the outcome**. This is a proposed design direction, not code already present. Audit storage and asynchronous analysis should observe decisions rather than implicitly determine whether an action succeeded.
412
+
413
+ ## Verification record and remaining questions
414
+
415
+ Executed directly from the project on mise default Ruby 4.0.6:
416
+
417
+ - `bundle check`: dependencies satisfied after the working lockfile/dependency setup changed during the review.
418
+ - `mise exec -- env PARALLEL_WORKERS=1 bin/rails test`: runner error before tests; no passing-suite claim.
419
+ - `bundle exec standardrb --format progress`: Ruby 4.0 parser/tooling error; no clean-lint claim.
420
+ - `mise exec -- bin/rails middleware`: actual dummy middleware placement inspected.
421
+ - `mise exec -- env RAILS_ENV=test bundle exec rake app:zeitwerk:check`: eager-loading check passed; the dummy app's mailer-preview directory is not included in eager loading. Engine tasks use the `app:` namespace; the initial unprefixed command was unavailable.
422
+ - Ruby 4.0.6 compiled all 33 Ruby source files under `lib`, `app`, `config`, and `db` without syntax errors. All local document links resolve. This is a syntax/reference check, not behavioral verification.
423
+ - `mise exec -- bin/rails runner -e test tmp/review/probes.rb`: focused probes through real services/models and several actual HTTP/session workflows. Each probe used the test database inside a rolled-back transaction and process-local cache; fault injections were local to the diagnostic process. Probe exceptions are reported deliberately as findings, not swallowed as successful tests.
424
+
425
+ The diagnostic script is in the ignored `tmp/review` directory. It is review evidence, not a committed regression suite. Initial isolated dependency setup under `/tmp` was abandoned once the project bundle became available; the final runtime checks did not use a different Ruby or temporary dependency manifest.
426
+
427
+ Still to establish before making production claims:
428
+
429
+ - Actual production proxy/CDN header handling, canonical IP expectations, and whether shared egress IPs are common.
430
+ - Production cache backend/topology, eviction and outage behavior, request rates, and event-history size.
431
+ - Supported host Rails/database/auth combinations, custom Devise scope mappings, API-only hosts, and reload behavior.
432
+ - Desired policy for successful-login counts, account/global budgets, whitelist scope, monitor activation, and degraded enforcement.
433
+ - MaxMind database behavior and update lifecycle; no real database was available for end-to-end geography validation.
434
+ - Browser behavior under actual CSP, Turbo, enabled CSRF, and Europe/Warsaw/local datetime input handling.
435
+ - An appropriate retention/anonymization policy and the recovery experience after an automatic lock/reset.
436
+
437
+ The findings above are sufficient to start targeted repairs. A passing compatibility matrix and deployment-level verification remain necessary to establish broader confidence.