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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +274 -0
- data/README.md +412 -204
- data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
- data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
- data/app/controllers/beskar/application_controller.rb +214 -0
- data/app/controllers/beskar/banned_ips_controller.rb +255 -0
- data/app/controllers/beskar/dashboard_controller.rb +62 -0
- data/app/controllers/beskar/security_events_controller.rb +164 -0
- data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
- data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
- data/app/jobs/beskar/notification_job.rb +33 -0
- data/app/mailers/beskar/security_mailer.rb +59 -0
- data/app/models/beskar/administrative_action.rb +41 -0
- data/app/models/beskar/banned_ip.rb +105 -105
- data/app/models/beskar/security_event.rb +51 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +88 -0
- data/app/views/beskar/administrative_actions/index.html.erb +33 -0
- data/app/views/beskar/administrative_actions/show.html.erb +21 -0
- data/app/views/beskar/banned_ips/edit.html.erb +195 -0
- data/app/views/beskar/banned_ips/index.html.erb +319 -0
- data/app/views/beskar/banned_ips/new.html.erb +190 -0
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +304 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +302 -0
- data/app/views/beskar/security_events/show.html.erb +293 -0
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/layouts/beskar/_behavior.html.erb +121 -0
- data/app/views/layouts/beskar/application.html.erb +581 -6
- data/config/routes.rb +30 -0
- data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
- data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
- data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
- data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
- data/docs/README.md +73 -0
- data/docs/archive/project-documentation.md +659 -0
- data/docs/audits/project-review.md +437 -0
- data/docs/audits/repair-status.md +216 -0
- data/docs/guides/audit-and-waf.md +175 -0
- data/docs/guides/audit-lifecycle.md +172 -0
- data/docs/guides/authentication.md +213 -0
- data/docs/guides/configuration.md +182 -0
- data/docs/guides/dashboard-and-search.md +251 -0
- data/docs/guides/notifications-and-recovery.md +157 -0
- data/docs/guides/risk-scoring.md +116 -0
- data/docs/operations/monitor-only-mode.md +85 -0
- data/docs/operations/security-hardening.md +167 -0
- data/docs/operations/state-storage.md +144 -0
- data/docs/research/rust-performance-assessment.md +69 -0
- data/lib/beskar/configuration.rb +105 -20
- data/lib/beskar/configuration_validator.rb +188 -0
- data/lib/beskar/devise_authentication.rb +24 -0
- data/lib/beskar/engine.rb +21 -88
- data/lib/beskar/logger.rb +288 -0
- data/lib/beskar/middleware/request_analyzer.rb +133 -99
- data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
- data/lib/beskar/models/security_trackable_devise.rb +34 -25
- data/lib/beskar/models/security_trackable_generic.rb +171 -214
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +90 -81
- data/lib/beskar/services/administrative_audit.rb +36 -0
- data/lib/beskar/services/administrative_bans.rb +104 -0
- data/lib/beskar/services/audit_data.rb +72 -0
- data/lib/beskar/services/authentication.rb +31 -0
- data/lib/beskar/services/authentication_attempt.rb +141 -0
- data/lib/beskar/services/ban_expiry.rb +28 -0
- data/lib/beskar/services/device_detector.rb +32 -41
- data/lib/beskar/services/event_search.rb +58 -0
- data/lib/beskar/services/geolocation_service.rb +83 -114
- data/lib/beskar/services/ip_whitelist.rb +31 -40
- data/lib/beskar/services/location_assessment.rb +109 -0
- data/lib/beskar/services/native_account_lock.rb +82 -0
- data/lib/beskar/services/notifications.rb +46 -0
- data/lib/beskar/services/rate_limiter.rb +99 -125
- data/lib/beskar/services/request_context.rb +64 -0
- data/lib/beskar/services/risk_assessment.rb +58 -0
- data/lib/beskar/services/session_revocation.rb +62 -0
- data/lib/beskar/services/waf.rb +311 -198
- data/lib/beskar/services/waf_request.rb +60 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar/warden_authentication.rb +53 -0
- data/lib/beskar.rb +54 -4
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
- data/lib/tasks/beskar_tasks.rake +25 -20
- metadata +93 -12
- 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.
|
data/lib/beskar/configuration.rb
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
module Beskar
|
|
2
2
|
class Configuration
|
|
3
|
-
|
|
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
|
-
|
|
20
|
-
violation_window:
|
|
21
|
-
block_durations: [
|
|
22
|
-
permanent_block_after:
|
|
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
|
-
|
|
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:
|
|
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, :
|
|
53
|
-
auto_unlock_time: 1.hour, #
|
|
54
|
-
notify_user:
|
|
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:
|
|
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:
|
|
69
|
-
notify_security_team:
|
|
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]
|
|
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
|
|
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]
|
|
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] && !@
|
|
221
|
+
waf_enabled? && @waf[:auto_block] && !@monitor_only
|
|
139
222
|
end
|
|
140
223
|
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|