beskar 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +274 -0
  3. data/README.md +412 -204
  4. data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
  5. data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
  6. data/app/controllers/beskar/application_controller.rb +214 -0
  7. data/app/controllers/beskar/banned_ips_controller.rb +255 -0
  8. data/app/controllers/beskar/dashboard_controller.rb +62 -0
  9. data/app/controllers/beskar/security_events_controller.rb +164 -0
  10. data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
  11. data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
  12. data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
  13. data/app/jobs/beskar/notification_job.rb +33 -0
  14. data/app/mailers/beskar/security_mailer.rb +59 -0
  15. data/app/models/beskar/administrative_action.rb +41 -0
  16. data/app/models/beskar/banned_ip.rb +105 -105
  17. data/app/models/beskar/security_event.rb +51 -4
  18. data/app/models/beskar/security_state.rb +58 -0
  19. data/app/services/beskar/banned_ip_manager.rb +88 -0
  20. data/app/views/beskar/administrative_actions/index.html.erb +33 -0
  21. data/app/views/beskar/administrative_actions/show.html.erb +21 -0
  22. data/app/views/beskar/banned_ips/edit.html.erb +195 -0
  23. data/app/views/beskar/banned_ips/index.html.erb +319 -0
  24. data/app/views/beskar/banned_ips/new.html.erb +190 -0
  25. data/app/views/beskar/banned_ips/review.html.erb +24 -0
  26. data/app/views/beskar/banned_ips/show.html.erb +304 -0
  27. data/app/views/beskar/dashboard/index.html.erb +280 -0
  28. data/app/views/beskar/security_events/index.html.erb +302 -0
  29. data/app/views/beskar/security_events/show.html.erb +293 -0
  30. data/app/views/beskar/shared/_export_form.html.erb +10 -0
  31. data/app/views/layouts/beskar/_behavior.html.erb +121 -0
  32. data/app/views/layouts/beskar/application.html.erb +581 -6
  33. data/config/routes.rb +30 -0
  34. data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
  35. data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
  36. data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
  37. data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
  38. data/docs/README.md +73 -0
  39. data/docs/archive/project-documentation.md +659 -0
  40. data/docs/audits/project-review.md +437 -0
  41. data/docs/audits/repair-status.md +216 -0
  42. data/docs/guides/audit-and-waf.md +175 -0
  43. data/docs/guides/audit-lifecycle.md +172 -0
  44. data/docs/guides/authentication.md +213 -0
  45. data/docs/guides/configuration.md +182 -0
  46. data/docs/guides/dashboard-and-search.md +251 -0
  47. data/docs/guides/notifications-and-recovery.md +157 -0
  48. data/docs/guides/risk-scoring.md +116 -0
  49. data/docs/operations/monitor-only-mode.md +85 -0
  50. data/docs/operations/security-hardening.md +167 -0
  51. data/docs/operations/state-storage.md +144 -0
  52. data/docs/research/rust-performance-assessment.md +69 -0
  53. data/lib/beskar/configuration.rb +105 -20
  54. data/lib/beskar/configuration_validator.rb +188 -0
  55. data/lib/beskar/devise_authentication.rb +24 -0
  56. data/lib/beskar/engine.rb +21 -88
  57. data/lib/beskar/logger.rb +288 -0
  58. data/lib/beskar/middleware/request_analyzer.rb +133 -99
  59. data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
  60. data/lib/beskar/models/security_trackable_devise.rb +34 -25
  61. data/lib/beskar/models/security_trackable_generic.rb +171 -214
  62. data/lib/beskar/risk_level.rb +22 -0
  63. data/lib/beskar/services/account_locker.rb +90 -81
  64. data/lib/beskar/services/administrative_audit.rb +36 -0
  65. data/lib/beskar/services/administrative_bans.rb +104 -0
  66. data/lib/beskar/services/audit_data.rb +72 -0
  67. data/lib/beskar/services/authentication.rb +31 -0
  68. data/lib/beskar/services/authentication_attempt.rb +141 -0
  69. data/lib/beskar/services/ban_expiry.rb +28 -0
  70. data/lib/beskar/services/device_detector.rb +32 -41
  71. data/lib/beskar/services/event_search.rb +58 -0
  72. data/lib/beskar/services/geolocation_service.rb +83 -114
  73. data/lib/beskar/services/ip_whitelist.rb +31 -40
  74. data/lib/beskar/services/location_assessment.rb +109 -0
  75. data/lib/beskar/services/native_account_lock.rb +82 -0
  76. data/lib/beskar/services/notifications.rb +46 -0
  77. data/lib/beskar/services/rate_limiter.rb +99 -125
  78. data/lib/beskar/services/request_context.rb +64 -0
  79. data/lib/beskar/services/risk_assessment.rb +58 -0
  80. data/lib/beskar/services/session_revocation.rb +62 -0
  81. data/lib/beskar/services/waf.rb +311 -198
  82. data/lib/beskar/services/waf_request.rb +60 -0
  83. data/lib/beskar/version.rb +1 -1
  84. data/lib/beskar/warden_authentication.rb +53 -0
  85. data/lib/beskar.rb +54 -4
  86. data/lib/generators/beskar/install/install_generator.rb +158 -0
  87. data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
  88. data/lib/tasks/beskar_tasks.rake +25 -20
  89. metadata +93 -12
  90. data/lib/beskar/templates/beskar_initializer.rb +0 -107
