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,144 @@
1
+ # Coordinated security state
2
+
3
+ Beskar supports any `Rails.cache` backend, including a process-local MemoryStore,
4
+ FileStore, RedisCacheStore, MemCacheStore, Solid Cache, or NullStore. Enforcement
5
+ does not depend on cache increment, compare-and-swap, distributed locks, or cache
6
+ persistence. A custom store does not need any Beskar-specific operations.
7
+
8
+ ## Authority and trade-offs
9
+
10
+ `beskar_security_states` is the shared authority for rate-limit windows, backoff
11
+ deadlines, middleware denial counts, and WAF violation history. Transactions lock
12
+ rows in deterministic order. Unique keys prevent duplicate state; optimistic
13
+ locking and bounded retries handle concurrent-update conflicts, including SQLite
14
+ write-lock contention. Retryable blocks must contain only database work.
15
+
16
+ Ban mutation reads also lock the ban row. Under MySQL's default `REPEATABLE READ`,
17
+ an earlier coordination lookup can establish a snapshot before another worker
18
+ commits. Locking the coordination row does not refresh ordinary snapshot reads
19
+ of other tables; [locking reads](https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html)
20
+ are required to preserve the latest violation count and expiry when extending a ban.
21
+ Native session revocation likewise locks the current session rows and checks for
22
+ remaining rows with a locking read, ignoring stale loaded associations while
23
+ preserving host removal/destruction callbacks.
24
+
25
+ `beskar_banned_ips` is authoritative for ban decisions. Each decision uses an
26
+ indexed database query. Stale positive/negative cache entries, cache eviction,
27
+ process restarts, and rolled-back ban writes do not change the result.
28
+ `BannedIp.preload_cache!` remains a compatibility no-op.
29
+
30
+ All application workers must use the same authoritative database. Separate SQLite
31
+ files in different containers are not shared coordination. Run security reads and
32
+ writes against the writer, not an asynchronously replicated read-only database.
33
+ Cache independence is **not** database-outage independence: database failures may
34
+ fail the request, rather than silently discard enforcement state.
35
+ Windows and deadlines use application time, so worker clocks must be synchronized.
36
+
37
+ This design adds database work: normal requests read bans; authentication attempts
38
+ update IP and optional account state; detected WAF violations update per-IP state.
39
+ The shared global counter is disabled by default, avoiding an application-wide
40
+ login-denial budget and lock hot spot. Explicitly enabling
41
+ `rate_limiting[:global_attempts][:enabled]` restores it and its availability risk.
42
+ Session resumption/generation checks add uncached database reads; locks revoke
43
+ generations transactionally. Benchmark with the host application's traffic and pool
44
+ size. No fixed throughput or latency guarantee is asserted.
45
+
46
+ Concurrency regressions exercise separate database connections. The PostgreSQL 17
47
+ and MySQL 8.4 full-suite jobs passed in
48
+ [CI run 35353219531](https://github.com/AuditBadger-com/beskar/actions/runs/35353219531).
49
+ The preceding run confirmed the MySQL schema repairs, then exposed stale ban reads and two
50
+ test SQL-quoting assumptions. These failures were reproduced against an isolated
51
+ local MySQL 8.4.11 server; repeated concurrency runs also exposed stale session
52
+ cleanup during native account locking. After repair, the full MySQL and SQLite
53
+ suites each pass 849 tests / 4,619 assertions with three existing MaxMind-data
54
+ skips (Ruby 4.0.7, seed `20260918`). The MySQL concurrency suite also passes ten
55
+ seeds (`101` through `1010`, in increments of `101`). New instance-extension and
56
+ stale-session-association regressions fail before their fixes and pass after them.
57
+ Production-load validation remains open.
58
+ Local Docker access was denied, so MySQL ran with a project-local data directory
59
+ and Unix socket, with TCP networking disabled; no system service was installed.
60
+ `BESKAR_TEST_DATABASE_URL` selects an isolated test database; never
61
+ point it at a production database (Rails test tasks can rebuild it).
62
+
63
+ JSON columns use `default: -> { "('{}')" }`: MySQL requires a parenthesized
64
+ expression for [JSON defaults](https://dev.mysql.com/doc/refman/8.4/en/data-type-defaults.html).
65
+ SQLite and PostgreSQL accept this expression too. Model attributes also supply
66
+ independent empty hashes because MySQL reports these defaults as SQL functions.
67
+ When regenerating the dummy schema from SQLite, preserve these expressions and
68
+ the `sessions.user_id` bigint type needed to match MySQL's default primary keys.
69
+ Regression tests check both migrations and the checked-in schema's MySQL SQL,
70
+ plus actual default values on the active test database.
71
+
72
+ ## Rate-limit semantics
73
+
74
+ All recorded authentication attempts count, whether credentials succeed or fail.
75
+ Each tier retains at most its configured limit of admitted timestamps; denied
76
+ attempts do not grow that tier's sliding window. Other tiers still count the
77
+ attempt while they have capacity. Results combine tiers using the latest retry
78
+ deadline. Exponential backoff is an enforced deadline, not just a response hint.
79
+
80
+ `:check`, `is_rate_limited?`, and `time_until_allowed` are read-only and do not
81
+ escalate backoff. Backoff grows on denied calls that record attempts. Middleware
82
+ previews do not grow authentication backoff. By default an authentication quota
83
+ does not block unrelated page/API traffic from the same shared IP. Opting into
84
+ `rate_limiting[:ip_attempts][:block_requests] = true` enables request-wide blocking
85
+ and the five-denials/hour automatic ban. IP login quotas still affect shared-NAT
86
+ users: size them for actual shared egress, and retain account limits. No default
87
+ can distinguish every legitimate user from an attacker sharing an IP.
88
+
89
+ `reset_rate_limit(ip_address:, user:, global: false)` clears the selected IP,
90
+ account, and IP denial/backoff state in the current mode. Pass `global: true` to
91
+ also reset the global counter. It does not remove bans. Monitor and enforcement
92
+ state are separate; see [monitor mode](monitor-only-mode.md).
93
+
94
+ The Devise database-password adapter now reserves and enforces all applicable
95
+ limits before verifying credentials. Rails-native controllers must adopt the
96
+ [explicit admission/session guards](../guides/authentication.md). Outcomes reuse the same
97
+ request-local attempt, independently of optional audit persistence. Whitelisted
98
+ requests record observation-only counters without consuming enforced capacity.
99
+
100
+ Native account locks also use this table, with an internal automatic-unlock
101
+ deadline or manual-only state. Their rows are retained for the account lifetime;
102
+ expired-counter cleanup does not delete native lock authority.
103
+
104
+ ## Upgrade
105
+
106
+ 1. Copy the new migration with `bin/rails beskar:install:migrations`, then run
107
+ `bin/rails db:migrate` before starting the new application workers. Fresh installs
108
+ can use `bin/rails generate beskar:install`.
109
+ 2. Drain old workers during rollout. Old cache-based workers and new database-based
110
+ workers do not coordinate. Existing cache counters are not imported, so counting
111
+ windows restart; existing database bans remain in force.
112
+ 3. Review legacy bans. A temporary ban must have an expiry; a permanent ban is
113
+ active regardless of a legacy expiry timestamp. Permanent rows are never removed
114
+ by expired-ban cleanup. New saves normalize permanent expiry to nil. Inspect
115
+ malformed/CIDR IPs, alternate IPv6 spellings, and duplicate canonical addresses
116
+ in older data before rollout; new writes require canonical individual IPs.
117
+ 4. Review bans created by older monitor-mode versions before enabling enforcement.
118
+ Those rows are not automatically deleted or reclassified.
119
+ 5. Schedule `bin/rails beskar:cleanup_security_state` periodically (for example,
120
+ hourly). Expired rows are ignored immediately; cleanup only reclaims storage.
121
+ Audit events and active bans are not deleted by this task. Security-event
122
+ retention is separate: account deletion now retains events unchanged, with
123
+ no automatic expiry/purge. See [Audit lifecycle](../guides/audit-lifecycle.md).
124
+ 6. Upgrade Rails-native login controllers and existing-session readers using
125
+ [Authentication](../guides/authentication.md). The former logging-only calls cannot
126
+ prevent session creation. No additional migration beyond the shared-state
127
+ migration is needed for these native locks.
128
+ 7. For batch eight, also apply the administrative-action migration and configure
129
+ a trusted `audit_actor` for dashboard writes. See [Audit lifecycle](../guides/audit-lifecycle.md)
130
+ for required reasons, transactional history, and the old-worker drain requirement.
131
+
132
+ ## Verification
133
+
134
+ Run with the project's current mise default Ruby:
135
+
136
+ ```sh
137
+ mise exec -- env PARALLEL_WORKERS=1 bin/rails test
138
+ mise exec -- bundle exec standardrb
139
+ ```
140
+
141
+ Regression coverage includes null/unavailable caches, distinct IP/account keys,
142
+ global coordination, atomic concurrent updates, long windows, backoff expiry,
143
+ read-only checks, rollback, stale ban caches, canonical IPv6, permanent bans,
144
+ monitor isolation, trusted proxy attribution, Rack headers, and migration copying.
@@ -0,0 +1,69 @@
1
+ # Rust performance assessment
2
+
3
+ Assessed on 2026-09-11 against the current working tree, including uncommitted remediation changes. This records an exploratory assessment for future consideration; no Rust implementation was built or benchmarked.
4
+
5
+ **There is room for a small Rust component, especially WAF matching and large IP/CIDR lists. Current evidence favors reducing database work before introducing Rust.** A broad rewrite is not justified by the measurements below.
6
+
7
+ The intended constraints are performance improvements only, no Rust toolchain requirement for applications installing Beskar, precompiled binaries for supported platforms and architectures, and no net increase in request overhead.
8
+
9
+ [Original project review](../audits/project-review.md) and [Archived project overview](../archive/project-documentation.md) describe an older architecture in several places. The current implementation and [State storage](../operations/state-storage.md) establish that ordinary requests query both bans and rate-limit state. Authentication attempts and WAF violations also perform transactional state updates. Translating their calculations to Rust would leave database round trips and lock contention in place.
10
+
11
+ **Local measurements**
12
+
13
+ A read-only diagnostic used Ruby 4.0.6 with YJIT, Rails 8.0.2.1, and local SQLite on Linux x86-64. It measured a single thread with warm code and database state, SQL query caching disabled, and logging suppressed. SQLite query-only mode prevented writes during the measurements.
14
+
15
+ The clean request was `/products/42?sort=recent`, with an unbanned, unrestricted IP and two nonmatching whitelist CIDRs. The downstream endpoint returned a trivial HTTP 200 response. Each operation received 2,000 warmup iterations, followed by three timed batches of 10,000 iterations; the larger whitelist cases used batches of 1,000. Garbage collection ran before each batch and remained enabled during timing.
16
+
17
+ | Operation | Approximate average time per operation |
18
+ | --- | ---: |
19
+ | WAF analysis, clean URL, all 55 patterns | 2.4 µs |
20
+ | Whitelist lookup, 2 CIDRs | 1.2 µs |
21
+ | Whitelist lookup, 100 CIDRs, no match | 5.1–5.2 µs |
22
+ | Whitelist lookup, 1,000 CIDRs, no match | 40–41 µs |
23
+ | Ban lookup | 26–29 µs |
24
+ | IP rate-limit preview | 14–16 µs |
25
+ | Complete clean middleware call, 2 CIDRs | 52–59 µs |
26
+ | Device detection for one browser user agent | 7.8 µs |
27
+
28
+ Ranges summarize batch averages across the diagnostic runs, not latency percentiles. The complete clean middleware call allocated approximately 310 Ruby objects, while its isolated WAF analysis allocated one. Instrumentation confirmed two SELECTs per clean middleware call: one for bans and one for IP rate-limit state.
29
+
30
+ These measurements exclude the host application's real work, a full HTTP server stack, concurrent traffic, remote database latency, populated authentication history, and attack-path persistence. They are not production throughput or latency guarantees and do not establish a Rust speedup.
31
+
32
+ Even eliminating the WAF computation entirely would save only about **4–5% of Beskar's measured clean-request overhead** in this setup. Its share of the full host request would be smaller. Large whitelist configurations present a more substantial CPU cost.
33
+
34
+ **Candidates worth considering**
35
+
36
+ | Component | Possible Rust implementation | Assessment |
37
+ | --- | --- | --- |
38
+ | [WAF matcher](../../lib/beskar/services/waf.rb) | Compile the rule set once, match through one native call, and return compact rule IDs | Best initial experiment, particularly as rule counts or input lengths grow; current short clean-path cost is already small |
39
+ | [IP whitelist](../../lib/beskar/services/ip_whitelist.rb) | Compiled IPv4/IPv6 prefix tree, with exact-address lookup where appropriate | Promising for large lists; replacing linear scanning is the principal opportunity in either language |
40
+ | [Device detection](../../lib/beskar/services/device_detector.rb) | Combine classification and extraction in one native operation | Secondary candidate because it mainly affects authentication rather than every ordinary request |
41
+ | [Rate limiting](../../lib/beskar/services/rate_limiter.rb), [bans](../../app/models/beskar/banned_ip.rb), and audit persistence | Translate existing calculations and orchestration | Low priority: database round trips, transactions, and contention remain |
42
+ | Risk arithmetic and geographic distance | Native numeric calculations | Small workloads; optimize history retrieval and repeated enrichment first |
43
+
44
+ Rust's [RegexSet](https://docs.rs/regex/latest/regex/struct.RegexSet.html) can identify matching expressions in one pass, which fits the WAF's need to report matching rules without extracting captures. A performance-only port must preserve all matching rules, result ordering, case handling, encoding behavior, and path/query interpretation. Ruby and Rust regex semantics differ, and Rust's [regex crate](https://docs.rs/regex/latest/regex/) does not support arbitrary look-around or backreferences. Differential tests against the Ruby implementation are necessary; a literal pattern translation is insufficient.
45
+
46
+ For IP lookup, native acceleration would also need to avoid rebuilding configuration or comparing the entire source list on each request. Configuration updates must preserve the existing invalidation contract. Process-local native counters or ban snapshots would require a separate coordination design to preserve cross-worker enforcement correctness.
47
+
48
+ **Distribution without a Rust installation requirement**
49
+
50
+ A Ruby native extension built with [Magnus](https://github.com/matsadler/magnus) and the [rb-sys distribution tooling](https://oxidize-rb.org/docs/deployment/) is a suitable approach. Rust would be a maintainer/CI build dependency; consuming applications would load the compiled library.
51
+
52
+ - Publish platform-specific gems containing release binaries, allowing RubyGems/Bundler to select the applicable artifact.
53
+ - Define and test the supported matrix explicitly: Linux x86-64 and ARM64 with glibc and musl, macOS Intel and Apple Silicon, and Windows if included in Beskar's supported platforms.
54
+ - Cover supported Ruby ABIs as well as OS and CPU architecture. At assessment time, this repository's main CI matrix uses Ruby 3.4 and 4.0.6. Do not assume one extension binary covers all Ruby versions.
55
+ - Account for minimum OS/libc versions and CPU instruction compatibility when building distributable binaries.
56
+ - Keep a pure Ruby gem variant for unsupported combinations, without automatically requiring Rust compilation during installation. Verify its detection behavior matches the native implementation.
57
+ - Test installation and execution of published artifacts in environments without a Rust toolchain.
58
+
59
+ RubyGems supports platform-specific binary gems through the [platform attribute](https://guides.rubygems.org/specification-reference/#platform). Platform support would be an explicit release commitment, not a promise that one universal binary works everywhere.
60
+
61
+ **Keeping request overhead small**
62
+
63
+ Load the extension and compile its rule configuration once at boot. Select the native or Ruby implementation at boot as well. Use one native call per analysis, pass existing strings with minimal copying, and return a compact result. Construct detailed Ruby metadata only when a match needs it. Keep database access and enforcement decisions coordinated through the established authority.
64
+
65
+ The Ruby/native boundary has a cost. Releasing the Ruby GVL, copying strings, or converting nested Ruby structures may cost more than a small calculation saves. The requirement should therefore be a measured net improvement, including allocations and tail latency, rather than a claim of zero overhead.
66
+
67
+ Before implementing Rust, investigate repeated IP parsing and whitelist checks, eager debug-message construction, repeated configuration processing, and database access. Reducing database work must preserve the consistency guarantees introduced by the remediation; reinstating stale process-local enforcement caches would change behavior.
68
+
69
+ If revisited, first establish representative benchmarks for clean requests, already-blocked requests, authentication attempts, large whitelists, long inputs, and WAF violations under concurrency. Measure p50/p95/p99 latency, CPU, allocations, database queries, and lock waits with the intended production backend. Then prototype only the WAF matcher and, if large lists are expected, the CIDR matcher. Retain Rust only if equivalent behavior and meaningful end-to-end gains justify maintaining the binary matrix.
@@ -1,10 +1,22 @@
1
1
  module Beskar
2
2
  class Configuration
3
- attr_accessor :rate_limiting, :security_tracking, :risk_based_locking, :geolocation, :ip_whitelist, :waf, :authentication_models, :emergency_password_reset
3
+ class Error < ArgumentError; end
4
+ SECTIONS = %i[rate_limiting security_tracking risk_based_locking geolocation waf authentication_models emergency_password_reset notifications].freeze
5
+ LOCK_STRATEGIES = %i[devise_lockable rails_auth none].freeze
6
+ GEOLOCATION_PROVIDERS = %i[mock maxmind].freeze
7
+ attr_accessor :rate_limiting, :security_tracking, :risk_based_locking, :geolocation, :ip_whitelist, :waf, :authentication_models, :emergency_password_reset, :notifications, :monitor_only, :authenticate_admin, :audit_actor, :authorize_admin, :authorize_configuration
4
8
 
5
9
  def initialize
10
+ @monitor_only = false # Global monitor-only mode - logs everything but doesn't block
6
11
  @ip_whitelist = [] # Array of IP addresses or CIDR ranges
7
12
 
13
+ # Dashboard authentication - configure this to restrict access to the dashboard
14
+ # Example: config.authenticate_admin = proc { |request| request.env["warden"]&.authenticate(scope: :admin).present? }
15
+ @authenticate_admin = nil
16
+ @audit_actor = nil # Proc returning a stable opaque actor ID; required for dashboard writes
17
+ @authorize_admin = nil # (request, permission), controller context; nil denies every dashboard action
18
+ @authorize_configuration = nil # (opaque_actor), trusted host code; nil denies runtime changes
19
+
8
20
  # Authentication models configuration
9
21
  # Auto-detect by default, or can be explicitly configured
10
22
  @authentication_models = {
@@ -16,21 +28,33 @@ module Beskar
16
28
  @waf = {
17
29
  enabled: false, # Master switch for WAF
18
30
  auto_block: true, # Automatically block IPs after threshold
19
- block_threshold: 3, # Number of violations before blocking
20
- violation_window: 1.hour, # Time window to count violations
21
- block_durations: [ 1.hour, 6.hours, 24.hours, 7.days ], # Escalating block durations
22
- permanent_block_after: 5, # Permanent block after N violations (nil = never)
31
+ score_threshold: 150, # Cumulative risk score before blocking (replaces block_threshold)
32
+ violation_window: 6.hours, # Maximum time window to track violations
33
+ block_durations: [1.hour, 6.hours, 24.hours, 7.days], # Escalating block durations
34
+ permanent_block_after: 500, # Permanent block after cumulative score reaches this (nil = never)
23
35
  create_security_events: true, # Create SecurityEvent records
24
- monitor_only: false # If true, log but don't block (even if auto_block is true)
36
+ exception_detection: :suspicious, # :suspicious, :all (opt-in broad scoring), or :none
37
+ request_exclusions: [], # {path: Regexp, methods: [...], categories: [...]}
38
+ record_not_found_exclusions: [], # Regex patterns to exclude from RecordNotFound detection
39
+ decay_enabled: true, # Enable exponential decay of violation scores over time
40
+ decay_rates: { # Decay rates by severity (half-life in minutes)
41
+ critical: 360, # Critical violations: 6 hour half-life
42
+ high: 120, # High violations: 2 hour half-life
43
+ medium: 45, # Medium violations: 45 minute half-life
44
+ low: 15 # Low violations: 15 minute half-life
45
+ },
46
+ max_violations_tracked: 50 # Maximum number of violations to track per IP (oldest pruned)
25
47
  }
26
48
  @security_tracking = {
27
49
  enabled: true,
28
50
  track_successful_logins: true,
29
51
  track_failed_logins: true,
30
- auto_analyze_patterns: true
52
+ auto_analyze_patterns: false, # No built-in background analyzer. Opt in with a host Active Job.
53
+ analysis_job: nil
31
54
  }
32
55
  @rate_limiting = {
33
56
  ip_attempts: {
57
+ block_requests: false, # Authentication quotas do not block unrelated traffic behind a shared NAT
34
58
  limit: 10,
35
59
  period: 1.hour,
36
60
  exponential_backoff: true
@@ -41,6 +65,7 @@ module Beskar
41
65
  exponential_backoff: true
42
66
  },
43
67
  global_attempts: {
68
+ enabled: false, # Opt-in availability tradeoff: a distributed attacker can consume this shared budget
44
69
  limit: 100,
45
70
  period: 1.minute,
46
71
  exponential_backoff: false
@@ -49,11 +74,11 @@ module Beskar
49
74
  @risk_based_locking = {
50
75
  enabled: false, # Master switch for risk-based locking
51
76
  risk_threshold: 75, # Lock account if risk score >= this value
52
- lock_strategy: :devise_lockable, # Strategy: :devise_lockable, :custom, :none
53
- auto_unlock_time: 1.hour, # Time until automatic unlock (if supported by strategy)
54
- notify_user: true, # Send notification on lock
77
+ lock_strategy: :devise_lockable, # Strategy: :devise_lockable, :rails_auth, :none
78
+ auto_unlock_time: 1.hour, # Native lock duration; nil for manual unlock. Devise owns unlock_in.
79
+ notify_user: false, # Opt-in email; configure notifications first
55
80
  log_lock_events: true, # Create security event for locks
56
- immediate_signout: false # Sign out user immediately via Warden callback (requires :lockable)
81
+ immediate_signout: true # Locked attempts are always denied; retained for compatibility
57
82
  }
58
83
  @geolocation = {
59
84
  provider: :mock, # Provider: :maxmind, :mock
@@ -65,10 +90,66 @@ module Beskar
65
90
  impossible_travel_threshold: 3, # Reset after N impossible travel events in 24h
66
91
  suspicious_device_threshold: 5, # Reset after N suspicious device events in 24h
67
92
  total_locks_threshold: 5, # Reset after N total locks in 24h (any reason)
68
- send_notification: true, # Send email to user about reset
69
- notify_security_team: true, # Alert security team about automatic resets
93
+ send_notification: false, # Opt-in recovery instructions via Action Mailer
94
+ notify_security_team: false, # Opt-in security-team email
70
95
  require_manual_unlock: false # Require manual admin unlock after reset
71
96
  }
97
+ @notifications = {
98
+ from: nil, # One sender mailbox; no example-address fallback
99
+ recovery_url: nil, # HTTPS recovery entry page, not a token-bearing URL
100
+ security_team_recipients: [] # Separate message/job for each configured mailbox
101
+ }
102
+ end
103
+
104
+ # Section assignment overlays library defaults, not the previous section.
105
+ # Nested edits in a configure block instead retain the current settings.
106
+ SECTIONS.each do |section|
107
+ define_method(:"#{section}=") do |value|
108
+ raise Error, "#{section} must be a hash with symbol keys" unless value.is_a?(Hash)
109
+ defaults = Configuration.new.public_send(section)
110
+ instance_variable_set(:"@#{section}", defaults.deep_merge(value.deep_dup))
111
+ end
112
+ end
113
+
114
+ def initialize_copy(other)
115
+ super
116
+ SECTIONS.each { |section| instance_variable_set(:"@#{section}", other.public_send(section).deep_dup) }
117
+ @ip_whitelist = other.ip_whitelist.deep_dup
118
+ end
119
+
120
+ def validate!(resolve_jobs: true)
121
+ ConfigurationValidator.new(self).validate!(resolve_jobs: resolve_jobs)
122
+ self
123
+ end
124
+
125
+ def seal!
126
+ freeze_value = lambda do |value|
127
+ case value
128
+ when Hash
129
+ value.each { |key, item|
130
+ freeze_value.call(key)
131
+ freeze_value.call(item)
132
+ }
133
+ value.freeze
134
+ when Array
135
+ value.each { |item| freeze_value.call(item) }
136
+ value.freeze
137
+ when String, Regexp then value.freeze
138
+ end
139
+ end
140
+ (SECTIONS + [:ip_whitelist]).each { |name| freeze_value.call(public_send(name)) }
141
+ freeze
142
+ end
143
+
144
+ def analysis_job_class
145
+ configured = @security_tracking[:analysis_job]
146
+ job = configured.is_a?(String) ? configured.safe_constantize : configured
147
+ unless job.is_a?(Class) && job < ActiveJob::Base
148
+ raise Error, "security_tracking.analysis_job must identify a host ActiveJob::Base subclass"
149
+ end
150
+ job
151
+ rescue NameError
152
+ raise Error, "security_tracking.analysis_job could not be loaded"
72
153
  end
73
154
 
74
155
  def security_tracking_enabled?
@@ -97,15 +178,17 @@ module Beskar
97
178
  end
98
179
 
99
180
  def lock_strategy
100
- @risk_based_locking[:lock_strategy] || :devise_lockable
181
+ strategy = @risk_based_locking[:lock_strategy]
182
+ raise Error, "risk_based_locking.lock_strategy must be devise_lockable, rails_auth, or none" unless LOCK_STRATEGIES.include?(strategy)
183
+ strategy
101
184
  end
102
185
 
103
186
  def auto_unlock_time
104
- @risk_based_locking[:auto_unlock_time] || 1.hour
187
+ @risk_based_locking.fetch(:auto_unlock_time, 1.hour)
105
188
  end
106
189
 
107
190
  def notify_user_on_lock?
108
- @risk_based_locking[:notify_user] != false
191
+ @risk_based_locking[:notify_user] == true
109
192
  end
110
193
 
111
194
  def log_lock_events?
@@ -135,11 +218,12 @@ module Beskar
135
218
  end
136
219
 
137
220
  def waf_auto_block?
138
- waf_enabled? && @waf[:auto_block] && !@waf[:monitor_only]
221
+ waf_enabled? && @waf[:auto_block] && !@monitor_only
139
222
  end
140
223
 
141
- def waf_monitor_only?
142
- @waf[:monitor_only] == true
224
+ # General monitor-only mode check (affects all blocking)
225
+ def monitor_only?
226
+ @monitor_only == true
143
227
  end
144
228
 
145
229
  # IP Whitelist configuration helpers
@@ -179,7 +263,7 @@ module Beskar
179
263
  end
180
264
  rescue => e
181
265
  # Ignore errors during detection
182
- Rails.logger.debug "[Beskar] Error detecting Rails auth model #{model.name}: #{e.message}"
266
+ Beskar::Logger.debug("Error detecting Rails auth model #{model.name}: #{e.class}")
183
267
  end
184
268
  end
185
269
 
@@ -192,6 +276,7 @@ module Beskar
192
276
  end
193
277
 
194
278
  def model_class_for_scope(scope)
279
+ return Devise.mappings[scope.to_sym].to if defined?(Devise) && scope && Devise.mappings.key?(scope.to_sym)
195
280
  scope.to_s.camelize.constantize
196
281
  rescue NameError
197
282
  nil
@@ -0,0 +1,188 @@
1
+ require "ipaddr"
2
+ require "uri"
3
+ require "mail"
4
+
5
+ module Beskar
6
+ # Validation errors name known setting paths, never supplied values/secrets.
7
+ # Pure configuration checks do not query application models or Rails.cache.
8
+ class ConfigurationValidator
9
+ def initialize(configuration)
10
+ @config = configuration
11
+ end
12
+
13
+ def validate!(resolve_jobs: true)
14
+ defaults = Configuration.new
15
+ Configuration::SECTIONS.each do |section|
16
+ shape(@config.public_send(section), defaults.public_send(section), section.to_s)
17
+ end
18
+ boolean(@config.monitor_only, "monitor_only")
19
+ invalid!("authenticate_admin", "must be a Proc or nil") unless @config.authenticate_admin.nil? || @config.authenticate_admin.is_a?(Proc)
20
+ invalid!("audit_actor", "must be a Proc or nil") unless @config.audit_actor.nil? || @config.audit_actor.is_a?(Proc)
21
+ %i[authorize_admin authorize_configuration].each do |name|
22
+ value = @config.public_send(name)
23
+ invalid!(name.to_s, "must be a Proc or nil") unless value.nil? || value.is_a?(Proc)
24
+ end
25
+ whitelist
26
+ waf
27
+ locking
28
+ geolocation
29
+ auth_models
30
+ analysis_job(resolve_jobs)
31
+ validate_notifications!
32
+ true
33
+ end
34
+
35
+ # Also checked by delivery workers, since low-level configuration is mutable.
36
+ def validate_notifications!
37
+ settings = @config.notifications
38
+ shape(settings, Configuration.new.notifications, "notifications")
39
+ sender = settings[:from]
40
+ invalid!("notifications.from", "must be one email address") unless sender.nil? || self.class.mailbox?(sender)
41
+ recipients = settings[:security_team_recipients]
42
+ unless recipients.size <= 20 && recipients.all? { |item| self.class.mailbox?(item) }
43
+ invalid!("notifications.security_team_recipients", "must contain at most 20 email addresses")
44
+ end
45
+ url = settings[:recovery_url]
46
+ invalid!("notifications.recovery_url", "must be an HTTPS entry-page URL without credentials, query, or fragment") unless url.nil? || self.class.recovery_url?(url)
47
+
48
+ user_delivery = @config.notify_user_on_lock? || @config.emergency_password_reset[:send_notification] == true
49
+ team_delivery = @config.emergency_password_reset[:notify_security_team] == true
50
+ invalid!("notifications.from", "is required when notifications are enabled") if (user_delivery || team_delivery) && sender.nil?
51
+ invalid!("notifications.recovery_url", "is required for user notifications") if user_delivery && url.nil?
52
+ invalid!("notifications.security_team_recipients", "must not be empty when team notifications are enabled") if team_delivery && recipients.empty?
53
+ true
54
+ end
55
+
56
+ def self.mailbox?(value)
57
+ return false unless value.is_a?(String) && value.bytesize <= 254 && value.match?(/\A[^\s<>@,;]+@[^\s<>@,;]+\z/)
58
+ parsed = Mail::Address.new(value)
59
+ parsed.address == value && parsed.domain.present?
60
+ rescue Mail::Field::ParseError
61
+ false
62
+ end
63
+
64
+ def self.recovery_url?(value)
65
+ return false unless value.is_a?(String) && value.bytesize <= 2048 && !value.match?(/[[:cntrl:]]/)
66
+ uri = URI.parse(value)
67
+ uri.is_a?(URI::HTTPS) && uri.host.present? && uri.userinfo.nil? && uri.query.nil? && uri.fragment.nil?
68
+ rescue URI::InvalidURIError
69
+ false
70
+ end
71
+
72
+ private
73
+
74
+ def invalid!(path, message)
75
+ raise Configuration::Error, "#{path} #{message}"
76
+ end
77
+
78
+ def shape(value, defaults, path)
79
+ invalid!(path, "must be a hash with symbol keys") unless value.is_a?(Hash)
80
+ invalid!(path, "contains unknown or non-symbol keys") unless (value.keys - defaults.keys).empty?
81
+ defaults.each do |key, default|
82
+ child = "#{path}.#{key}"
83
+ invalid!(child, "is missing") unless value.key?(key)
84
+ item = value[key]
85
+ case default
86
+ when Hash then shape(item, default, child)
87
+ when TrueClass, FalseClass then boolean(item, child)
88
+ when ActiveSupport::Duration then positive(item, child, optional: key == :auto_unlock_time)
89
+ when Numeric
90
+ if child == "risk_based_locking.risk_threshold"
91
+ invalid!(child, "must be a finite number between 0 and 100") unless number?(item) && (0..100).cover?(item)
92
+ else
93
+ positive(item, child, optional: key == :permanent_block_after)
94
+ if [:limit, :max_violations_tracked, :impossible_travel_threshold, :suspicious_device_threshold, :total_locks_threshold].include?(key)
95
+ invalid!(child, "must be an integer") unless item.is_a?(Integer)
96
+ end
97
+ end
98
+ when Array then invalid!(child, "must be an array") unless item.is_a?(Array)
99
+ end
100
+ end
101
+ end
102
+
103
+ def number?(value)
104
+ value.is_a?(Numeric) && value.real? && value.finite?
105
+ end
106
+
107
+ def positive(value, path, optional: false)
108
+ return if optional && value.nil?
109
+ numeric = value.is_a?(ActiveSupport::Duration) ? value.to_f : value
110
+ invalid!(path, "must be finite and positive#{" or nil" if optional}") unless number?(numeric) && numeric.positive?
111
+ end
112
+
113
+ def boolean(value, path)
114
+ invalid!(path, "must be true or false") unless value == true || value == false
115
+ end
116
+
117
+ def whitelist
118
+ invalid!("ip_whitelist", "must be an array") unless @config.ip_whitelist.is_a?(Array)
119
+ @config.ip_whitelist.each_with_index do |entry, index|
120
+ path = "ip_whitelist[#{index}]"
121
+ invalid!(path, "must be an IP address or CIDR string") unless entry.is_a?(String) && entry.present?
122
+ begin
123
+ IPAddr.new(entry.strip)
124
+ rescue IPAddr::Error
125
+ invalid!(path, "must be a valid IP address or CIDR")
126
+ end
127
+ end
128
+ end
129
+
130
+ def waf
131
+ settings = @config.waf
132
+ invalid!("waf.exception_detection", "must be suspicious, all, or none") unless %i[suspicious all none].include?(settings[:exception_detection])
133
+ invalid!("waf.block_durations", "must not be empty") if settings[:block_durations].empty?
134
+ settings[:block_durations].each_with_index { |value, index| positive(value, "waf.block_durations[#{index}]") }
135
+ regexps(settings[:record_not_found_exclusions], "waf.record_not_found_exclusions")
136
+ categories = Services::Waf::VULNERABILITY_PATTERNS.keys + Services::Waf::EXCEPTION_RULES.values.map(&:first) + [:malformed_path]
137
+ settings[:request_exclusions].each_with_index do |rule, index|
138
+ path = "waf.request_exclusions[#{index}]"
139
+ invalid!(path, "must be a hash with path, methods, and/or categories") unless rule.is_a?(Hash) && (rule.keys - %i[path methods categories]).empty?
140
+ invalid!("#{path}.path", "must be a Regexp") unless rule[:path].is_a?(Regexp)
141
+ if rule.key?(:methods)
142
+ valid = rule[:methods].is_a?(Array) && rule[:methods].all? do |item|
143
+ (item.is_a?(String) || item.is_a?(Symbol)) && %w[GET HEAD POST PUT PATCH DELETE OPTIONS CONNECT TRACE OTHER].include?(item.to_s.upcase)
144
+ end
145
+ invalid!("#{path}.methods", "must contain HTTP method names") unless valid
146
+ end
147
+ if rule.key?(:categories)
148
+ invalid!("#{path}.categories", "must contain known WAF categories") unless rule[:categories].is_a?(Array) && rule[:categories].all? { |item| categories.any? { |category| category.to_s == item.to_s } }
149
+ end
150
+ end
151
+ end
152
+
153
+ def regexps(values, path)
154
+ invalid!(path, "must contain only Regexp entries") unless values.all? { |value| value.is_a?(Regexp) }
155
+ end
156
+
157
+ def locking
158
+ @config.lock_strategy
159
+ end
160
+
161
+ def geolocation
162
+ settings = @config.geolocation
163
+ invalid!("geolocation.provider", "must be mock or maxmind") unless Configuration::GEOLOCATION_PROVIDERS.include?(settings[:provider])
164
+ path = settings[:maxmind_city_db_path]
165
+ invalid!("geolocation.maxmind_city_db_path", "must be a path string or nil") unless path.nil? || path.is_a?(String)
166
+ if settings[:provider] == :maxmind
167
+ readable = path.present? && !path.include?("\0") && File.file?(path) && File.readable?(path)
168
+ invalid!("geolocation.maxmind_city_db_path", "must identify a readable database file") unless readable
169
+ end
170
+ end
171
+
172
+ def auth_models
173
+ %i[devise rails_auth].each do |kind|
174
+ names = @config.authentication_models[kind]
175
+ invalid!("authentication_models.#{kind}", "must contain scope names") unless names.all? { |name| (name.is_a?(String) || name.is_a?(Symbol)) && name.to_s.match?(/\A[a-zA-Z]\w*(?:(?:::|\/)\w+)*\z/) }
176
+ end
177
+ end
178
+
179
+ def analysis_job(resolve)
180
+ job = @config.security_tracking[:analysis_job]
181
+ valid = job.nil? || job.is_a?(Class) || (job.is_a?(String) && job.match?(/\A[A-Z]\w*(?:::[A-Z]\w*)*\z/))
182
+ invalid!("security_tracking.analysis_job", "must be a job class, class name, or nil") unless valid
183
+ return unless @config.auto_analyze_patterns?
184
+ invalid!("security_tracking.analysis_job", "is required when automatic analysis is enabled") unless job
185
+ @config.analysis_job_class if resolve || job.is_a?(Class)
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,24 @@
1
+ module Beskar
2
+ # Only the password strategy is intercepted. Warden's before_failure callback
3
+ # also runs for protected-page visits without credentials, so it cannot safely
4
+ # serve as an admission hook.
5
+ module DeviseAuthentication
6
+ def authenticate!
7
+ model = mapping.to
8
+ return super unless model.respond_to?(:track_failed_authentication)
9
+
10
+ resource = model.find_for_database_authentication(authentication_hash)
11
+ attempt = Services::AuthenticationAttempt.reserve(request, model: model, scope: scope,
12
+ user: resource, credentials: authentication_hash, cache: true)
13
+ resource ? attempt.bind_user!(resource) : attempt.bind_identity!(model, authentication_hash)
14
+ unless attempt.allowed?
15
+ model.track_failed_authentication(request, scope, attempt: attempt)
16
+ return custom!(attempt.response)
17
+ end
18
+ super
19
+ rescue Services::AuthenticationAttempt::Unavailable, ActiveRecord::ActiveRecordError => error
20
+ Beskar::Logger.error("Authentication admission unavailable (#{error.class})")
21
+ custom!(Services::AuthenticationAttempt.unavailable_response)
22
+ end
23
+ end
24
+ end