beskar 0.1.0 → 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 +150 -19
- data/README.md +142 -122
- 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 +69 -25
- data/app/controllers/beskar/banned_ips_controller.rb +116 -141
- data/app/controllers/beskar/dashboard_controller.rb +20 -28
- data/app/controllers/beskar/security_events_controller.rb +37 -55
- 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 +91 -132
- data/app/models/beskar/security_event.rb +37 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +16 -6
- 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 +25 -89
- data/app/views/beskar/banned_ips/index.html.erb +20 -62
- data/app/views/beskar/banned_ips/new.html.erb +18 -138
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +9 -15
- data/app/views/beskar/dashboard/index.html.erb +4 -4
- data/app/views/beskar/security_events/index.html.erb +8 -15
- data/app/views/beskar/security_events/show.html.erb +6 -20
- 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 +9 -76
- data/config/routes.rb +10 -21
- 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 +84 -13
- 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 +30 -35
- data/lib/beskar/middleware/request_analyzer.rb +41 -82
- data/lib/beskar/models/security_trackable_authenticable.rb +72 -93
- data/lib/beskar/models/security_trackable_devise.rb +32 -23
- data/lib/beskar/models/security_trackable_generic.rb +169 -212
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +85 -76
- 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 +164 -280
- 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 +53 -4
- data/lib/generators/beskar/install/install_generator.rb +36 -36
- data/lib/generators/beskar/install/templates/initializer.rb.tt +104 -20
- data/lib/tasks/beskar_tasks.rake +15 -19
- metadata +60 -8
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Beskar
|
|
4
|
+
module Services
|
|
5
|
+
# Matching input only. Never copy these raw/canonical paths or query values
|
|
6
|
+
# into audit records, state, or logs; those contain rule identifiers instead.
|
|
7
|
+
class WafRequest
|
|
8
|
+
MAX_BYTES = 8192
|
|
9
|
+
attr_reader :path, :problem, :method, :decoding_passes
|
|
10
|
+
|
|
11
|
+
def initialize(request)
|
|
12
|
+
@request = request
|
|
13
|
+
@method = request.respond_to?(:request_method) ? request.request_method.to_s.upcase : "GET"
|
|
14
|
+
@method = "OTHER" unless %w[GET HEAD POST PUT PATCH DELETE OPTIONS CONNECT TRACE].include?(@method)
|
|
15
|
+
raw = request.path.to_s
|
|
16
|
+
@decoding_passes = 0
|
|
17
|
+
if raw.bytesize > MAX_BYTES
|
|
18
|
+
@path, @problem = "", :oversized_path
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
@path = raw.b
|
|
22
|
+
@problem = :invalid_encoding if @path.match?(/%(?![0-9a-f]{2})/i)
|
|
23
|
+
2.times do
|
|
24
|
+
break unless @path.match?(/%[0-9a-f]{2}/i)
|
|
25
|
+
@path = @path.gsub(/%([0-9a-f]{2})/i) { [$1.to_i(16)].pack("C") }
|
|
26
|
+
@decoding_passes += 1
|
|
27
|
+
end
|
|
28
|
+
@problem ||= :excessive_encoding if @path.match?(/%[0-9a-f]{2}/i)
|
|
29
|
+
@path = @path.tr("\\", "/").force_encoding(Encoding::UTF_8)
|
|
30
|
+
if !@path.valid_encoding? || @path.match?(/[[:cntrl:]]/)
|
|
31
|
+
@problem = :invalid_encoding
|
|
32
|
+
@path = ""
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def suspicious_format?
|
|
37
|
+
return false unless @request.respond_to?(:query_string)
|
|
38
|
+
query = @request.query_string.to_s
|
|
39
|
+
return false if query.bytesize > MAX_BYTES
|
|
40
|
+
URI.decode_www_form(query).any? do |key, value|
|
|
41
|
+
key == "format" && value.match?(/\A(?:exe|bat|cmd|com|scr|vbs|jar|asp|aspx|jsp|php)\z/i)
|
|
42
|
+
end
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def excluded?(category)
|
|
48
|
+
Array((Beskar.configuration.waf || {})[:request_exclusions]).any? do |rule|
|
|
49
|
+
next false unless rule.is_a?(Hash)
|
|
50
|
+
rule = rule.symbolize_keys
|
|
51
|
+
next false unless rule[:path].is_a?(Regexp)
|
|
52
|
+
methods = Array(rule[:methods]).map { |value| value.to_s.upcase }
|
|
53
|
+
categories = Array(rule[:categories]).map(&:to_s)
|
|
54
|
+
(methods.empty? || methods.include?(method)) &&
|
|
55
|
+
(categories.empty? || categories.include?(category.to_s)) && rule[:path].match?(path)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/beskar/version.rb
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
# Wrap strategy execution, not before_failure (which also runs on anonymous
|
|
3
|
+
# page visits). Database passwords retain their identity-aware admission hook.
|
|
4
|
+
module WardenStrategyAdmission
|
|
5
|
+
def _run!
|
|
6
|
+
return super if defined?(Devise::Strategies::DatabaseAuthenticatable) && is_a?(Devise::Strategies::DatabaseAuthenticatable)
|
|
7
|
+
model = Beskar.configuration.model_class_for_scope(scope)
|
|
8
|
+
return super unless model&.respond_to?(:track_failed_authentication)
|
|
9
|
+
|
|
10
|
+
request = ActionDispatch::Request.new(env)
|
|
11
|
+
attempt = Services::AuthenticationAttempt.reserve(request, model: model, scope: scope, cache: true)
|
|
12
|
+
unless attempt.allowed?
|
|
13
|
+
model.track_failed_authentication(request, scope, attempt: attempt)
|
|
14
|
+
custom!(attempt.response)
|
|
15
|
+
return self
|
|
16
|
+
end
|
|
17
|
+
super
|
|
18
|
+
rescue Services::AuthenticationAttempt::Unavailable, ActiveRecord::ActiveRecordError
|
|
19
|
+
custom!(Services::AuthenticationAttempt.unavailable_response)
|
|
20
|
+
self
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Enforce even if a host uses set_user(..., run_callbacks: false). Includes
|
|
25
|
+
# OAuth sign_in, non-password strategies, stateless Warden and session fetch.
|
|
26
|
+
module WardenSessionAdmission
|
|
27
|
+
def set_user(user, opts = {})
|
|
28
|
+
return super unless user.respond_to?(:track_authentication_event)
|
|
29
|
+
scope = opts[:scope] || config.default_scope
|
|
30
|
+
request = ActionDispatch::Request.new(env)
|
|
31
|
+
if opts[:event] == :fetch
|
|
32
|
+
allowed = Services::SessionRevocation.allowed?(user, request: request,
|
|
33
|
+
token: Services::SessionRevocation.token(user))
|
|
34
|
+
else
|
|
35
|
+
attempt = Services::AuthenticationAttempt.current(request, scope)
|
|
36
|
+
attempt ||= Services::AuthenticationAttempt.reserve(request, model: user.class,
|
|
37
|
+
scope: scope, user: user, cache: true)
|
|
38
|
+
user.track_authentication_event(request, :success, attempt: attempt, persist: false)
|
|
39
|
+
attempt.verify_generation!
|
|
40
|
+
attempt.persist_outcome!
|
|
41
|
+
allowed = attempt.allowed?
|
|
42
|
+
end
|
|
43
|
+
unless allowed
|
|
44
|
+
logout(scope)
|
|
45
|
+
throw :warden, scope: scope, message: :authentication_denied
|
|
46
|
+
end
|
|
47
|
+
super
|
|
48
|
+
rescue Services::AuthenticationAttempt::Unavailable, ActiveRecord::ActiveRecordError
|
|
49
|
+
logout(scope)
|
|
50
|
+
raise Services::AuthenticationAttempt::Unavailable, "Authentication temporarily unavailable"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
data/lib/beskar.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
require "beskar/version"
|
|
2
2
|
require "beskar/configuration"
|
|
3
|
+
require "beskar/configuration_validator"
|
|
3
4
|
require "beskar/logger"
|
|
5
|
+
require "beskar/risk_level"
|
|
4
6
|
require "beskar/middleware"
|
|
5
7
|
require "beskar/middleware/request_analyzer"
|
|
6
8
|
require "beskar/models/security_trackable_generic"
|
|
@@ -10,19 +12,66 @@ require "beskar/models/security_trackable"
|
|
|
10
12
|
require "beskar/services/rate_limiter"
|
|
11
13
|
require "beskar/services/device_detector"
|
|
12
14
|
require "beskar/services/geolocation_service"
|
|
15
|
+
require "beskar/services/location_assessment"
|
|
16
|
+
require "beskar/services/risk_assessment"
|
|
13
17
|
require "beskar/services/account_locker"
|
|
14
18
|
require "beskar/services/ip_whitelist"
|
|
15
19
|
require "beskar/services/waf"
|
|
20
|
+
require "beskar/services/waf_request"
|
|
21
|
+
require "beskar/services/audit_data"
|
|
22
|
+
require "beskar/services/event_search"
|
|
23
|
+
require "beskar/services/request_context"
|
|
24
|
+
require "beskar/services/authentication_attempt"
|
|
25
|
+
require "beskar/services/authentication"
|
|
26
|
+
require "beskar/services/native_account_lock"
|
|
27
|
+
require "beskar/services/session_revocation"
|
|
28
|
+
require "beskar/services/notifications"
|
|
29
|
+
require "beskar/services/administrative_bans"
|
|
30
|
+
require "beskar/services/administrative_audit"
|
|
31
|
+
require "beskar/services/ban_expiry"
|
|
32
|
+
require "beskar/devise_authentication"
|
|
33
|
+
require "beskar/warden_authentication"
|
|
16
34
|
require "beskar/engine"
|
|
17
35
|
|
|
18
36
|
module Beskar
|
|
19
37
|
class << self
|
|
20
|
-
|
|
38
|
+
def configuration=(value)
|
|
39
|
+
raise Configuration::Error, "Use audited Beskar.configure for runtime changes" if configuration.frozen?
|
|
40
|
+
@configuration = value
|
|
41
|
+
end
|
|
21
42
|
end
|
|
22
43
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
44
|
+
CONFIGURATION_MUTEX = Mutex.new
|
|
45
|
+
|
|
46
|
+
def self.configure(actor: nil, reason: nil, request_id: nil)
|
|
47
|
+
CONFIGURATION_MUTEX.synchronize do
|
|
48
|
+
original = configuration
|
|
49
|
+
if original.frozen?
|
|
50
|
+
allowed = original.authorize_configuration&.call(actor) == true
|
|
51
|
+
raise Configuration::Error, "Runtime configuration change is not authorized" unless allowed
|
|
52
|
+
raise Configuration::Error, "Runtime configuration cannot run in a database transaction" if AdministrativeAction.connection.transaction_open?
|
|
53
|
+
end
|
|
54
|
+
candidate = original.dup
|
|
55
|
+
yield(candidate)
|
|
56
|
+
candidate.validate!(resolve_jobs: !!Rails.application&.initialized?)
|
|
57
|
+
published = candidate.dup
|
|
58
|
+
if original.frozen?
|
|
59
|
+
published.seal!
|
|
60
|
+
Services::AdministrativeAudit.record!(actor: actor, reason: reason, request_id: request_id,
|
|
61
|
+
action: "configuration_changed", target_type: "Configuration",
|
|
62
|
+
before_state: Services::AdministrativeAudit.configuration_snapshot(original),
|
|
63
|
+
after_state: Services::AdministrativeAudit.configuration_snapshot(published).merge(
|
|
64
|
+
"changed_settings" => original.instance_variables.filter_map do |name|
|
|
65
|
+
name.to_s.delete_prefix("@") unless original.instance_variable_get(name) == published.instance_variable_get(name)
|
|
66
|
+
end, "process_id" => Process.pid
|
|
67
|
+
))
|
|
68
|
+
end
|
|
69
|
+
@configuration = published
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.configuration
|
|
74
|
+
@configuration ||= Configuration.new
|
|
26
75
|
end
|
|
27
76
|
|
|
28
77
|
# Convenience method to access the rate limiter
|
|
@@ -1,25 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record/migration"
|
|
5
5
|
|
|
6
6
|
module Beskar
|
|
7
7
|
module Generators
|
|
8
8
|
class InstallGenerator < Rails::Generators::Base
|
|
9
|
-
include
|
|
9
|
+
include ActiveRecord::Generators::Migration
|
|
10
10
|
|
|
11
|
-
source_root File.expand_path(
|
|
11
|
+
source_root File.expand_path("templates", __dir__)
|
|
12
12
|
|
|
13
|
-
desc "Creates a Beskar initializer and
|
|
14
|
-
|
|
15
|
-
def self.next_migration_number(path)
|
|
16
|
-
if @prev_migration_nr
|
|
17
|
-
@prev_migration_nr += 1
|
|
18
|
-
else
|
|
19
|
-
@prev_migration_nr = Time.now.utc.strftime("%Y%m%d%H%M%S").to_i
|
|
20
|
-
end
|
|
21
|
-
@prev_migration_nr.to_s
|
|
22
|
-
end
|
|
13
|
+
desc "Creates a Beskar initializer, mounts the dashboard, and copies migrations"
|
|
23
14
|
|
|
24
15
|
def copy_initializer
|
|
25
16
|
template "initializer.rb.tt", "config/initializers/beskar.rb"
|
|
@@ -29,7 +20,10 @@ module Beskar
|
|
|
29
20
|
route_text = "mount Beskar::Engine => '/beskar'"
|
|
30
21
|
|
|
31
22
|
# Check if the route already exists
|
|
32
|
-
|
|
23
|
+
routes_path = File.join(destination_root, "config/routes.rb")
|
|
24
|
+
if !File.exist?(routes_path)
|
|
25
|
+
say "No config/routes.rb found; mount Beskar::Engine manually at /beskar.", :yellow
|
|
26
|
+
elsif File.read(routes_path).match?(/\bmount\s+Beskar::Engine\b/)
|
|
33
27
|
say "Route already mounted, skipping...", :yellow
|
|
34
28
|
else
|
|
35
29
|
route route_text
|
|
@@ -39,11 +33,11 @@ module Beskar
|
|
|
39
33
|
|
|
40
34
|
def copy_migrations
|
|
41
35
|
# Copy migrations from the engine to the host app
|
|
42
|
-
migration_source = File.expand_path("
|
|
36
|
+
migration_source = File.expand_path("../../../../db/migrate", __dir__)
|
|
43
37
|
|
|
44
38
|
if Dir.exist?(migration_source)
|
|
45
39
|
Dir.glob("#{migration_source}/*.rb").each do |migration|
|
|
46
|
-
migration_name = File.basename(migration).sub(/^\d+_/,
|
|
40
|
+
migration_name = File.basename(migration).sub(/^\d+_/, "")
|
|
47
41
|
|
|
48
42
|
# Check if migration already exists
|
|
49
43
|
if migration_already_exists?(migration_name)
|
|
@@ -76,22 +70,28 @@ module Beskar
|
|
|
76
70
|
|
|
77
71
|
2. Configure authentication for the dashboard in config/initializers/beskar.rb
|
|
78
72
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
end
|
|
73
|
+
Uncomment one complete example in the generated initializer:
|
|
74
|
+
- Rails built-in authentication: signed session_id cookie and Session model.
|
|
75
|
+
- Devise: Warden authentication with your app's Devise mapping scope.
|
|
83
76
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
77
|
+
Each example includes authenticate_admin, authorize_admin, and audit_actor.
|
|
78
|
+
Adapt admin? and beskar_permissions to your application's roles/policies.
|
|
79
|
+
Permissions: read, manage_bans, export, read_audit. Missing grants deny access.
|
|
80
|
+
Beskar's controller does not inherit your host authentication helpers.
|
|
81
|
+
See docs/guides/authentication.md and docs/guides/audit-lifecycle.md.
|
|
82
|
+
Without audit_actor, separately authorized reads work but writes/exports return 503.
|
|
88
83
|
|
|
89
84
|
3. Add Beskar concerns to your User model (or authentication model):
|
|
90
85
|
|
|
86
|
+
For Rails built-in authentication:
|
|
91
87
|
class User < ApplicationRecord
|
|
92
|
-
include Beskar::Models::
|
|
88
|
+
include Beskar::Models::SecurityTrackableAuthenticable
|
|
89
|
+
end
|
|
90
|
+
Also integrate login admission and session guards from docs/guides/authentication.md.
|
|
93
91
|
|
|
94
|
-
|
|
92
|
+
For Devise:
|
|
93
|
+
class User < ApplicationRecord
|
|
94
|
+
include Beskar::Models::SecurityTrackable
|
|
95
95
|
include Beskar::Models::SecurityTrackableDevise
|
|
96
96
|
end
|
|
97
97
|
|
|
@@ -109,8 +109,9 @@ module Beskar
|
|
|
109
109
|
📚 Documentation
|
|
110
110
|
===============================================================================
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
Documentation: https://github.com/humadroid-io/beskar/blob/master/docs/README.md
|
|
113
|
+
Dashboard Guide: https://github.com/humadroid-io/beskar/blob/master/docs/guides/dashboard-and-search.md
|
|
114
|
+
Configuration: https://github.com/humadroid-io/beskar/blob/master/docs/guides/configuration.md
|
|
114
115
|
|
|
115
116
|
===============================================================================
|
|
116
117
|
⚠️ Important for Production
|
|
@@ -119,10 +120,8 @@ module Beskar
|
|
|
119
120
|
1. ALWAYS configure authentication for the dashboard
|
|
120
121
|
2. Set monitor_only = false when ready to block threats
|
|
121
122
|
3. Configure your IP whitelist to prevent locking yourself out
|
|
122
|
-
4.
|
|
123
|
-
|
|
124
|
-
$ rails generate beskar:indexes
|
|
125
|
-
$ rails db:migrate
|
|
123
|
+
4. Run the copied migrations; required indexes are included.
|
|
124
|
+
5. Schedule Beskar::SecurityState.cleanup_expired! to reclaim expired state.
|
|
126
125
|
|
|
127
126
|
===============================================================================
|
|
128
127
|
💡 Quick Tips
|
|
@@ -144,12 +143,13 @@ module Beskar
|
|
|
144
143
|
private
|
|
145
144
|
|
|
146
145
|
def migration_already_exists?(migration_name)
|
|
147
|
-
|
|
146
|
+
basename = migration_name.delete_suffix(".rb")
|
|
147
|
+
Dir.glob(File.join(destination_root, "db/migrate/*_#{basename}{,.beskar}.rb")).any?
|
|
148
148
|
end
|
|
149
149
|
|
|
150
150
|
def migration_template(source, destination)
|
|
151
|
-
migration_number = self.class.next_migration_number(
|
|
152
|
-
file_name = "#{migration_number}_#{destination}"
|
|
151
|
+
migration_number = self.class.next_migration_number(File.join(destination_root, File.dirname(destination)))
|
|
152
|
+
file_name = File.join(File.dirname(destination), "#{migration_number}_#{File.basename(destination)}")
|
|
153
153
|
|
|
154
154
|
copy_file source, file_name
|
|
155
155
|
end
|
|
@@ -12,51 +12,108 @@ Beskar.configure do |config|
|
|
|
12
12
|
# (cookies, session, authenticate_or_request_with_http_basic, etc.).
|
|
13
13
|
# The block should return truthy value to allow access, falsey to deny.
|
|
14
14
|
|
|
15
|
-
#
|
|
15
|
+
# Choose one authentication option below. Options 1 and 2 include all three
|
|
16
|
+
# callbacks: authentication, permissions, and the operator recorded in audits.
|
|
17
|
+
# admin? and beskar_permissions are application-defined methods, not Rails or
|
|
18
|
+
# Devise defaults. Adapt them to your roles/policies. Permission names are:
|
|
19
|
+
# read, manage_bans, export, read_audit (beskar_permissions returns strings).
|
|
20
|
+
#
|
|
21
|
+
# Option 1: Rails built-in authentication (Rails 8 authentication generator)
|
|
22
|
+
# Beskar does not inherit your ApplicationController or Authentication concern,
|
|
23
|
+
# so Current.session/current_user may not be populated on dashboard requests.
|
|
24
|
+
# Resolve the host's Session from its signed cookie instead. Adapt the model
|
|
25
|
+
# and cookie name if you customized Rails' generated authentication.
|
|
26
|
+
# First add this to app/models/user.rb (not inside this initializer):
|
|
27
|
+
# include Beskar::Models::SecurityTrackableAuthenticable
|
|
28
|
+
# This supplies beskar_access_allowed? for the revocation guard below.
|
|
29
|
+
# Beskar::Models::SecurityTrackable is Devise-specific and is not a substitute;
|
|
30
|
+
# without the native concern, the guard raises NoMethodError on the user.
|
|
31
|
+
# See docs/guides/authentication.md for the full integration.
|
|
32
|
+
# config.authenticate_admin = ->(request) do
|
|
33
|
+
# @beskar_admin_user = nil
|
|
34
|
+
# session_id = cookies.signed[:session_id]
|
|
35
|
+
# auth_session = ::Session.find_by(id: session_id) if session_id
|
|
36
|
+
# if Beskar::Services::SessionRevocation.native_session_allowed?(auth_session, request: request)
|
|
37
|
+
# @beskar_admin_user = auth_session.user
|
|
38
|
+
# end
|
|
39
|
+
# @beskar_admin_user&.admin?
|
|
40
|
+
# end
|
|
41
|
+
# config.authorize_admin = ->(_request, permission) do
|
|
42
|
+
# @beskar_admin_user&.admin? && @beskar_admin_user.beskar_permissions.include?(permission.to_s)
|
|
43
|
+
# end
|
|
44
|
+
# config.audit_actor = ->(_request) do
|
|
45
|
+
# "User:#{@beskar_admin_user.id}" if @beskar_admin_user&.admin?
|
|
46
|
+
# end
|
|
47
|
+
|
|
48
|
+
# Option 2: Devise (Warden-backed; does not use the Rails Session model)
|
|
49
|
+
# :user is the Devise mapping scope, not a role. Change it to :admin only if
|
|
50
|
+
# your app has a separate Devise Admin model/mapping.
|
|
51
|
+
# authenticate resumes the Warden session or tries configured strategies
|
|
52
|
+
# (including remember-me); user reads the resulting authenticated identity.
|
|
16
53
|
# config.authenticate_admin = ->(request) do
|
|
17
54
|
# user = request.env['warden']&.authenticate(scope: :user)
|
|
18
55
|
# user&.admin?
|
|
19
56
|
# end
|
|
57
|
+
# config.authorize_admin = ->(request, permission) do
|
|
58
|
+
# user = request.env['warden']&.user(scope: :user)
|
|
59
|
+
# user&.admin? && user.beskar_permissions.include?(permission.to_s)
|
|
60
|
+
# end
|
|
61
|
+
# config.audit_actor = ->(request) do
|
|
62
|
+
# user = request.env['warden']&.user(scope: :user)
|
|
63
|
+
# "User:#{user.id}" if user&.admin?
|
|
64
|
+
# end
|
|
20
65
|
|
|
21
|
-
# Option
|
|
66
|
+
# Option 3: HTTP Basic Authentication (uses controller method)
|
|
22
67
|
# config.authenticate_admin = ->(request) do
|
|
23
68
|
# authenticate_or_request_with_http_basic("Beskar Admin") do |username, password|
|
|
24
|
-
# username
|
|
25
|
-
#
|
|
69
|
+
# Beskar::Services::RequestContext.secure_match?(username, ENV['BESKAR_ADMIN_USERNAME']) &&
|
|
70
|
+
# Beskar::Services::RequestContext.secure_match?(password, ENV['BESKAR_ADMIN_PASSWORD'])
|
|
26
71
|
# end
|
|
27
72
|
# end
|
|
28
73
|
|
|
29
|
-
# Option
|
|
74
|
+
# Option 4: Token-based authentication
|
|
30
75
|
# config.authenticate_admin = ->(request) do
|
|
31
|
-
#
|
|
76
|
+
# token = ENV['BESKAR_ADMIN_TOKEN']
|
|
77
|
+
# token.present? && Beskar::Services::RequestContext.secure_match?(request.headers['Authorization'], "Bearer #{token}")
|
|
32
78
|
# end
|
|
33
79
|
|
|
34
|
-
# Option
|
|
80
|
+
# Option 5: Cookie-based authentication (uses controller cookies)
|
|
35
81
|
# config.authenticate_admin = ->(request) do
|
|
36
|
-
# cookies.signed[:admin_token]
|
|
82
|
+
# Beskar::Services::RequestContext.secure_match?(cookies.signed[:admin_token], ENV['BESKAR_ADMIN_TOKEN'])
|
|
37
83
|
# end
|
|
38
84
|
|
|
39
|
-
# Option
|
|
85
|
+
# Option 6: Using CanCanCan (requires policy helpers on Beskar's controller)
|
|
40
86
|
# config.authenticate_admin = ->(request) do
|
|
41
87
|
# authorize! :manage, :beskar_dashboard
|
|
42
88
|
# end
|
|
43
89
|
|
|
44
|
-
# Option
|
|
90
|
+
# Option 7: Using Pundit (requires policy helpers on Beskar's controller)
|
|
45
91
|
# config.authenticate_admin = ->(request) do
|
|
46
92
|
# authorize :beskar_dashboard, :access?
|
|
47
93
|
# end
|
|
48
94
|
|
|
49
|
-
# Option
|
|
95
|
+
# Option 8: For development/testing ONLY (NOT for production!)
|
|
50
96
|
# config.authenticate_admin = ->(request) do
|
|
51
97
|
# Rails.env.development? || Rails.env.test?
|
|
52
98
|
# end
|
|
53
99
|
|
|
100
|
+
# REQUIRED: grant permissions separately from authentication (nil denies all).
|
|
101
|
+
# Options 1 and 2 show complete examples. For other options, supply your own
|
|
102
|
+
# authorize_admin callback returning true for each permitted operation.
|
|
103
|
+
|
|
104
|
+
# REQUIRED FOR DASHBOARD WRITES AND EXPORTS: resolve an authenticated operator.
|
|
105
|
+
# Use the same trusted identity used by authenticate_admin (as above).
|
|
106
|
+
# Never derive this from request params, emails, passwords, or tokens.
|
|
107
|
+
# Without audit_actor, separately authorized reads work; writes/exports return 503.
|
|
108
|
+
# Forms require a reason and changes require transactional history.
|
|
109
|
+
# Apply the administrative-action migration; see docs/guides/audit-lifecycle.md.
|
|
110
|
+
|
|
54
111
|
# ============================================================================
|
|
55
112
|
# MONITOR-ONLY MODE
|
|
56
113
|
# ============================================================================
|
|
57
114
|
# When enabled, Beskar will log all security events but won't actually block
|
|
58
115
|
# any requests. Useful for testing or initial deployment.
|
|
59
|
-
config.monitor_only =
|
|
116
|
+
config.monitor_only = true
|
|
60
117
|
|
|
61
118
|
# ============================================================================
|
|
62
119
|
# IP WHITELIST
|
|
@@ -71,7 +128,7 @@ Beskar.configure do |config|
|
|
|
71
128
|
# ============================================================================
|
|
72
129
|
# WAF (WEB APPLICATION FIREWALL) - Score-Based Blocking
|
|
73
130
|
# ============================================================================
|
|
74
|
-
# Enable
|
|
131
|
+
# Enable scanner-path detection (not general SQL injection/XSS filtering).
|
|
75
132
|
# Uses score-based blocking with exponential decay for intelligent threat detection.
|
|
76
133
|
config.waf[:enabled] = true
|
|
77
134
|
|
|
@@ -82,6 +139,12 @@ Beskar.configure do |config|
|
|
|
82
139
|
# config.waf[:block_durations] = [1.hour, 6.hours, 24.hours, 7.days] # Escalating durations
|
|
83
140
|
# config.waf[:permanent_block_after] = 500 # Permanent block when cumulative score reaches this
|
|
84
141
|
# config.waf[:create_security_events] = true # Create SecurityEvent records
|
|
142
|
+
# config.waf[:exception_detection] = :suspicious # Ordinary Rails errors need scanner evidence
|
|
143
|
+
# config.waf[:request_exclusions] = [
|
|
144
|
+
# {path: %r{\A/wp-content/}, methods: ["GET", "HEAD"], categories: [:wordpress_static]}
|
|
145
|
+
# ]
|
|
146
|
+
# WAF evidence excludes raw URLs and exception messages. Audit fields honor
|
|
147
|
+
# Rails filter_parameters. See docs/guides/audit-and-waf.md before opting into :all errors.
|
|
85
148
|
#
|
|
86
149
|
# === Exponential Decay Configuration ===
|
|
87
150
|
# Violations decay over time based on severity (reduces false positives)
|
|
@@ -102,8 +165,8 @@ Beskar.configure do |config|
|
|
|
102
165
|
# %r{/public/.*} # Public content
|
|
103
166
|
# ]
|
|
104
167
|
#
|
|
105
|
-
# ===
|
|
106
|
-
# See
|
|
168
|
+
# === Illustrative thresholds ===
|
|
169
|
+
# See docs/guides/audit-and-waf.md for matching rules and tuning limitations:
|
|
107
170
|
# - STRICT: score_threshold = 100 (high-security)
|
|
108
171
|
# - BALANCED: score_threshold = 150 (recommended default)
|
|
109
172
|
# - PERMISSIVE: score_threshold = 200 (high-traffic sites)
|
|
@@ -115,7 +178,11 @@ Beskar.configure do |config|
|
|
|
115
178
|
# config.security_tracking[:enabled] = false
|
|
116
179
|
# config.security_tracking[:track_successful_logins] = true
|
|
117
180
|
# config.security_tracking[:track_failed_logins] = true
|
|
181
|
+
# No background analyzer is built in. Opt in with your own Active Job:
|
|
182
|
+
# config.security_tracking[:analysis_job] = "SecurityReviewJob"
|
|
118
183
|
# config.security_tracking[:auto_analyze_patterns] = true
|
|
184
|
+
# The job receives user_type:, user_id:, event_type: after the outer commit.
|
|
185
|
+
# See docs/guides/configuration.md for the contract and queue/delivery limitations.
|
|
119
186
|
|
|
120
187
|
# ============================================================================
|
|
121
188
|
# RATE LIMITING
|
|
@@ -129,6 +196,8 @@ Beskar.configure do |config|
|
|
|
129
196
|
# config.rate_limiting[:account_attempts][:period] = 15.minutes
|
|
130
197
|
# config.rate_limiting[:account_attempts][:exponential_backoff] = true
|
|
131
198
|
#
|
|
199
|
+
# config.rate_limiting[:global_attempts][:enabled] = false # Shared budget is opt-in
|
|
200
|
+
# config.rate_limiting[:ip_attempts][:block_requests] = false # Keep login quotas off unrelated requests
|
|
132
201
|
# config.rate_limiting[:global_attempts][:limit] = 100
|
|
133
202
|
# config.rate_limiting[:global_attempts][:period] = 1.minute
|
|
134
203
|
# config.rate_limiting[:global_attempts][:exponential_backoff] = false
|
|
@@ -137,17 +206,22 @@ Beskar.configure do |config|
|
|
|
137
206
|
# RISK-BASED ACCOUNT LOCKING
|
|
138
207
|
# ============================================================================
|
|
139
208
|
# Risk-based locking is disabled by default. To enable:
|
|
209
|
+
# Review docs/guides/risk-scoring.md: factors are heuristics; repeated IPs do not establish trust.
|
|
140
210
|
# config.risk_based_locking[:enabled] = true
|
|
141
|
-
#
|
|
211
|
+
# Confirmed locks always reject sign-in and revoke old sessions, including with legacy immediate_signout: false.
|
|
142
212
|
# config.risk_based_locking[:risk_threshold] = 75 # Risk score threshold for locking
|
|
143
|
-
# config.risk_based_locking[:
|
|
144
|
-
# config.risk_based_locking[:
|
|
213
|
+
# config.risk_based_locking[:lock_strategy] = :devise_lockable # :rails_auth or :none also supported; :custom is rejected
|
|
214
|
+
# config.risk_based_locking[:auto_unlock_time] = 1.hour # Native locks only; nil requires manual unlock
|
|
215
|
+
# Devise owns unlock_strategy/unlock_in. Configure those through Devise.
|
|
216
|
+
# Rails-native controllers/session readers need the guards in docs/guides/authentication.md.
|
|
217
|
+
# config.risk_based_locking[:notify_user] = true # Opt-in email; configure notifications below first
|
|
145
218
|
# config.risk_based_locking[:log_lock_events] = true # Log lock events
|
|
146
219
|
|
|
147
220
|
# ============================================================================
|
|
148
221
|
# GEOLOCATION
|
|
149
222
|
# ============================================================================
|
|
150
223
|
# Geolocation uses a mock provider by default. To use MaxMind:
|
|
224
|
+
# Mock locations are synthetic and never trigger travel/country risk.
|
|
151
225
|
# 1. Download GeoLite2-City database from maxmind.com
|
|
152
226
|
# 2. Place it in config/GeoLite2-City.mmdb
|
|
153
227
|
# 3. Configure:
|
|
@@ -171,7 +245,17 @@ Beskar.configure do |config|
|
|
|
171
245
|
# config.emergency_password_reset[:impossible_travel_threshold] = 3
|
|
172
246
|
# config.emergency_password_reset[:suspicious_device_threshold] = 5
|
|
173
247
|
# config.emergency_password_reset[:total_locks_threshold] = 5
|
|
174
|
-
# config.emergency_password_reset[:send_notification] = true
|
|
175
|
-
# config.emergency_password_reset[:notify_security_team] = true
|
|
248
|
+
# config.emergency_password_reset[:send_notification] = true # Default false; requires notification settings
|
|
249
|
+
# config.emergency_password_reset[:notify_security_team] = true # Default false; requires team recipients
|
|
176
250
|
# config.emergency_password_reset[:require_manual_unlock] = false
|
|
251
|
+
|
|
252
|
+
# ============================================================================
|
|
253
|
+
# NOTIFICATIONS (OPT-IN)
|
|
254
|
+
# ============================================================================
|
|
255
|
+
# config.notifications[:from] = "security@your-domain.example"
|
|
256
|
+
# config.notifications[:recovery_url] = "https://your-domain.example/account-recovery"
|
|
257
|
+
# config.notifications[:security_team_recipients] = ["security-team@your-domain.example"]
|
|
258
|
+
# Replace these values, configure Action Mailer, and run an Active Job worker
|
|
259
|
+
# consuming beskar_notifications. Recovery URLs are entry pages, never tokens.
|
|
260
|
+
# See docs/guides/notifications-and-recovery.md for retries and host recovery requirements.
|
|
177
261
|
end
|
data/lib/tasks/beskar_tasks.rake
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
namespace :beskar do
|
|
4
|
+
desc "Remove expired coordination state (does not remove audit events or active bans)"
|
|
5
|
+
task cleanup_security_state: :environment do
|
|
6
|
+
Beskar::SecurityState.cleanup_expired!
|
|
7
|
+
end
|
|
8
|
+
|
|
4
9
|
desc "Install Beskar: copy migrations and create initializer"
|
|
5
10
|
task install: :environment do
|
|
6
11
|
puts "=" * 80
|
|
@@ -10,25 +15,19 @@ namespace :beskar do
|
|
|
10
15
|
|
|
11
16
|
# Copy migrations
|
|
12
17
|
puts "📦 Copying migrations..."
|
|
13
|
-
|
|
14
|
-
Rake::Task["beskar:install:migrations"].invoke
|
|
15
|
-
rescue RuntimeError
|
|
16
|
-
# In development/test within the gem, this task might not exist
|
|
17
|
-
# In a real app using the gem, it will work fine
|
|
18
|
-
puts " (Skipping migration copy in gem development mode)"
|
|
19
|
-
end
|
|
18
|
+
Rake::Task["beskar:install:migrations"].invoke
|
|
20
19
|
puts "✓ Migrations ready"
|
|
21
20
|
puts
|
|
22
21
|
|
|
23
22
|
# Create initializer
|
|
24
23
|
initializer_path = Rails.root.join("config/initializers/beskar.rb")
|
|
25
|
-
|
|
24
|
+
|
|
26
25
|
if File.exist?(initializer_path)
|
|
27
26
|
puts "⚠️ Initializer already exists at config/initializers/beskar.rb"
|
|
28
27
|
print " Overwrite? (y/N): "
|
|
29
|
-
response = $stdin.gets.
|
|
30
|
-
|
|
31
|
-
unless response ==
|
|
28
|
+
response = $stdin.gets.to_s.strip.downcase
|
|
29
|
+
|
|
30
|
+
unless response == "y" || response == "yes"
|
|
32
31
|
puts " Skipping initializer creation"
|
|
33
32
|
puts
|
|
34
33
|
next_steps
|
|
@@ -41,7 +40,7 @@ namespace :beskar do
|
|
|
41
40
|
|
|
42
41
|
# Read the template and process ERB (Rails will be available in the rake task context)
|
|
43
42
|
template_content = File.read(template_path)
|
|
44
|
-
erb = ERB.new(template_content, trim_mode:
|
|
43
|
+
erb = ERB.new(template_content, trim_mode: "-")
|
|
45
44
|
|
|
46
45
|
# Evaluate the ERB template in a context where Rails is available
|
|
47
46
|
processed_content = erb.result(binding)
|
|
@@ -69,7 +68,7 @@ namespace :beskar do
|
|
|
69
68
|
puts
|
|
70
69
|
puts " # app/models/user.rb"
|
|
71
70
|
puts " class User < ApplicationRecord"
|
|
72
|
-
puts " include Beskar::SecurityTrackable"
|
|
71
|
+
puts " include Beskar::Models::SecurityTrackable"
|
|
73
72
|
puts " "
|
|
74
73
|
puts " devise :database_authenticatable, :registerable,"
|
|
75
74
|
puts " :recoverable, :rememberable, :validatable"
|
|
@@ -98,11 +97,8 @@ namespace :beskar do
|
|
|
98
97
|
puts "5. When ready to enable blocking:"
|
|
99
98
|
puts
|
|
100
99
|
puts " # config/initializers/beskar.rb"
|
|
101
|
-
puts " config.waf =
|
|
102
|
-
puts "
|
|
103
|
-
puts " monitor_only: false, # <-- Change this to false"
|
|
104
|
-
puts " # ... rest of config"
|
|
105
|
-
puts " }"
|
|
100
|
+
puts " config.waf[:enabled] = true"
|
|
101
|
+
puts " config.monitor_only = false"
|
|
106
102
|
puts
|
|
107
103
|
puts "6. Optional: Add IP whitelist for trusted sources:"
|
|
108
104
|
puts
|
|
@@ -115,7 +111,7 @@ namespace :beskar do
|
|
|
115
111
|
puts "=" * 80
|
|
116
112
|
puts "Documentation:"
|
|
117
113
|
puts " - README: https://github.com/humadroid-io/beskar"
|
|
118
|
-
puts " - WAF Monitor Mode: See
|
|
114
|
+
puts " - WAF Monitor Mode: See docs/operations/monitor-only-mode.md"
|
|
119
115
|
puts "=" * 80
|
|
120
116
|
end
|
|
121
117
|
end
|