@@ -0,0 +1,28 @@
1
+ require "time"
2
+ require "date"
3
+
4
+ module Beskar
5
+ module Services
6
+ # datetime-local has no timezone. Dashboard wall-clock input is explicitly
7
+ # UTC; scripted callers may instead supply an ISO 8601 numeric offset.
8
+ module BanExpiry
9
+ class InvalidInput < ArgumentError; end
10
+ FORMAT = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,6})?)?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?\z/
11
+
12
+ def self.parse(value)
13
+ return nil if value.nil? || value == ""
14
+ match = FORMAT.match(value) if value.is_a?(String) && value.bytesize <= 40
15
+ unless match && match[1].to_i.positive? && Date.valid_date?(*match.captures.first(3).map(&:to_i)) &&
16
+ match[4].to_i < 24 && match[5].to_i < 60 && match[6].to_i < 60
17
+ raise InvalidInput, "Expiry must be a valid UTC date and time or ISO 8601 timestamp with offset"
18
+ end
19
+ # Browsers omit seconds when zero; Ruby's ISO parser requires them.
20
+ timestamp = value.sub(/T(\d{2}):(\d{2})(?=Z|[+-]|\z)/, 'T\1:\2:00')
21
+ Time.iso8601(match[7] ? timestamp : "#{timestamp}Z").utc
22
+ rescue ArgumentError => error
23
+ raise error if error.is_a?(InvalidInput)
24
+ raise InvalidInput, "Expiry must be a valid UTC date and time or ISO 8601 timestamp with offset"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -27,14 +27,14 @@ module Beskar
27
27
  curl|wget|postman|httpie|
28
28
  python-requests|ruby|php|java|
29
29
  headless|phantom|selenium|playwright
30
- }ix.freeze
30
+ }ix
31
31
 
32
32
  # Mobile device patterns
33
33
  MOBILE_PATTERNS = %r{
34
34
  Mobile|Android|iPhone|iPad|iPod|
35
35
  BlackBerry|IEMobile|Opera\s*Mini|
36
36
  Windows\s*Phone|webOS|Kindle
37
- }ix.freeze
37
+ }ix
38
38
 
39
39
  # Browser patterns with version extraction
