beskar 0.0.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +274 -0
- data/README.md +412 -204
- data/app/channels/concerns/beskar/channels/session_security.rb +46 -0
- data/app/controllers/beskar/administrative_actions_controller.rb +16 -0
- data/app/controllers/beskar/application_controller.rb +214 -0
- data/app/controllers/beskar/banned_ips_controller.rb +255 -0
- data/app/controllers/beskar/dashboard_controller.rb +62 -0
- data/app/controllers/beskar/security_events_controller.rb +164 -0
- data/app/controllers/concerns/beskar/controllers/audit_export.rb +54 -0
- data/app/controllers/concerns/beskar/controllers/security_tracking.rb +76 -48
- data/app/controllers/concerns/beskar/controllers/session_security.rb +29 -0
- data/app/jobs/beskar/notification_job.rb +33 -0
- data/app/mailers/beskar/security_mailer.rb +59 -0
- data/app/models/beskar/administrative_action.rb +41 -0
- data/app/models/beskar/banned_ip.rb +105 -105
- data/app/models/beskar/security_event.rb +51 -4
- data/app/models/beskar/security_state.rb +58 -0
- data/app/services/beskar/banned_ip_manager.rb +88 -0
- data/app/views/beskar/administrative_actions/index.html.erb +33 -0
- data/app/views/beskar/administrative_actions/show.html.erb +21 -0
- data/app/views/beskar/banned_ips/edit.html.erb +195 -0
- data/app/views/beskar/banned_ips/index.html.erb +319 -0
- data/app/views/beskar/banned_ips/new.html.erb +190 -0
- data/app/views/beskar/banned_ips/review.html.erb +24 -0
- data/app/views/beskar/banned_ips/show.html.erb +304 -0
- data/app/views/beskar/dashboard/index.html.erb +280 -0
- data/app/views/beskar/security_events/index.html.erb +302 -0
- data/app/views/beskar/security_events/show.html.erb +293 -0
- data/app/views/beskar/shared/_export_form.html.erb +10 -0
- data/app/views/layouts/beskar/_behavior.html.erb +121 -0
- data/app/views/layouts/beskar/application.html.erb +581 -6
- data/config/routes.rb +30 -0
- data/db/migrate/20251016000001_create_beskar_security_events.rb +3 -3
- data/db/migrate/20260910000001_create_beskar_security_states.rb +14 -0
- data/db/migrate/20260911000001_create_beskar_administrative_actions.rb +22 -0
- data/db/migrate/20260911000002_expand_administrative_action_targets.rb +6 -0
- data/docs/README.md +73 -0
- data/docs/archive/project-documentation.md +659 -0
- data/docs/audits/project-review.md +437 -0
- data/docs/audits/repair-status.md +216 -0
- data/docs/guides/audit-and-waf.md +175 -0
- data/docs/guides/audit-lifecycle.md +172 -0
- data/docs/guides/authentication.md +213 -0
- data/docs/guides/configuration.md +182 -0
- data/docs/guides/dashboard-and-search.md +251 -0
- data/docs/guides/notifications-and-recovery.md +157 -0
- data/docs/guides/risk-scoring.md +116 -0
- data/docs/operations/monitor-only-mode.md +85 -0
- data/docs/operations/security-hardening.md +167 -0
- data/docs/operations/state-storage.md +144 -0
- data/docs/research/rust-performance-assessment.md +69 -0
- data/lib/beskar/configuration.rb +105 -20
- data/lib/beskar/configuration_validator.rb +188 -0
- data/lib/beskar/devise_authentication.rb +24 -0
- data/lib/beskar/engine.rb +21 -88
- data/lib/beskar/logger.rb +288 -0
- data/lib/beskar/middleware/request_analyzer.rb +133 -99
- data/lib/beskar/models/security_trackable_authenticable.rb +76 -97
- data/lib/beskar/models/security_trackable_devise.rb +34 -25
- data/lib/beskar/models/security_trackable_generic.rb +171 -214
- data/lib/beskar/risk_level.rb +22 -0
- data/lib/beskar/services/account_locker.rb +90 -81
- data/lib/beskar/services/administrative_audit.rb +36 -0
- data/lib/beskar/services/administrative_bans.rb +104 -0
- data/lib/beskar/services/audit_data.rb +72 -0
- data/lib/beskar/services/authentication.rb +31 -0
- data/lib/beskar/services/authentication_attempt.rb +141 -0
- data/lib/beskar/services/ban_expiry.rb +28 -0
- data/lib/beskar/services/device_detector.rb +32 -41
- data/lib/beskar/services/event_search.rb +58 -0
- data/lib/beskar/services/geolocation_service.rb +83 -114
- data/lib/beskar/services/ip_whitelist.rb +31 -40
- data/lib/beskar/services/location_assessment.rb +109 -0
- data/lib/beskar/services/native_account_lock.rb +82 -0
- data/lib/beskar/services/notifications.rb +46 -0
- data/lib/beskar/services/rate_limiter.rb +99 -125
- data/lib/beskar/services/request_context.rb +64 -0
- data/lib/beskar/services/risk_assessment.rb +58 -0
- data/lib/beskar/services/session_revocation.rb +62 -0
- data/lib/beskar/services/waf.rb +311 -198
- data/lib/beskar/services/waf_request.rb +60 -0
- data/lib/beskar/version.rb +1 -1
- data/lib/beskar/warden_authentication.rb +53 -0
- data/lib/beskar.rb +54 -4
- data/lib/generators/beskar/install/install_generator.rb +158 -0
- data/lib/generators/beskar/install/templates/initializer.rb.tt +261 -0
- data/lib/tasks/beskar_tasks.rake +25 -20
- metadata +93 -12
- data/lib/beskar/templates/beskar_initializer.rb +0 -107
|
@@ -0,0 +1,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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
39
|
-
IPAddr.new(
|
|
40
|
-
IPAddr.new(
|
|
41
|
-
IPAddr.new(
|
|
42
|
-
IPAddr.new(
|
|
43
|
-
IPAddr.new(
|
|
44
|
-
IPAddr.new(
|
|
45
|
-
IPAddr.new(
|
|
46
|
-
IPAddr.new(
|
|
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
|
-
|
|
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
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 =>
|
|
132
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
@
|
|
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
|
-
|
|
170
|
-
when :
|
|
171
|
-
|
|
177
|
+
lookup_maxmind(ip_address)
|
|
178
|
+
when :mock
|
|
179
|
+
lookup_mock(ip_address)
|
|
172
180
|
else
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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 = [
|
|
289
|
-
cities = [
|
|
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
|
|
297
|
-
when
|
|
298
|
-
when
|
|
299
|
-
when
|
|
300
|
-
when
|
|
301
|
-
when
|
|
302
|
-
when
|
|
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 ?
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
14
|
+
parsed_entries.values.any? do |entry|
|
|
15
|
+
entry&.include?(ip)
|
|
17
16
|
end
|
|
18
|
-
rescue
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
77
|
+
rescue ArgumentError
|
|
90
78
|
false
|
|
91
79
|
end
|
|
92
80
|
|
|
93
81
|
# Cache parsed entries for performance
|
|
94
82
|
def parsed_entries
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|