beskar 0.0.1 → 0.1.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 +143 -0
- data/README.md +987 -21
- data/app/controllers/beskar/application_controller.rb +170 -0
- data/app/controllers/beskar/banned_ips_controller.rb +280 -0
- data/app/controllers/beskar/dashboard_controller.rb +70 -0
- data/app/controllers/beskar/security_events_controller.rb +182 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +70 -0
- data/app/models/beskar/banned_ip.rb +193 -0
- data/app/models/beskar/security_event.rb +64 -0
- data/app/services/beskar/banned_ip_manager.rb +78 -0
- data/app/views/beskar/banned_ips/edit.html.erb +259 -0
- data/app/views/beskar/banned_ips/index.html.erb +361 -0
- data/app/views/beskar/banned_ips/new.html.erb +310 -0
- data/app/views/beskar/banned_ips/show.html.erb +310 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +309 -0
- data/app/views/beskar/security_events/show.html.erb +307 -0
- data/app/views/layouts/beskar/application.html.erb +647 -5
- data/config/locales/en.yml +10 -0
- data/config/routes.rb +41 -0
- data/db/migrate/20251016000001_create_beskar_security_events.rb +25 -0
- data/db/migrate/20251016000002_create_beskar_banned_ips.rb +23 -0
- data/lib/beskar/configuration.rb +214 -0
- data/lib/beskar/engine.rb +105 -0
- data/lib/beskar/logger.rb +293 -0
- data/lib/beskar/middleware/request_analyzer.rb +305 -0
- data/lib/beskar/middleware.rb +4 -0
- data/lib/beskar/models/security_trackable.rb +25 -0
- data/lib/beskar/models/security_trackable_authenticable.rb +167 -0
- data/lib/beskar/models/security_trackable_devise.rb +82 -0
- data/lib/beskar/models/security_trackable_generic.rb +355 -0
- data/lib/beskar/services/account_locker.rb +263 -0
- data/lib/beskar/services/device_detector.rb +250 -0
- data/lib/beskar/services/geolocation_service.rb +392 -0
- data/lib/beskar/services/ip_whitelist.rb +113 -0
- data/lib/beskar/services/rate_limiter.rb +257 -0
- data/lib/beskar/services/waf.rb +551 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar.rb +32 -1
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +177 -0
- data/lib/tasks/beskar_tasks.rake +121 -4
- metadata +138 -5
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBeskarSecurityEvents < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :beskar_security_events do |t|
|
|
6
|
+
t.references :user, polymorphic: true, null: true, index: true
|
|
7
|
+
t.string :event_type, null: false
|
|
8
|
+
t.string :ip_address
|
|
9
|
+
t.string :attempted_email
|
|
10
|
+
t.text :user_agent
|
|
11
|
+
t.json :metadata, default: {}
|
|
12
|
+
t.integer :risk_score
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
add_index :beskar_security_events, :ip_address
|
|
18
|
+
add_index :beskar_security_events, :event_type
|
|
19
|
+
add_index :beskar_security_events, :attempted_email
|
|
20
|
+
add_index :beskar_security_events, :created_at
|
|
21
|
+
add_index :beskar_security_events, :risk_score
|
|
22
|
+
add_index :beskar_security_events, [:ip_address, :event_type, :created_at],
|
|
23
|
+
name: 'index_security_events_on_ip_event_time'
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateBeskarBannedIps < ActiveRecord::Migration[8.0]
|
|
4
|
+
def change
|
|
5
|
+
create_table :beskar_banned_ips do |t|
|
|
6
|
+
t.string :ip_address, null: false
|
|
7
|
+
t.string :reason, null: false
|
|
8
|
+
t.text :details
|
|
9
|
+
t.datetime :banned_at, null: false
|
|
10
|
+
t.datetime :expires_at
|
|
11
|
+
t.boolean :permanent, default: false, null: false
|
|
12
|
+
t.integer :violation_count, default: 1, null: false
|
|
13
|
+
t.text :metadata # Using text to store JSON (compatible with SQLite and PostgreSQL)
|
|
14
|
+
|
|
15
|
+
t.timestamps
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :beskar_banned_ips, :ip_address, unique: true
|
|
19
|
+
add_index :beskar_banned_ips, :banned_at
|
|
20
|
+
add_index :beskar_banned_ips, :expires_at
|
|
21
|
+
add_index :beskar_banned_ips, [:ip_address, :expires_at]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
module Beskar
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :rate_limiting, :security_tracking, :risk_based_locking, :geolocation, :ip_whitelist, :waf, :authentication_models, :emergency_password_reset, :monitor_only, :authenticate_admin
|
|
4
|
+
|
|
5
|
+
def initialize
|
|
6
|
+
@monitor_only = false # Global monitor-only mode - logs everything but doesn't block
|
|
7
|
+
@ip_whitelist = [] # Array of IP addresses or CIDR ranges
|
|
8
|
+
|
|
9
|
+
# Dashboard authentication - configure this to restrict access to the dashboard
|
|
10
|
+
# Example: config.authenticate_admin = proc { authenticate_admin! }
|
|
11
|
+
@authenticate_admin = nil
|
|
12
|
+
|
|
13
|
+
# Authentication models configuration
|
|
14
|
+
# Auto-detect by default, or can be explicitly configured
|
|
15
|
+
@authentication_models = {
|
|
16
|
+
devise: [], # Will be auto-detected: [:devise_user, :admin, etc.]
|
|
17
|
+
rails_auth: [], # Will be auto-detected: [:user, etc.]
|
|
18
|
+
auto_detect: true # Set to false to use only explicitly configured models
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@waf = {
|
|
22
|
+
enabled: false, # Master switch for WAF
|
|
23
|
+
auto_block: true, # Automatically block IPs after threshold
|
|
24
|
+
score_threshold: 150, # Cumulative risk score before blocking (replaces block_threshold)
|
|
25
|
+
violation_window: 6.hours, # Maximum time window to track violations
|
|
26
|
+
block_durations: [1.hour, 6.hours, 24.hours, 7.days], # Escalating block durations
|
|
27
|
+
permanent_block_after: 500, # Permanent block after cumulative score reaches this (nil = never)
|
|
28
|
+
create_security_events: true, # Create SecurityEvent records
|
|
29
|
+
record_not_found_exclusions: [], # Regex patterns to exclude from RecordNotFound detection
|
|
30
|
+
decay_enabled: true, # Enable exponential decay of violation scores over time
|
|
31
|
+
decay_rates: { # Decay rates by severity (half-life in minutes)
|
|
32
|
+
critical: 360, # Critical violations: 6 hour half-life
|
|
33
|
+
high: 120, # High violations: 2 hour half-life
|
|
34
|
+
medium: 45, # Medium violations: 45 minute half-life
|
|
35
|
+
low: 15 # Low violations: 15 minute half-life
|
|
36
|
+
},
|
|
37
|
+
max_violations_tracked: 50 # Maximum number of violations to track per IP (oldest pruned)
|
|
38
|
+
}
|
|
39
|
+
@security_tracking = {
|
|
40
|
+
enabled: true,
|
|
41
|
+
track_successful_logins: true,
|
|
42
|
+
track_failed_logins: true,
|
|
43
|
+
auto_analyze_patterns: true
|
|
44
|
+
}
|
|
45
|
+
@rate_limiting = {
|
|
46
|
+
ip_attempts: {
|
|
47
|
+
limit: 10,
|
|
48
|
+
period: 1.hour,
|
|
49
|
+
exponential_backoff: true
|
|
50
|
+
},
|
|
51
|
+
account_attempts: {
|
|
52
|
+
limit: 5,
|
|
53
|
+
period: 15.minutes,
|
|
54
|
+
exponential_backoff: true
|
|
55
|
+
},
|
|
56
|
+
global_attempts: {
|
|
57
|
+
limit: 100,
|
|
58
|
+
period: 1.minute,
|
|
59
|
+
exponential_backoff: false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
@risk_based_locking = {
|
|
63
|
+
enabled: false, # Master switch for risk-based locking
|
|
64
|
+
risk_threshold: 75, # Lock account if risk score >= this value
|
|
65
|
+
lock_strategy: :devise_lockable, # Strategy: :devise_lockable, :custom, :none
|
|
66
|
+
auto_unlock_time: 1.hour, # Time until automatic unlock (if supported by strategy)
|
|
67
|
+
notify_user: true, # Send notification on lock
|
|
68
|
+
log_lock_events: true, # Create security event for locks
|
|
69
|
+
immediate_signout: false # Sign out user immediately via Warden callback (requires :lockable)
|
|
70
|
+
}
|
|
71
|
+
@geolocation = {
|
|
72
|
+
provider: :mock, # Provider: :maxmind, :mock
|
|
73
|
+
maxmind_city_db_path: nil, # Path to MaxMind GeoLite2-City.mmdb or GeoIP2-City.mmdb
|
|
74
|
+
cache_ttl: 4.hours # How long to cache geolocation results
|
|
75
|
+
}
|
|
76
|
+
@emergency_password_reset = {
|
|
77
|
+
enabled: false, # Master switch for emergency password reset
|
|
78
|
+
impossible_travel_threshold: 3, # Reset after N impossible travel events in 24h
|
|
79
|
+
suspicious_device_threshold: 5, # Reset after N suspicious device events in 24h
|
|
80
|
+
total_locks_threshold: 5, # Reset after N total locks in 24h (any reason)
|
|
81
|
+
send_notification: true, # Send email to user about reset
|
|
82
|
+
notify_security_team: true, # Alert security team about automatic resets
|
|
83
|
+
require_manual_unlock: false # Require manual admin unlock after reset
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def security_tracking_enabled?
|
|
88
|
+
@security_tracking[:enabled]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def track_successful_logins?
|
|
92
|
+
security_tracking_enabled? && @security_tracking[:track_successful_logins]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def track_failed_logins?
|
|
96
|
+
security_tracking_enabled? && @security_tracking[:track_failed_logins]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def auto_analyze_patterns?
|
|
100
|
+
security_tracking_enabled? && @security_tracking[:auto_analyze_patterns]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Risk-based locking configuration helpers
|
|
104
|
+
def risk_based_locking_enabled?
|
|
105
|
+
@risk_based_locking[:enabled]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def risk_threshold
|
|
109
|
+
@risk_based_locking[:risk_threshold] || 75
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def lock_strategy
|
|
113
|
+
@risk_based_locking[:lock_strategy] || :devise_lockable
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def auto_unlock_time
|
|
117
|
+
@risk_based_locking[:auto_unlock_time] || 1.hour
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def notify_user_on_lock?
|
|
121
|
+
@risk_based_locking[:notify_user] != false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def log_lock_events?
|
|
125
|
+
@risk_based_locking[:log_lock_events] != false
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def immediate_signout?
|
|
129
|
+
@risk_based_locking[:immediate_signout] == true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Geolocation configuration helpers
|
|
133
|
+
def geolocation_provider
|
|
134
|
+
@geolocation[:provider] || :mock
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def maxmind_city_db_path
|
|
138
|
+
@geolocation[:maxmind_city_db_path]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def geolocation_cache_ttl
|
|
142
|
+
@geolocation[:cache_ttl] || 4.hours
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# WAF configuration helpers
|
|
146
|
+
def waf_enabled?
|
|
147
|
+
@waf && @waf[:enabled]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def waf_auto_block?
|
|
151
|
+
waf_enabled? && @waf[:auto_block] && !@monitor_only
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# General monitor-only mode check (affects all blocking)
|
|
155
|
+
def monitor_only?
|
|
156
|
+
@monitor_only == true
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# IP Whitelist configuration helpers
|
|
160
|
+
def ip_whitelist_enabled?
|
|
161
|
+
@ip_whitelist.is_a?(Array) && @ip_whitelist.any?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Authentication models helpers
|
|
165
|
+
def devise_scopes
|
|
166
|
+
return @authentication_models[:devise] unless @authentication_models[:auto_detect]
|
|
167
|
+
|
|
168
|
+
# Auto-detect Devise models
|
|
169
|
+
detected = []
|
|
170
|
+
if defined?(Devise)
|
|
171
|
+
Devise.mappings.keys.each do |scope|
|
|
172
|
+
detected << scope
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Merge with explicitly configured models
|
|
177
|
+
(detected + Array(@authentication_models[:devise])).uniq
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def rails_auth_scopes
|
|
181
|
+
return @authentication_models[:rails_auth] unless @authentication_models[:auto_detect]
|
|
182
|
+
|
|
183
|
+
# Auto-detect Rails authentication models (has_secure_password)
|
|
184
|
+
detected = []
|
|
185
|
+
if defined?(ActiveRecord::Base)
|
|
186
|
+
# Try to find models with has_secure_password
|
|
187
|
+
# This is a heuristic - models that have password_digest column
|
|
188
|
+
ActiveRecord::Base.descendants.each do |model|
|
|
189
|
+
next unless model.table_exists?
|
|
190
|
+
if model.column_names.include?("password_digest")
|
|
191
|
+
scope = model.name.underscore.to_sym
|
|
192
|
+
detected << scope unless devise_scopes.include?(scope)
|
|
193
|
+
end
|
|
194
|
+
rescue => e
|
|
195
|
+
# Ignore errors during detection
|
|
196
|
+
Beskar::Logger.debug("Error detecting Rails auth model #{model.name}: #{e.message}")
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Merge with explicitly configured models
|
|
201
|
+
(detected + Array(@authentication_models[:rails_auth])).uniq
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def all_auth_scopes
|
|
205
|
+
(devise_scopes + rails_auth_scopes).uniq
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def model_class_for_scope(scope)
|
|
209
|
+
scope.to_s.camelize.constantize
|
|
210
|
+
rescue NameError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
data/lib/beskar/engine.rb
CHANGED
|
@@ -1,5 +1,110 @@
|
|
|
1
1
|
module Beskar
|
|
2
2
|
class Engine < ::Rails::Engine
|
|
3
3
|
isolate_namespace Beskar
|
|
4
|
+
|
|
5
|
+
initializer "beskar.middleware" do |app|
|
|
6
|
+
app.config.middleware.use ::Beskar::Middleware::RequestAnalyzer
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Preload banned IPs into cache on startup
|
|
10
|
+
config.after_initialize do
|
|
11
|
+
if defined?(Beskar::BannedIp)
|
|
12
|
+
Rails.application.executor.wrap do
|
|
13
|
+
Beskar::BannedIp.preload_cache!
|
|
14
|
+
Beskar::Logger.info("Preloaded banned IPs into cache")
|
|
15
|
+
rescue => e
|
|
16
|
+
Beskar::Logger.warn("Failed to preload banned IPs: #{e.message}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
initializer "beskar.warden_callbacks", after: :load_config_initializers do |app|
|
|
22
|
+
if defined?(Warden)
|
|
23
|
+
# Track successful authentication and check for high-risk locks
|
|
24
|
+
Warden::Manager.after_set_user except: :fetch do |user, auth, opts|
|
|
25
|
+
# Only proceed if Beskar security tracking is available and enabled
|
|
26
|
+
if user.respond_to?(:track_authentication_event) && auth.request
|
|
27
|
+
# Track the authentication event (creates security event)
|
|
28
|
+
security_event = user.track_authentication_event(auth.request, :success)
|
|
29
|
+
|
|
30
|
+
# Check if account was locked due to high risk (only if immediate_signout is enabled)
|
|
31
|
+
# This happens AFTER successful authentication but BEFORE the request completes
|
|
32
|
+
# Requires :lockable module to be enabled on the user model
|
|
33
|
+
if Beskar.configuration.immediate_signout? &&
|
|
34
|
+
Beskar.configuration.risk_based_locking_enabled? &&
|
|
35
|
+
security_event &&
|
|
36
|
+
user_was_just_locked?(user, security_event) &&
|
|
37
|
+
user.respond_to?(:access_locked?) && user.access_locked?
|
|
38
|
+
Beskar::Logger.warn("Signing out user #{user.id} due to high-risk lock")
|
|
39
|
+
auth.logout
|
|
40
|
+
throw :warden, scope: opts[:scope], message: :account_locked_due_to_high_risk
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Alternative approach using after_authentication is available but not enabled by default
|
|
46
|
+
# Uncomment this to use the alternative approach (more targeted, only on authentication)
|
|
47
|
+
# Warden::Manager.after_authentication do |user, auth, opts|
|
|
48
|
+
# if user.respond_to?(:check_high_risk_lock_and_signout)
|
|
49
|
+
# user.check_high_risk_lock_and_signout(auth)
|
|
50
|
+
# end
|
|
51
|
+
# end
|
|
52
|
+
|
|
53
|
+
Warden::Manager.before_failure do |env, opts|
|
|
54
|
+
if env
|
|
55
|
+
request = ActionDispatch::Request.new(env)
|
|
56
|
+
scope = opts[:scope]
|
|
57
|
+
|
|
58
|
+
# Try to get model class from configuration
|
|
59
|
+
model_class = Beskar.configuration&.model_class_for_scope(scope)
|
|
60
|
+
|
|
61
|
+
if model_class && model_class.respond_to?(:track_failed_authentication)
|
|
62
|
+
model_class.track_failed_authentication(request, scope)
|
|
63
|
+
else
|
|
64
|
+
Beskar::Logger.debug("No trackable model found for scope: #{scope}")
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Helper method to check if user was just locked
|
|
72
|
+
def self.user_was_just_locked?(user, security_event)
|
|
73
|
+
return false unless Beskar.configuration.risk_based_locking_enabled?
|
|
74
|
+
return false unless security_event
|
|
75
|
+
return false unless user&.respond_to?(:security_events)
|
|
76
|
+
|
|
77
|
+
# Check if an account_locked or lock_attempted event was just created
|
|
78
|
+
recent_lock = user.security_events
|
|
79
|
+
.where(event_type: [ "account_locked", "lock_attempted" ])
|
|
80
|
+
.where("created_at >= ?", 10.seconds.ago)
|
|
81
|
+
.order(created_at: :desc)
|
|
82
|
+
.first
|
|
83
|
+
|
|
84
|
+
recent_lock.present?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Add engine migrations to host app's migration paths
|
|
88
|
+
initializer "beskar.append_migrations" do |app|
|
|
89
|
+
# Don't add migrations if we're inside the engine itself (testing)
|
|
90
|
+
if !root.to_s.include?(app.root.to_s) && !app.root.to_s.include?(root.to_s)
|
|
91
|
+
engine_migrations = root.join("db", "migrate").to_s
|
|
92
|
+
|
|
93
|
+
# Add to Rails paths
|
|
94
|
+
app.config.paths["db/migrate"] << engine_migrations
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Ensure ActiveRecord sees the engine migrations after initialization
|
|
99
|
+
# config.after_initialize do |app|
|
|
100
|
+
# unless root.to_s.include?(app.root.to_s)
|
|
101
|
+
# engine_migrations = root.join("db", "migrate").to_s
|
|
102
|
+
|
|
103
|
+
# # Update ActiveRecord::Tasks paths
|
|
104
|
+
# current_paths = Array(ActiveRecord::Tasks::DatabaseTasks.migrations_paths)
|
|
105
|
+
# current_paths << engine_migrations
|
|
106
|
+
# ActiveRecord::Tasks::DatabaseTasks.migrations_paths = current_paths.uniq
|
|
107
|
+
# end
|
|
108
|
+
# end
|
|
4
109
|
end
|
|
5
110
|
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Beskar
|
|
4
|
+
# Centralized logging module for consistent log formatting and flexible output handling
|
|
5
|
+
module Logger
|
|
6
|
+
class << self
|
|
7
|
+
# Available log levels
|
|
8
|
+
LOG_LEVELS = %i[debug info warn error fatal].freeze
|
|
9
|
+
|
|
10
|
+
# Generate logging methods for each level
|
|
11
|
+
LOG_LEVELS.each do |level|
|
|
12
|
+
define_method(level) do |message, component: nil|
|
|
13
|
+
log(level, message, component: component)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Main logging method that handles formatting and output
|
|
18
|
+
#
|
|
19
|
+
# @param level [Symbol] The log level (:debug, :info, :warn, :error, :fatal)
|
|
20
|
+
# @param message [String] The message to log
|
|
21
|
+
# @param component [String, Symbol, nil] Optional component name for more specific prefixes
|
|
22
|
+
#
|
|
23
|
+
# @example Basic usage
|
|
24
|
+
# Beskar::Logger.info("User authenticated successfully")
|
|
25
|
+
# # => [Beskar] User authenticated successfully
|
|
26
|
+
#
|
|
27
|
+
# @example With component
|
|
28
|
+
# Beskar::Logger.warn("Rate limit exceeded", component: :WAF)
|
|
29
|
+
# # => [Beskar::WAF] Rate limit exceeded
|
|
30
|
+
#
|
|
31
|
+
# @example With class as component
|
|
32
|
+
# Beskar::Logger.error("Failed to lock account", component: self.class)
|
|
33
|
+
# # => [Beskar::AccountLocker] Failed to lock account
|
|
34
|
+
def log(level, message, component: nil)
|
|
35
|
+
return unless should_log?(level)
|
|
36
|
+
|
|
37
|
+
formatted_message = format_message(message, component)
|
|
38
|
+
logger.send(level, formatted_message)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
# Fallback to stderr if logging fails
|
|
41
|
+
$stderr.puts "[Beskar::Logger] Failed to log message: #{e.message}"
|
|
42
|
+
$stderr.puts "[Beskar::Logger] Original message: #{formatted_message}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Configure the logger instance
|
|
46
|
+
#
|
|
47
|
+
# @param logger_instance [Logger, nil] The logger to use, defaults to Rails.logger
|
|
48
|
+
def logger=(logger_instance)
|
|
49
|
+
@logger = logger_instance
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get the current logger instance
|
|
53
|
+
#
|
|
54
|
+
# @return [Logger] The configured logger or Rails.logger as default
|
|
55
|
+
def logger
|
|
56
|
+
@logger ||= default_logger
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Configure log level threshold
|
|
60
|
+
#
|
|
61
|
+
# @param level [Symbol, String] Minimum log level to output
|
|
62
|
+
def level=(level)
|
|
63
|
+
@level = level.to_sym if LOG_LEVELS.include?(level.to_sym)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get the current log level
|
|
67
|
+
#
|
|
68
|
+
# @return [Symbol] Current log level
|
|
69
|
+
def level
|
|
70
|
+
@level ||= :debug
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Reset logger configuration to defaults
|
|
74
|
+
def reset!
|
|
75
|
+
@logger = nil
|
|
76
|
+
@level = nil
|
|
77
|
+
@component_aliases = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Configure component name aliases for cleaner output
|
|
81
|
+
#
|
|
82
|
+
# @param aliases [Hash] Mapping of classes/modules to display names
|
|
83
|
+
#
|
|
84
|
+
# @example
|
|
85
|
+
# Beskar::Logger.component_aliases = {
|
|
86
|
+
# 'Beskar::Services::Waf' => 'WAF',
|
|
87
|
+
# 'Beskar::Services::AccountLocker' => 'AccountLocker'
|
|
88
|
+
# }
|
|
89
|
+
def component_aliases=(aliases)
|
|
90
|
+
@component_aliases = aliases
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Get component aliases
|
|
94
|
+
#
|
|
95
|
+
# @return [Hash] Current component aliases
|
|
96
|
+
def component_aliases
|
|
97
|
+
@component_aliases ||= default_component_aliases
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Format the log message with appropriate prefix
|
|
103
|
+
#
|
|
104
|
+
# @param message [String] The message to format
|
|
105
|
+
# @param component [String, Symbol, Class, nil] Component identifier
|
|
106
|
+
# @return [String] Formatted message with prefix
|
|
107
|
+
def format_message(message, component)
|
|
108
|
+
prefix = build_prefix(component)
|
|
109
|
+
"#{prefix} #{message}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Build the log prefix based on component
|
|
113
|
+
#
|
|
114
|
+
# @param component [String, Symbol, Class, nil] Component identifier
|
|
115
|
+
# @return [String] Formatted prefix
|
|
116
|
+
def build_prefix(component)
|
|
117
|
+
return "[Beskar]" if component.nil?
|
|
118
|
+
|
|
119
|
+
component_name = normalize_component_name(component)
|
|
120
|
+
|
|
121
|
+
if component_name.nil? || component_name.empty?
|
|
122
|
+
"[Beskar]"
|
|
123
|
+
else
|
|
124
|
+
"[Beskar::#{component_name}]"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Normalize component name from various input types
|
|
129
|
+
#
|
|
130
|
+
# @param component [String, Symbol, Class] Component identifier
|
|
131
|
+
# @return [String, nil] Normalized component name
|
|
132
|
+
def normalize_component_name(component)
|
|
133
|
+
case component
|
|
134
|
+
when String
|
|
135
|
+
apply_component_alias(component)
|
|
136
|
+
when Symbol
|
|
137
|
+
component.to_s
|
|
138
|
+
when Class
|
|
139
|
+
apply_component_alias(component.name)
|
|
140
|
+
when Module
|
|
141
|
+
apply_component_alias(component.name)
|
|
142
|
+
else
|
|
143
|
+
component.to_s
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Apply component alias if configured
|
|
148
|
+
#
|
|
149
|
+
# @param component_name [String] Original component name
|
|
150
|
+
# @return [String] Aliased name or original
|
|
151
|
+
def apply_component_alias(component_name)
|
|
152
|
+
return nil if component_name.nil?
|
|
153
|
+
|
|
154
|
+
# First check exact matches
|
|
155
|
+
aliased = component_aliases[component_name]
|
|
156
|
+
return aliased if aliased
|
|
157
|
+
|
|
158
|
+
# Remove Beskar:: prefix if present for lookup
|
|
159
|
+
clean_name = component_name.sub(/^Beskar::/, '')
|
|
160
|
+
aliased = component_aliases[clean_name]
|
|
161
|
+
return aliased if aliased
|
|
162
|
+
|
|
163
|
+
# Check if it's already a simple component name (no ::)
|
|
164
|
+
return clean_name unless clean_name.include?('::')
|
|
165
|
+
|
|
166
|
+
# Extract the last component for nested classes
|
|
167
|
+
# e.g., "Beskar::Services::Waf" -> "Waf"
|
|
168
|
+
last_component = clean_name.split('::').last
|
|
169
|
+
component_aliases[clean_name] || last_component
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Check if message should be logged based on current level
|
|
173
|
+
#
|
|
174
|
+
# @param message_level [Symbol] Level of the message
|
|
175
|
+
# @return [Boolean] True if message should be logged
|
|
176
|
+
def should_log?(message_level)
|
|
177
|
+
level_value(message_level) >= level_value(level)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Convert log level to numeric value for comparison
|
|
181
|
+
#
|
|
182
|
+
# @param level_sym [Symbol] Log level
|
|
183
|
+
# @return [Integer] Numeric value
|
|
184
|
+
def level_value(level_sym)
|
|
185
|
+
LOG_LEVELS.index(level_sym) || 0
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Get the default logger instance
|
|
189
|
+
#
|
|
190
|
+
# @return [Logger] Default logger (Rails.logger or stdlib Logger)
|
|
191
|
+
def default_logger
|
|
192
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
193
|
+
Rails.logger
|
|
194
|
+
else
|
|
195
|
+
require 'logger'
|
|
196
|
+
::Logger.new($stdout)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Default component name aliases for cleaner output
|
|
201
|
+
#
|
|
202
|
+
# @return [Hash] Default aliases
|
|
203
|
+
def default_component_aliases
|
|
204
|
+
{
|
|
205
|
+
'Beskar::Services::Waf' => 'WAF',
|
|
206
|
+
'Beskar::Services::WAF' => 'WAF',
|
|
207
|
+
'Services::Waf' => 'WAF',
|
|
208
|
+
'Services::WAF' => 'WAF',
|
|
209
|
+
'Beskar::Services::AccountLocker' => 'AccountLocker',
|
|
210
|
+
'Services::AccountLocker' => 'AccountLocker',
|
|
211
|
+
'Beskar::Services::RateLimiter' => 'RateLimiter',
|
|
212
|
+
'Services::RateLimiter' => 'RateLimiter',
|
|
213
|
+
'Beskar::Services::IpWhitelist' => 'IpWhitelist',
|
|
214
|
+
'Services::IpWhitelist' => 'IpWhitelist',
|
|
215
|
+
'Beskar::Services::GeolocationService' => 'GeolocationService',
|
|
216
|
+
'Services::GeolocationService' => 'GeolocationService',
|
|
217
|
+
'Beskar::Services::DeviceDetector' => 'DeviceDetector',
|
|
218
|
+
'Services::DeviceDetector' => 'DeviceDetector',
|
|
219
|
+
'Beskar::Middleware::RequestAnalyzer' => 'Middleware',
|
|
220
|
+
'Middleware::RequestAnalyzer' => 'Middleware',
|
|
221
|
+
'Beskar::Models::SecurityTrackableDevise' => 'SecurityTracking',
|
|
222
|
+
'Models::SecurityTrackableDevise' => 'SecurityTracking',
|
|
223
|
+
'Beskar::Models::SecurityTrackableAuthenticable' => 'SecurityTracking',
|
|
224
|
+
'Models::SecurityTrackableAuthenticable' => 'SecurityTracking',
|
|
225
|
+
'Beskar::Models::SecurityTrackableGeneric' => 'SecurityTracking',
|
|
226
|
+
'Models::SecurityTrackableGeneric' => 'SecurityTracking'
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Module to include in classes for instance-level logging
|
|
232
|
+
module ClassMethods
|
|
233
|
+
# Log a debug message with automatic component detection
|
|
234
|
+
def log_debug(message)
|
|
235
|
+
Beskar::Logger.debug(message, component: self)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Log an info message with automatic component detection
|
|
239
|
+
def log_info(message)
|
|
240
|
+
Beskar::Logger.info(message, component: self)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Log a warning message with automatic component detection
|
|
244
|
+
def log_warn(message)
|
|
245
|
+
Beskar::Logger.warn(message, component: self)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Log an error message with automatic component detection
|
|
249
|
+
def log_error(message)
|
|
250
|
+
Beskar::Logger.error(message, component: self)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Log a fatal message with automatic component detection
|
|
254
|
+
def log_fatal(message)
|
|
255
|
+
Beskar::Logger.fatal(message, component: self)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Module to include in classes for instance-level logging
|
|
260
|
+
module InstanceMethods
|
|
261
|
+
# Log a debug message with automatic component detection
|
|
262
|
+
def log_debug(message)
|
|
263
|
+
Beskar::Logger.debug(message, component: self.class)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Log an info message with automatic component detection
|
|
267
|
+
def log_info(message)
|
|
268
|
+
Beskar::Logger.info(message, component: self.class)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Log a warning message with automatic component detection
|
|
272
|
+
def log_warn(message)
|
|
273
|
+
Beskar::Logger.warn(message, component: self.class)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Log an error message with automatic component detection
|
|
277
|
+
def log_error(message)
|
|
278
|
+
Beskar::Logger.error(message, component: self.class)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Log a fatal message with automatic component detection
|
|
282
|
+
def log_fatal(message)
|
|
283
|
+
Beskar::Logger.fatal(message, component: self.class)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Convenience method to include both class and instance methods
|
|
288
|
+
def self.included(base)
|
|
289
|
+
base.extend(ClassMethods)
|
|
290
|
+
base.include(InstanceMethods)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|