40
40
  BROWSER_PATTERNS = {
@@ -111,11 +111,11 @@ module Beskar
111
111
  # because Chrome user agents contain "Safari"
112
112
  # Also check for Opera (OPR) before Chrome since it also contains Chrome
113
113
  if user_agent.match?(/OPR|Opera/i)
114
- if match = user_agent.match(BROWSER_PATTERNS[:opera])
114
+ if (match = user_agent.match(BROWSER_PATTERNS[:opera]))
115
115
  return "Opera #{match[1]}"
116
116
  end
117
117
  elsif user_agent.match?(/Chrome/i) && !user_agent.match?(/Edg/i)
118
- if match = user_agent.match(BROWSER_PATTERNS[:chrome])
118
+ if (match = user_agent.match(BROWSER_PATTERNS[:chrome]))
119
119
  return "Chrome #{match[1]}"
120
120
  end
121
121
  end
@@ -123,7 +123,7 @@ module Beskar
123
123
  BROWSER_PATTERNS.each do |browser, pattern|
124
124
  next if browser == :chrome # Already handled above
125
125
 
126
- if match = user_agent.match(pattern)
126
+ if (match = user_agent.match(pattern))
127
127
  return "#{browser.to_s.titleize} #{match[1]}"
128
128
  end
129
129
  end
@@ -139,7 +139,7 @@ module Beskar
139
139
  return "Unknown" if user_agent.blank?
140
140
 
141
141
  PLATFORM_PATTERNS.each do |platform, pattern|
142
- if match = user_agent.match(pattern)
142
+ if (match = user_agent.match(pattern))
143
143
  version = match[1]&.tr("_", ".")
144
144
 
145
145
  case platform
@@ -183,44 +183,35 @@ module Beskar
183
183
  # @param user_agent [String] The User-Agent string
184
184
  # @return [Integer] Risk score from 0 to 50
185
185
  def calculate_user_agent_risk(user_agent)
186
- return 20 if user_agent.blank?
187
-
188
- risk = 0
189
-
190
- # Bot detection adds significant risk
191
- if bot?(user_agent)
192
- risk += 30
193
- Rails.logger.info "Bot detected: #{user_agent}, adding 30 risk"
194
- end
195
-
196
- # Suspicious patterns
197
- if user_agent.length < 20 || user_agent.length > 500
198
- risk += 15
199
- Rails.logger.info "Suspicious length: #{user_agent.length}, adding 15 risk"
200
- end
201
-
202
- if user_agent.match?(/test|debug|script/i)
203
- risk += 10
204
- Rails.logger.info "Suspicious pattern: #{user_agent}, adding 10 risk"
205
- end
206
-
207
- if user_agent.count("()") > 3
208
- risk += 5
209
- Rails.logger.info "Suspicious pattern: #{user_agent}, adding 5 risk"
210
- end
186
+ assess(user_agent)[:score]
187
+ end
211
188
 
212
- # Very old browsers might be suspicious
213
- if browser_info = detect_browser(user_agent)
214
- if browser_info.match?(/Chrome (\d+)/) && $1.to_i < 90
215
- risk += 5
216
- Rails.logger.info "Suspicious browser: #{browser_info}, adding 5 risk"
217
- elsif browser_info.match?(/Firefox (\d+)/) && $1.to_i < 90
218
- risk += 5
219
- Rails.logger.info "Suspicious browser: #{browser_info}, adding 5 risk"
189
+ # Evidence describes unverified User-Agent claims, never device identity.
190
+ # Do not log the raw header, which can contain attacker-controlled secrets.
191
+ def assess(user_agent)
192
+ original_length = user_agent.to_s.length
193
+ user_agent = RequestContext.text(user_agent)
194
+ info = detect(user_agent)
195
+ factors = []
196
+ if user_agent.blank?
197
+ factors << {name: "user_agent_missing", points: 20, evidence: {}}
198
+ else
199
+ factors << {name: "user_agent_bot", points: 30, evidence: {bot: true}} if info[:bot]
200
+ if original_length < 20 || original_length > 500
201
+ factors << {name: "user_agent_length", points: 15, evidence: {length: original_length}}
202
+ end
203
+ if user_agent.match?(/test|debug|script/i)
204
+ factors << {name: "user_agent_suspicious_pattern", points: 10, evidence: {}}
205
+ end
206
+ factors << {name: "user_agent_parentheses", points: 5, evidence: {count: user_agent.count("()")}} if user_agent.count("()") > 3
207
+ if (match = info[:browser].match(/\A(?:Chrome|Firefox) (\d+)/)) && match[1].to_i < 90
208
+ factors << {name: "old_browser", points: 5, evidence: {browser: info[:browser], legacy_major_cutoff: 90}}
220
209
  end
221
210
  end
222
-
223
- [ risk, 50 ].min # Cap at 50 to leave room for other risk factors
211
+ total = factors.sum { |factor| factor[:points] }
212
+ factors << {name: "user_agent_cap", points: 50 - total, evidence: {cap: 50}} if total > 50
213
+ info[:suspicious] = factors.any? { |factor| %w[user_agent_bot user_agent_suspicious_pattern].include?(factor[:name]) }
214
+ {score: [total, 50].min, device_info: info, factors: factors}
224
215
  end
225
216
 
226
217
  private
@@ -0,0 +1,58 @@
1
+ module Beskar
2
+ module Services
3
+ # Centralizes adapter-specific JSON expressions. Values are always bound;
4
+ # table/column names come from the model, never request parameters.
5
+ class EventSearch
6
+ def initialize(relation)
7
+ @relation = relation
8
+ @connection = relation.klass.connection
9
+ end
10
+
11
+ def search(term)
12
+ match(term, %w[ip_address user_agent attempted_email event_type].map { |name| column(name) } + [metadata_text])
13
+ end
14
+
15
+ def email(term)
16
+ # Match the model's legacy fallback, not unrelated text in metadata.
17
+ match(term, ["COALESCE(#{column("attempted_email")}, #{legacy_email})"])
18
+ end
19
+
20
+ private
21
+
22
+ def match(term, expressions)
23
+ return @relation unless term.is_a?(String) && term.present?
24
+ value = RequestContext.text(term, limit: 256).downcase
25
+ pattern = "%#{@relation.klass.sanitize_sql_like(value, "!")}%"
26
+ predicates = expressions.map { |expression| "LOWER(#{expression}) LIKE ? ESCAPE '!'" }
27
+ @relation.where(predicates.join(" OR "), *Array.new(expressions.length, pattern))
28
+ end
29
+
30
+ def column(name)
31
+ "#{@connection.quote_table_name(@relation.klass.table_name)}.#{@connection.quote_column_name(name)}"
32
+ end
33
+
34
+ def mysql?
35
+ %w[Mysql2 Trilogy].include?(@connection.adapter_name)
36
+ end
37
+
38
+ def metadata_text
39
+ "CAST(#{column("metadata")} AS #{mysql? ? "CHAR" : "TEXT"})"
40
+ end
41
+
42
+ def legacy_email
43
+ metadata = column("metadata")
44
+ case @connection.adapter_name
45
+ when "PostgreSQL"
46
+ "(#{metadata} ->> 'attempted_email')"
47
+ when "Mysql2", "Trilogy"
48
+ extracted = "JSON_EXTRACT(#{metadata}, '$.attempted_email')"
49
+ "CASE WHEN JSON_TYPE(#{extracted}) = 'NULL' THEN NULL ELSE JSON_UNQUOTE(#{extracted}) END"
50
+ when "SQLite"
51
+ "json_extract(#{metadata}, '$.attempted_email')"
52
+ else
53
+ raise ArgumentError, "Beskar email search does not support this database adapter"
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  begin
4
- require 'maxminddb'
6
+ require "maxminddb"
5
7
  rescue LoadError
6
8
  # MaxMindDB gem not available
7
9
  end
@@ -35,15 +37,15 @@ module Beskar
35
37
  class GeolocationService
36
38
  # Private/internal IP ranges that should not be geolocated
37
39
  PRIVATE_IP_RANGES = [
38
- IPAddr.new('10.0.0.0/8'), # RFC 1918 - Private networks
39
- IPAddr.new('172.16.0.0/12'), # RFC 1918 - Private networks
40
- IPAddr.new('192.168.0.0/16'), # RFC 1918 - Private networks
41
- IPAddr.new('127.0.0.0/8'), # Loopback
42
- IPAddr.new('169.254.0.0/16'), # Link-local
43
- IPAddr.new('224.0.0.0/4'), # Multicast
44
- IPAddr.new('::1/128'), # IPv6 loopback
45
- IPAddr.new('fe80::/10'), # IPv6 link-local
46
- IPAddr.new('fc00::/7') # IPv6 unique local
40
+ IPAddr.new("10.0.0.0/8"), # RFC 1918 - Private networks
41
+ IPAddr.new("172.16.0.0/12"), # RFC 1918 - Private networks
42
+ IPAddr.new("192.168.0.0/16"), # RFC 1918 - Private networks
43
+ IPAddr.new("127.0.0.0/8"), # Loopback
44
+ IPAddr.new("169.254.0.0/16"), # Link-local
45
+ IPAddr.new("224.0.0.0/4"), # Multicast
46
+ IPAddr.new("::1/128"), # IPv6 loopback
47
+ IPAddr.new("fe80::/10"), # IPv6 link-local
48
+ IPAddr.new("fc00::/7") # IPv6 unique local
47
49
  ].freeze
48
50
 
49
51
  # Cache TTL for geolocation results (4 hours)
@@ -98,8 +100,9 @@ module Beskar
98
100
  dlat = lat2_rad - lat1_rad
99
101
  dlon = lon2_rad - lon1_rad
100
102
 
101
- a = Math.sin(dlat/2)**2 + Math.cos(lat1_rad) * Math.cos(lat2_rad) * Math.sin(dlon/2)**2
102
- c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
103
+ a = Math.sin(dlat / 2)**2 + Math.cos(lat1_rad) * Math.cos(lat2_rad) * Math.sin(dlon / 2)**2
104
+ a = a.clamp(0.0, 1.0)
105
+ c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
103
106
 
104
107
  # Earth's radius in kilometers
105
108
  earth_radius = 6371.0
@@ -107,35 +110,36 @@ module Beskar
107
110
  end
108
111
  end
109
112
 
110
- # Get or initialize the MaxMind City database reader
111
- # Uses thread-safe singleton pattern for efficient database access
112
- #
113
- # @return [MaxMindDB::Reader, nil] The reader instance or nil if not configured
114
- def self.city_reader
115
- return @city_reader if @city_reader
116
- return nil unless Beskar.configuration.maxmind_city_db_path
117
- return nil unless defined?(MaxMindDB)
113
+ # Namespace readers and optional caches by the configured database generation.
114
+ def self.database_identity
115
+ path = Beskar.configuration.maxmind_city_db_path.to_s
116
+ stat = File.stat(path) if path.present?
117
+ [path, stat&.ino, stat&.size, stat&.mtime&.to_r&.to_s]
118
+ rescue SystemCallError
119
+ [path, nil, nil, nil]
120
+ end
118
121
 
122
+ def self.city_reader(identity = database_identity)
119
123
  @city_reader_mutex.synchronize do
120
- return @city_reader if @city_reader
121
-
122
- db_path = Beskar.configuration.maxmind_city_db_path
123
- if File.exist?(db_path)
124
- @city_reader = MaxMindDB.new(db_path)
125
- Rails.logger.info "[Beskar::GeolocationService] MaxMind City database loaded from #{db_path}"
126
- else
127
- Rails.logger.warn "[Beskar::GeolocationService] MaxMind City database not found at #{db_path}"
128
- end
129
- @city_reader
124
+ return @city_reader if @city_reader_identity == identity && @city_reader
125
+ @city_reader = nil
126
+ @city_reader_identity = identity
127
+ path = identity.first
128
+ return nil unless path.present? && defined?(MaxMindDB) && File.file?(path)
129
+ @city_reader = MaxMindDB.new(path)
130
130
  end
131
- rescue => e
132
- Rails.logger.error "[Beskar::GeolocationService] Failed to load MaxMind City database: #{e.message}"
131
+ rescue => error
132
+ Beskar::Logger.error("Failed to load MaxMind database (#{error.class})", component: :GeolocationService)
133
133
  nil
134
134
  end
135
135
 
136
- # Reset the database reader (useful for testing or reloading configuration)
136
+ # Existing lookups can finish using their reader; do not close it underneath
137
+ # another thread. New lookups select the current database generation.
137
138
  def self.reset_readers!
138
- @city_reader_mutex.synchronize { @city_reader = nil }
139
+ @city_reader_mutex.synchronize do
140
+ @city_reader = nil
141
+ @city_reader_identity = nil
142
+ end
139
143
  end
140
144
 
141
145
  # Initialize the geolocation service
@@ -143,7 +147,11 @@ module Beskar
143
147
  # @param provider [Symbol] The geolocation provider to use (:maxmind, :mock)
144
148
  def initialize(provider: nil)
145
149
  @provider = provider || Beskar.configuration.geolocation_provider
146
- @cache_key_prefix = "beskar:geolocation"
150
+ unless Configuration::GEOLOCATION_PROVIDERS.include?(@provider)
151
+ raise Configuration::Error, "geolocation.provider must be mock or maxmind"
152
+ end
153
+ @database_identity = self.class.database_identity
154
+ @cache_key_prefix = "beskar:geolocation:v2:#{Digest::SHA256.hexdigest([@provider, @database_identity].to_json)}"
147
155
  @cache_ttl = Beskar.configuration.geolocation_cache_ttl
148
156
  end
149
157
 
@@ -159,18 +167,18 @@ module Beskar
159
167
  end
160
168
 
161
169
  # Check cache first
162
- if cached_result = get_cached_location(ip_address)
170
+ if (cached_result = get_cached_location(ip_address))
163
171
  return cached_result
164
172
  end
165
173
 
166
174
  # Perform lookup based on provider
167
- case @provider
175
+ result = case @provider
168
176
  when :maxmind
169
- result = lookup_maxmind(ip_address)
170
- when :ip2location
171
- result = lookup_ip2location(ip_address)
177
+ lookup_maxmind(ip_address)
178
+ when :mock
179
+ lookup_mock(ip_address)
172
180
  else
173
- result = lookup_mock(ip_address)
181
+ unknown_location(ip_address)
174
182
  end
175
183
 
176
184
  # Cache the result
@@ -178,7 +186,7 @@ module Beskar
178
186
 
179
187
  result
180
188
  rescue => e
181
- Rails.logger.warn "[Beskar::GeolocationService] Failed to locate IP #{ip_address}: #{e.message}"
189
+ Beskar::Logger.warn("Failed to locate IP #{ip_address}: #{e.class}", component: :GeolocationService)
182
190
  unknown_location(ip_address)
183
191
  end
184
192
 
@@ -190,55 +198,28 @@ module Beskar
190
198
  # @param max_speed_kmh [Integer] Maximum realistic travel speed in km/h (default: 1000 for commercial flights)
191
199
  # @return [Boolean] true if travel is impossible
192
200
  def impossible_travel?(location1, location2, time_diff_seconds, max_speed_kmh: 1000)
193
- return false if location1.nil? || location2.nil?
194
- return false unless location1[:latitude] && location2[:latitude]
195
-
196
- distance_km = self.class.calculate_distance(
197
- location1[:latitude], location1[:longitude],
198
- location2[:latitude], location2[:longitude]
199
- )
200
-
201
- # Calculate maximum possible distance at given speed
202
- time_hours = time_diff_seconds / 3600.0
203
- max_distance_km = max_speed_kmh * time_hours
201
+ first, second = LocationAssessment.coordinates(location1), LocationAssessment.coordinates(location2)
202
+ elapsed, speed = LocationAssessment.number(time_diff_seconds), LocationAssessment.number(max_speed_kmh)
203
+ return false unless first && second && elapsed&.positive? && speed&.positive?
204
+ self.class.calculate_distance(*first, *second) > speed * elapsed / 3600.0
205
+ end
204
206
 
205
- distance_km > max_distance_km
207
+ def assess_location(ip_address, observations: [], at: Time.current, location: nil)
208
+ LocationAssessment.new(location || locate(ip_address), observations: observations, at: at).call
206
209
  end
207
210
 
208
- # Calculate risk score based on geolocation factors
209
- #
210
- # @param ip_address [String] The IP address
211
- # @param previous_locations [Array<Hash>] Array of previous location hashes
212
- # @param time_since_last [Integer] Seconds since last login
213
- # @return [Integer] Risk score from 0 to 30
211
+ # Compatibility API for a single previous location and elapsed duration.
212
+ # Multi-observation callers must supply each observation's own timestamp.
214
213
  def calculate_location_risk(ip_address, previous_locations = [], time_since_last = nil)
215
- current_location = locate(ip_address)
216
- risk = 0
217
-
218
- # Private/unknown IPs have moderate risk
219
- return 10 if current_location[:country] == "Unknown" || current_location[:country] == "Private"
220
-
221
- # Check for impossible travel if we have previous locations
222
- if previous_locations.any? && time_since_last
223
- previous_locations.each do |prev_location|
224
- if impossible_travel?(current_location, prev_location, time_since_last)
225
- risk += 25
226
- break
227
- end
228
- end
229
- end
230
-
231
- # Country change adds some risk
232
- if previous_locations.any?
233
- recent_countries = previous_locations.map { |loc| loc[:country] }.uniq
234
- risk += 10 unless recent_countries.include?(current_location[:country])
214
+ at = Time.current
215
+ observations = if previous_locations.all? { |entry| entry.is_a?(Hash) && (entry.key?(:occurred_at) || entry.key?("occurred_at")) }
216
+ previous_locations
217
+ elsif previous_locations.size == 1 && (elapsed = LocationAssessment.number(time_since_last))&.positive?
218
+ [{location: previous_locations.first, occurred_at: at - elapsed}]
219
+ else
220
+ []
235
221
  end
236
-
237
- # Known high-risk countries (this would be configurable in production)
238
- high_risk_countries = ['Unknown']
239
- risk += 15 if high_risk_countries.include?(current_location[:country])
240
-
241
- [risk, 30].min # Cap at 30 to leave room for other risk factors
222
+ assess_location(ip_address, observations: observations, at: at)[:score]
242
223
  end
243
224
 
244
225
  private
@@ -285,27 +266,27 @@ module Beskar
285
266
  # @return [Hash] Mock location information
286
267
  def lookup_mock(ip_address)
287
268
  # Generate consistent mock data based on IP
288
- country_codes = ['US', 'CA', 'GB', 'DE', 'FR', 'JP', 'AU']
289
- cities = ['New York', 'Toronto', 'London', 'Berlin', 'Paris', 'Tokyo', 'Sydney']
269
+ country_codes = ["US", "CA", "GB", "DE", "FR", "JP", "AU"]
270
+ cities = ["New York", "Toronto", "London", "Berlin", "Paris", "Tokyo", "Sydney"]
290
271
 
291
272
  index = ip_address.bytes.sum % country_codes.length
292
273
 
293
274
  {
294
275
  ip: ip_address,
295
276
  country: case country_codes[index]
296
- when 'US' then 'United States'
297
- when 'CA' then 'Canada'
298
- when 'GB' then 'United Kingdom'
299
- when 'DE' then 'Germany'
300
- when 'FR' then 'France'
301
- when 'JP' then 'Japan'
302
- when 'AU' then 'Australia'
277
+ when "US" then "United States"
278
+ when "CA" then "Canada"
279
+ when "GB" then "United Kingdom"
280
+ when "DE" then "Germany"
281
+ when "FR" then "France"
282
+ when "JP" then "Japan"
283
+ when "AU" then "Australia"
303
284
  end,
304
285
  country_code: country_codes[index],
305
286
  city: cities[index],
306
287
  latitude: (40.0 + (index * 10)) % 90,
307
288
  longitude: (-74.0 + (index * 15)) % 180,
308
- timezone: "UTC#{index > 3 ? '+' : '-'}#{index + 1}",
289
+ timezone: "UTC#{(index > 3) ? "+" : "-"}#{index + 1}",
309
290
  provider: @provider,
310
291
  private_ip: false
311
292
  }
@@ -316,10 +297,10 @@ module Beskar
316
297
  # @param ip_address [String] The IP address
317
298
  # @return [Hash] Location information from MaxMind
318
299
  def lookup_maxmind(ip_address)
319
- result = { ip: ip_address, provider: @provider, private_ip: false }
300
+ result = {ip: ip_address, provider: @provider, private_ip: false}
320
301
 
321
302
  # Lookup city/location data
322
- if city_reader = self.class.city_reader
303
+ if (city_reader = self.class.city_reader(@database_identity))
323
304
  begin
324
305
  city_data = city_reader.lookup(ip_address)
325
306
  if city_data&.found?
@@ -339,7 +320,7 @@ module Beskar
339
320
  result.merge!(unknown_location(ip_address).except(:ip, :provider, :private_ip))
340
321
  end
341
322
  rescue => e
342
- Rails.logger.warn "[Beskar::GeolocationService] MaxMind City lookup failed for #{ip_address}: #{e.message}"
323
+ Beskar::Logger.warn("MaxMind City lookup failed for #{ip_address}: #{e.class}", component: :GeolocationService)
343
324
  result.merge!(unknown_location(ip_address).except(:ip, :provider, :private_ip))
344
325
  end
345
326
  else
@@ -349,22 +330,10 @@ module Beskar
349
330
 
350
331
  result
351
332
  rescue => e
352
- Rails.logger.error "[Beskar::GeolocationService] MaxMind lookup failed for #{ip_address}: #{e.message}"
333
+ Beskar::Logger.error("MaxMind lookup failed for #{ip_address}: #{e.class}", component: :GeolocationService)
353
334
  unknown_location(ip_address)
354
335
  end
355
336
 
356
- # Lookup using IP2Location database
357
- #
358
- # @param ip_address [String] The IP address
359
- # @return [Hash] Location information from IP2Location
360
- def lookup_ip2location(ip_address)
361
- # This would integrate with IP2Location in production
362
- # For now, return unknown result
363
- result = unknown_location(ip_address)
364
- result[:provider] = @provider
365
- result
366
- end
367
-
368
337
  # Get cached location result
369
338
  #
370
339
  # @param ip_address [String] The IP address
@@ -373,7 +342,7 @@ module Beskar
373
342
  cache_key = "#{@cache_key_prefix}:#{ip_address}"
374
343
  Rails.cache.read(cache_key)
375
344
  rescue => e
376
- Rails.logger.debug "[Beskar::GeolocationService] Cache read failed: #{e.message}"
345
+ Beskar::Logger.debug("Cache read failed: #{e.class}", component: :GeolocationService)
377
346
  nil
378
347
  end
379
348
 
@@ -385,7 +354,7 @@ module Beskar
385
354
  cache_key = "#{@cache_key_prefix}:#{ip_address}"
386
355
  Rails.cache.write(cache_key, result, expires_in: @cache_ttl)
387
356
  rescue => e
388
- Rails.logger.debug "[Beskar::GeolocationService] Cache write failed: #{e.message}"
357
+ Beskar::Logger.debug("Cache write failed: #{e.class}", component: :GeolocationService)
389
358
  end
390
359
  end
391
360
  end
@@ -1,4 +1,4 @@
1
- require 'ipaddr'
1
+ require "ipaddr"
2
2
 
3
3
  module Beskar
4
4
  module Services
@@ -7,45 +7,39 @@ module Beskar
7
7
  # Check if an IP address is whitelisted
8
8
  def whitelisted?(ip_address)
9
9
  return false if ip_address.blank?
10
- return false unless whitelist_entries.any?
11
10
 
12
11
  ip = parse_ip(ip_address)
13
12
  return false unless ip
14
13
 
15
- whitelist_entries.any? do |entry|
16
- match_entry?(ip, entry)
14
+ parsed_entries.values.any? do |entry|
15
+ entry&.include?(ip)
17
16
  end
18
- rescue IPAddr::InvalidAddressError, ArgumentError => e
19
- Rails.logger.warn "[Beskar::IpWhitelist] Invalid IP address: #{ip_address} - #{e.message}"
17
+ rescue ArgumentError => e
18
+ Beskar::Logger.warn("Invalid IP address: #{ip_address} - #{e.class}", component: :IpWhitelist)
20
19
  false
21
20
  end
22
21
 
23
22
  # Get whitelist entries from configuration
24
23
  def whitelist_entries
25
- @whitelist_entries ||= begin
26
- entries = Beskar.configuration.ip_whitelist || []
27
- # Ensure it's an array
28
- entries = [entries] unless entries.is_a?(Array)
29
- entries.compact
30
- end
24
+ entries = Beskar.configuration.ip_whitelist || []
25
+ # Ensure it's an array
26
+ entries = [entries] unless entries.is_a?(Array)
27
+ entries.compact
31
28
  end
32
29
 
33
30
  # Clear cached whitelist (useful when config changes)
34
31
  def clear_cache!
35
- @whitelist_entries = nil
36
- @parsed_entries = nil
32
+ @parsed_snapshot = nil
37
33
  end
38
34
 
39
35
  # Validate whitelist configuration
40
36
  def validate_configuration!
41
37
  errors = []
42
-
38
+
43
39
  whitelist_entries.each_with_index do |entry, index|
44
- begin
45
- parse_entry(entry)
46
- rescue IPAddr::InvalidAddressError, ArgumentError => e
47
- errors << "Entry #{index} (#{entry}): #{e.message}"
48
- end
40
+ parse_entry(entry)
41
+ rescue ArgumentError => e
42
+ errors << "Entry #{index} (#{entry}): #{e.message}"
49
43
  end
50
44
 
51
45
  if errors.any?
@@ -67,16 +61,10 @@ module Beskar
67
61
  # Parse whitelist entry (can be single IP or CIDR notation)
68
62
  def parse_entry(entry)
69
63
  return nil if entry.blank?
70
-
64
+
71
65
  entry_str = entry.to_s.strip
72
-
73
- # Check if it's CIDR notation
74
- if entry_str.include?('/')
75
- IPAddr.new(entry_str)
76
- else
77
- # Single IP address
78
- IPAddr.new(entry_str)
79
- end
66
+
67
+ IPAddr.new(entry_str)
80
68
  end
81
69
 
82
70
  # Check if IP matches whitelist entry
@@ -86,23 +74,26 @@ module Beskar
86
74
 
87
75
  # IPAddr#include? handles both single IPs and CIDR ranges
88
76
  parsed_entry.include?(ip)
89
- rescue IPAddr::InvalidAddressError, ArgumentError
77
+ rescue ArgumentError
90
78
  false
91
79
  end
92
80
 
93
81
  # Cache parsed entries for performance
94
82
  def parsed_entries
95
- @parsed_entries ||= begin
96
- entries = {}
97
- whitelist_entries.each do |entry|
98
- begin
99
- entries[entry] = parse_entry(entry)
100
- rescue IPAddr::InvalidAddressError, ArgumentError => e
101
- Rails.logger.warn "[Beskar::IpWhitelist] Skipping invalid entry: #{entry} - #{e.message}"
102
- end
103
- end
104
- entries
83
+ source = whitelist_entries
84
+ snapshot = @parsed_snapshot
85
+ return snapshot.last if snapshot && snapshot.first == source
86
+
87
+ entries = {}
88
+ source.each do |entry|
89
+ entries[entry] = parse_entry(entry)
90
+ rescue ArgumentError => e
91
+ Beskar::Logger.warn("Skipping invalid entry: #{entry} - #{e.class}", component: :IpWhitelist)
105
92
  end
93
+ # Publish the source and parsed entries together, never a half-updated
94
+ # cache that another request could mistake for the new configuration.
95
+ @parsed_snapshot = [source.deep_dup, entries]
96
+ entries
106
97
  end
107
98
  end
108
99