otto 2.7.0 → 2.8.1

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.
@@ -0,0 +1,138 @@
1
+ # lib/otto/privacy/anonymizer_resolver.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Privacy
7
+ # Anonymizer (Tor / VPN / proxy / hosting) classification for IP addresses
8
+ #
9
+ # Answers one question — "is this address a known anonymizing egress, and
10
+ # of what kind?" — as a single label a downstream allow/deny rule can
11
+ # compare directly. Database-only, and the ONLY thing that ever leaves
12
+ # this resolver is the label.
13
+ #
14
+ # ## Why this one reads the UNMASKED address
15
+ #
16
+ # Every other database lookup in Otto operates on the masked IP, because
17
+ # country and ASN networks are >= /24 and a masked address lands in the
18
+ # same network as the real one. Anonymizer data breaks that equivalence:
19
+ # providers list individual egress nodes at or near /32, so a /24-masked
20
+ # lookup answers a question about the node's *neighbours* rather than the
21
+ # node. That produces wrong verdicts in both directions — flagging a whole
22
+ # /24 because one host in it is an exit node, and missing the exit node
23
+ # itself. A signal that quietly lies is worse than no signal.
24
+ #
25
+ # So this resolver takes the real address and returns only a label, which
26
+ # is the same trade Otto already makes twice: {IPPrivacy.hash_ip} consumes
27
+ # the full IP to emit an opaque digest, and +env['otto.ip_match']+ closes
28
+ # over the full IP to emit a boolean. The invariant Otto defends is that a
29
+ # raw address is never persisted, serialized, or handed downstream — not
30
+ # that it is unreachable in-process. A label honours that invariant; a
31
+ # masked lookup here would honour the letter of it while breaking the
32
+ # feature.
33
+ #
34
+ # ## Reading the 'none' label
35
+ #
36
+ # 'none' means "the database was consulted and did not list this address".
37
+ # Anonymizer databases are allow-list-by-omission — an address absent from
38
+ # the file is simply not a known egress — so absence is a real answer, not
39
+ # a miss. It is NOT a positive assertion that the address is a residential
40
+ # user, and it is only as fresh as the database file. '**' is reserved for
41
+ # "no database, or the lookup failed" — genuinely no answer.
42
+ #
43
+ # @example Configuring
44
+ # otto.configure_ip_privacy(
45
+ # anonymizer: true,
46
+ # anonymizer_db_path: 'data/GeoIP2-Anonymous-IP.mmdb',
47
+ # )
48
+ #
49
+ # @example Reading
50
+ # req.anonymizer # => 'tor' | 'vpn' | 'none' | '**' | nil
51
+ # env['otto.privacy.anonymizer']
52
+ #
53
+ class AnonymizerResolver
54
+ # Returned when classification is enabled but no database answered.
55
+ UNKNOWN = '**'
56
+
57
+ # Returned when the database was consulted and did not list the address.
58
+ NONE = 'none'
59
+
60
+ # Database flag => label, in precedence order. An address can carry
61
+ # several flags at once (a Tor exit node hosted at a cloud provider sets
62
+ # both +is_tor_exit_node+ and +is_hosting_provider+); the FIRST match
63
+ # wins, so the most specific and most access-relevant classification is
64
+ # what surfaces. Ordering rationale: Tor and public proxies are
65
+ # deliberate anonymity, commercial VPNs next, then residential proxies
66
+ # (frequently abuse infrastructure), then hosting — which is merely "not
67
+ # an eyeball network" and the weakest signal of the set.
68
+ CLASSIFICATIONS = [
69
+ %w[is_tor_exit_node tor],
70
+ %w[is_public_proxy proxy],
71
+ %w[is_anonymous_vpn vpn],
72
+ %w[is_residential_proxy residential_proxy],
73
+ %w[is_hosting_provider hosting],
74
+ # Generic catch-all: the provider says "anonymous" without saying how.
75
+ # Last, so it never masks a specific classification above.
76
+ %w[is_anonymous anonymous],
77
+ ].freeze
78
+
79
+ # Every label this resolver can emit, for consumers building zone rules.
80
+ LABELS = (CLASSIFICATIONS.map(&:last) + [NONE, UNKNOWN]).freeze
81
+
82
+ class << self
83
+ # Classify an IP address.
84
+ #
85
+ # @param ip [String] the UNMASKED client IP (see the class docs)
86
+ # @param config [Otto::Privacy::Config, nil] privacy configuration
87
+ # @return [String] one of {LABELS}
88
+ def resolve(ip, config = nil)
89
+ return UNKNOWN if ip.nil? || ip.empty?
90
+
91
+ check_anonymizer_database(ip, config) || UNKNOWN
92
+ end
93
+
94
+ private
95
+
96
+ # Look the address up in the configured anonymizer database.
97
+ #
98
+ # No masking: see the class documentation for why this resolver is the
99
+ # one exception, and note the record is reduced to a label before it
100
+ # returns, so the address itself goes no further.
101
+ #
102
+ # @return [String, nil] a label, or nil to fall through to UNKNOWN
103
+ def check_anonymizer_database(ip, config)
104
+ reader = config&.anonymizer_db_reader
105
+ return nil unless reader
106
+
107
+ classify(reader.get(ip))
108
+ rescue StandardError => e
109
+ warn "AnonymizerResolver database lookup error: #{e.message}" if $DEBUG
110
+ nil
111
+ end
112
+
113
+ # Reduce a database record to a single label.
114
+ #
115
+ # A nil record means "not listed", which for an anonymizer database is
116
+ # the meaningful answer NONE rather than a miss — these files record
117
+ # only flagged addresses. A non-Hash record is a malformed answer and
118
+ # falls through to UNKNOWN.
119
+ #
120
+ # @return [String, nil]
121
+ def classify(record)
122
+ return NONE if record.nil?
123
+ return nil unless record.is_a?(Hash)
124
+
125
+ match = CLASSIFICATIONS.find { |flag, _label| flagged?(record[flag]) }
126
+ match ? match.last : NONE
127
+ end
128
+
129
+ # MaxMind omits these keys entirely when false, so absent and false are
130
+ # the same answer. Accept the string forms too, since MMDB builds from
131
+ # other vendors sometimes encode the flags as text.
132
+ def flagged?(value)
133
+ value == true || value.to_s == 'true' || value.to_s == '1'
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,149 @@
1
+ # lib/otto/privacy/asn_resolver.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Privacy
7
+ # Autonomous System Number (ASN) resolution for IP addresses
8
+ #
9
+ # Provides the network operator an address belongs to, as a privacy-safe
10
+ # label a downstream allow/deny rule can compare directly. Resolution is
11
+ # database-only and operates on Otto's already-MASKED IP, exactly as
12
+ # {GeoResolver}'s database fallback does — the unmasked address never
13
+ # reaches this resolver.
14
+ #
15
+ # Resolution order (first hit wins), when a privacy Config is supplied:
16
+ # 1. Local MMDB lookup, masked before lookup (Config#asn_db_reader)
17
+ # 2. '**' (unknown)
18
+ #
19
+ # Resolution is honest: when no database resolves an ASN, the answer is
20
+ # '**' — never a guess. A caller can therefore distinguish three states:
21
+ # nil (ASN resolution is switched off), '**' (on, but no answer), and a
22
+ # real label.
23
+ #
24
+ # ## Why database-only
25
+ #
26
+ # Unlike country, no CDN publishes a client-ASN header with meaningful
27
+ # deployment, so there is no header tier to trust. Staying database-only
28
+ # also keeps ASN clear of the geo_header/trusted_proxy_depth boot conflict
29
+ # ({Otto::Security::Config::GEO_HEADER_DEPTH_CONFLICT_MESSAGE}): there is
30
+ # no header to be silently ignored under count-based proxy trust.
31
+ #
32
+ # ## Masking and accuracy
33
+ #
34
+ # IPv4 BGP routes are not announced longer than /24, so a /24-masked
35
+ # address lands in the same announced prefix — and therefore the same
36
+ # ASN — as the real one. That equivalence is what makes a masked lookup
37
+ # honest here, and it is weaker than it looks for IPv6: at
38
+ # +octet_precision: 1+ Otto zeroes the last 80 bits (a /48), which is
39
+ # coarser than many IPv6 announcements. Treat IPv6 ASN as best-effort.
40
+ #
41
+ # @example Configuring
42
+ # otto.configure_ip_privacy(asn: true, asn_db_path: 'data/GeoLite2-ASN.mmdb')
43
+ #
44
+ # @example Reading
45
+ # req.asn # => 'AS15169' | '**' | nil
46
+ # env['otto.privacy.asn']
47
+ #
48
+ class AsnResolver
49
+ # Returned when ASN resolution is enabled but nothing resolved. Shared
50
+ # spelling with {GeoResolver::UNKNOWN} so consumers can treat every
51
+ # privacy label the same way.
52
+ UNKNOWN = '**'
53
+
54
+ # Reserved ASNs that carry no operator meaning: 0 is "reserved by the
55
+ # IANA" (RFC 7607) and 23456 is the AS_TRANS placeholder a 2-byte-only
56
+ # speaker substitutes for a 4-byte ASN (RFC 6793). A database that
57
+ # returns either has told us nothing, so both resolve to UNKNOWN rather
58
+ # than being dressed up as an answer.
59
+ RESERVED = [0, 23_456].freeze
60
+
61
+ # Highest assignable ASN; 4_294_967_295 is reserved (RFC 7300).
62
+ MAX_ASN = 4_294_967_294
63
+
64
+ class << self
65
+ # Resolve an ASN label for an IP address.
66
+ #
67
+ # @param ip [String] the ALREADY-MASKED client IP
68
+ # @param config [Otto::Privacy::Config, nil] privacy configuration
69
+ # @return [String] 'AS<number>' or {UNKNOWN}
70
+ def resolve(ip, config = nil)
71
+ return UNKNOWN if ip.nil? || ip.empty?
72
+
73
+ check_asn_database(ip, config) || UNKNOWN
74
+ end
75
+
76
+ private
77
+
78
+ # Look the masked IP up in the configured ASN database.
79
+ #
80
+ # The reader is any object responding to +#get(ip)+. A database read
81
+ # must never crash a request, so every StandardError falls through to
82
+ # "unknown" — the same posture {GeoResolver.check_geo_database} takes.
83
+ #
84
+ # @return [String, nil] 'AS<number>', or nil to fall through
85
+ def check_asn_database(ip, config)
86
+ reader = config&.asn_db_reader
87
+ return nil unless reader
88
+
89
+ # Re-mask defensively: masking is idempotent, so this costs nothing
90
+ # for an already-masked address and closes the hole if a caller ever
91
+ # hands us a raw one directly.
92
+ lookup_ip = IPPrivacy.mask_ip(ip, config.octet_precision) || ip
93
+ format_asn(extract_db_asn(reader.get(lookup_ip)))
94
+ rescue StandardError => e
95
+ warn "AsnResolver database lookup error: #{e.message}" if $DEBUG
96
+ nil
97
+ end
98
+
99
+ # Pull the AS number out of a database record.
100
+ #
101
+ # MaxMind's GeoLite2-ASN stores a flat +autonomous_system_number+ as a
102
+ # uint32. Some combined builds nest it under an 'asn' map, and a few
103
+ # emit the bare key 'asn'. Accept all three; anything else is not an
104
+ # answer.
105
+ #
106
+ # NOTE: the value is an Integer, not a String — the opposite of
107
+ # {GeoResolver.extract_db_country}'s terminal guard. Copying that
108
+ # method's String check here would discard every real hit.
109
+ #
110
+ # @return [Integer, nil]
111
+ def extract_db_asn(result)
112
+ return nil unless result.is_a?(Hash)
113
+
114
+ asn = result['asn']
115
+ number =
116
+ if asn.is_a?(Hash)
117
+ asn['autonomous_system_number'] || asn['number']
118
+ else
119
+ result['autonomous_system_number'] || asn
120
+ end
121
+ valid_asn?(number) ? number : nil
122
+ end
123
+
124
+ # @return [Boolean] whether the number is an assignable, meaningful ASN
125
+ def valid_asn?(number)
126
+ number.is_a?(Integer) &&
127
+ number.positive? &&
128
+ number <= MAX_ASN &&
129
+ !RESERVED.include?(number)
130
+ end
131
+
132
+ # Render as the conventional 'AS<number>' text form.
133
+ #
134
+ # A String — never the bare Integer — because the whole resolution
135
+ # chain, and Otto::Request's env fallbacks, treat a falsey value as
136
+ # "keep looking". A label is also what a downstream zone rule compares
137
+ # against, and it leaves room for the '**' sentinel that an Integer
138
+ # representation has no way to express.
139
+ #
140
+ # @return [String, nil]
141
+ def format_asn(number)
142
+ return nil if number.nil?
143
+
144
+ "AS#{number}"
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -24,6 +24,10 @@ class Otto
24
24
  # config = Otto::Privacy::Config.new
25
25
  # config.octet_precision = 2 # Mask 2 octets instead of 1
26
26
  #
27
+ # rubocop:disable Metrics/ClassLength -- three parallel database
28
+ # configurations (geo, ASN, anonymizer) live here by design: each is a
29
+ # thin, symmetric writer/reader/loader trio, and splitting them into
30
+ # modules would hide the symmetry that makes them reviewable.
27
31
  class Config
28
32
  include Otto::Core::Freezable
29
33
 
@@ -49,8 +53,10 @@ class Otto
49
53
  audit: { disabled: true }.freeze,
50
54
  }.freeze
51
55
 
52
- attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips
53
- attr_reader :disabled, :correlation_secret, :geo_header, :geo_db_path
56
+ attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips,
57
+ :asn_enabled, :anonymizer_enabled
58
+ attr_reader :disabled, :correlation_secret, :geo_header, :geo_db_path,
59
+ :asn_db_path, :anonymizer_db_path
54
60
 
55
61
  # Class-level rotation key storage (mutable, not frozen with instances)
56
62
  # This is stored at the class level so it persists across frozen config instances
@@ -82,6 +88,23 @@ class Otto
82
88
  # @option options [#get] :geo_db_reader Bring-your-own MMDB reader (any object responding
83
89
  # to #get, e.g. a MaxMind::DB or a compatible reader). Overrides :geo_db_path when set,
84
90
  # so the reader choice stays independent of Otto. Default nil.
91
+ # @option options [Boolean] :asn_enabled Enable ASN resolution (default: FALSE — unlike
92
+ # :geo_enabled, this signal is opt-in, so a deployment that never asks for it pays
93
+ # nothing and no database is opened)
94
+ # @option options [String] :asn_db_path Filesystem path to a MaxMind-format (.mmdb) ASN
95
+ # database (looked up on the already MASKED IP, like the geo database). Requires the
96
+ # 'maxmind-db' gem. A bad/unreadable path raises at boot, not per-request. Default nil.
97
+ # @option options [#get] :asn_db_reader Bring-your-own MMDB reader for ASN lookups (any
98
+ # object responding to #get). Overrides :asn_db_path when set. Default nil.
99
+ # @option options [Boolean] :anonymizer_enabled Enable anonymizer (Tor/VPN/proxy/hosting)
100
+ # classification (default: FALSE — opt-in, same as :asn_enabled)
101
+ # @option options [String] :anonymizer_db_path Filesystem path to a MaxMind-format (.mmdb)
102
+ # anonymous-IP database. Looked up on the UNMASKED IP — anonymizer data lists individual
103
+ # egress nodes at /32, so a masked lookup would answer for the node's neighbours; only
104
+ # the resulting label leaves the resolver. Requires the 'maxmind-db' gem. A bad path
105
+ # raises at boot. Default nil.
106
+ # @option options [#get] :anonymizer_db_reader Bring-your-own MMDB reader for anonymizer
107
+ # lookups (any object responding to #get). Overrides :anonymizer_db_path. Default nil.
85
108
  # @option options [Boolean] :disabled Disable privacy entirely (default: false)
86
109
  # @option options [Boolean] :mask_private_ips Mask private/localhost IPs (default: false)
87
110
  # @option options [String] :correlation_secret A secret string that turns
@@ -119,8 +142,24 @@ class Otto
119
142
  @geo_db_override = nil # reader injected via geo_db_reader= (wins over path)
120
143
  self.geo_header = options[:geo_header] # canonicalized to an HTTP_* env key (or nil)
121
144
  self.geo_db_reader = options[:geo_db_reader] if options.key?(:geo_db_reader)
122
- @geo_db_path = normalize_geo_db_path(options[:geo_db_path])
145
+ @geo_db_path = normalize_db_path(options[:geo_db_path])
123
146
  load_geo_database! # build/attach the reader now so a bad path fails at boot
147
+
148
+ # ASN enrichment (opt-in, boot-time only). Same two-ivar shape as geo.
149
+ @asn_enabled = options.fetch(:asn_enabled, false)
150
+ @asn_db_reader = nil
151
+ @asn_db_override = nil
152
+ self.asn_db_reader = options[:asn_db_reader] if options.key?(:asn_db_reader)
153
+ @asn_db_path = normalize_db_path(options[:asn_db_path])
154
+ load_asn_database!
155
+
156
+ # Anonymizer classification (opt-in, boot-time only).
157
+ @anonymizer_enabled = options.fetch(:anonymizer_enabled, false)
158
+ @anonymizer_db_reader = nil
159
+ @anonymizer_db_override = nil
160
+ self.anonymizer_db_reader = options[:anonymizer_db_reader] if options.key?(:anonymizer_db_reader)
161
+ @anonymizer_db_path = normalize_db_path(options[:anonymizer_db_path])
162
+ load_anonymizer_database!
124
163
  end
125
164
 
126
165
  # Set the stable correlation secret, validating its type up front.
@@ -163,7 +202,42 @@ class Otto
163
202
  #
164
203
  # @param value [String, nil] filesystem path to a .mmdb file, or nil
165
204
  def geo_db_path=(value)
166
- @geo_db_path = normalize_geo_db_path(value)
205
+ @geo_db_path = normalize_db_path(value)
206
+ end
207
+
208
+ # Path to a MaxMind-format ASN database. See {#geo_db_path=}; the same
209
+ # boot-time contract applies — call {#load_asn_database!} to attach it.
210
+ #
211
+ # @param value [String, nil] filesystem path to a .mmdb file, or nil
212
+ def asn_db_path=(value)
213
+ @asn_db_path = normalize_db_path(value)
214
+ end
215
+
216
+ # Path to a MaxMind-format anonymous-IP database. See {#geo_db_path=};
217
+ # call {#load_anonymizer_database!} to attach it.
218
+ #
219
+ # @param value [String, nil] filesystem path to a .mmdb file, or nil
220
+ def anonymizer_db_path=(value)
221
+ @anonymizer_db_path = normalize_db_path(value)
222
+ end
223
+
224
+ # Replace one enrichment database path only after its new reader has
225
+ # opened successfully. Used by the boot-time configuration path; direct
226
+ # callers should normally use Otto#configure_ip_privacy.
227
+ #
228
+ # @param prefix [:asn, :anonymizer] database source to replace
229
+ # @param value [String, nil] new database path
230
+ # @return [void]
231
+ # @raise [ArgumentError] if an enabled signal's path cannot be opened
232
+ # @api private
233
+ def replace_enrichment_database_path!(prefix, value)
234
+ path = normalize_db_path(value)
235
+ enabled = instance_variable_get(:"@#{prefix}_enabled")
236
+ reader = path && enabled ? build_maxmind_reader(path, option_name: "#{prefix}_db_path") : nil
237
+
238
+ instance_variable_set(:"@#{prefix}_db_path", path)
239
+ instance_variable_set(:"@#{prefix}_db_override", nil)
240
+ instance_variable_set(:"@#{prefix}_db_reader", reader)
167
241
  end
168
242
 
169
243
  # Inject a ready-made MMDB reader (any object responding to #get).
@@ -202,6 +276,45 @@ class Otto
202
276
  @geo_enabled ? @geo_db_reader : nil
203
277
  end
204
278
 
279
+ # Inject a ready-made MMDB reader for ASN lookups. See {#geo_db_reader=}.
280
+ #
281
+ # @param reader [#get, nil] MMDB-compatible reader, or nil to clear
282
+ # @raise [ArgumentError] if reader does not respond to :get
283
+ def asn_db_reader=(reader)
284
+ unless reader.nil? || reader.respond_to?(:get)
285
+ raise ArgumentError, "asn_db_reader must respond to :get, got: #{reader.class}"
286
+ end
287
+
288
+ @asn_db_override = reader
289
+ end
290
+
291
+ # The effective ASN reader, or nil when ASN resolution is off.
292
+ #
293
+ # @return [#get, nil]
294
+ def asn_db_reader
295
+ @asn_enabled ? @asn_db_reader : nil
296
+ end
297
+
298
+ # Inject a ready-made MMDB reader for anonymizer lookups.
299
+ # See {#geo_db_reader=}.
300
+ #
301
+ # @param reader [#get, nil] MMDB-compatible reader, or nil to clear
302
+ # @raise [ArgumentError] if reader does not respond to :get
303
+ def anonymizer_db_reader=(reader)
304
+ unless reader.nil? || reader.respond_to?(:get)
305
+ raise ArgumentError, "anonymizer_db_reader must respond to :get, got: #{reader.class}"
306
+ end
307
+
308
+ @anonymizer_db_override = reader
309
+ end
310
+
311
+ # The effective anonymizer reader, or nil when classification is off.
312
+ #
313
+ # @return [#get, nil]
314
+ def anonymizer_db_reader
315
+ @anonymizer_enabled ? @anonymizer_db_reader : nil
316
+ end
317
+
205
318
  # Build/attach the geo database reader for the current configuration.
206
319
  #
207
320
  # Boot-time only. Resolves the effective reader (injected override wins
@@ -224,6 +337,39 @@ class Otto
224
337
  end
225
338
  end
226
339
 
340
+ # Build/attach the ASN database reader. See {#load_geo_database!} — same
341
+ # boot-time contract, same override-wins-over-path resolution.
342
+ #
343
+ # @return [void]
344
+ # @raise [ArgumentError] if the path is unreadable or maxmind-db is absent
345
+ def load_asn_database!
346
+ @asn_db_reader = nil
347
+ return unless @asn_enabled
348
+
349
+ @asn_db_reader =
350
+ if @asn_db_override
351
+ @asn_db_override
352
+ elsif @asn_db_path
353
+ build_maxmind_reader(@asn_db_path, option_name: 'asn_db_path')
354
+ end
355
+ end
356
+
357
+ # Build/attach the anonymizer database reader. See {#load_geo_database!}.
358
+ #
359
+ # @return [void]
360
+ # @raise [ArgumentError] if the path is unreadable or maxmind-db is absent
361
+ def load_anonymizer_database!
362
+ @anonymizer_db_reader = nil
363
+ return unless @anonymizer_enabled
364
+
365
+ @anonymizer_db_reader =
366
+ if @anonymizer_db_override
367
+ @anonymizer_db_override
368
+ elsif @anonymizer_db_path
369
+ build_maxmind_reader(@anonymizer_db_path, option_name: 'anonymizer_db_path')
370
+ end
371
+ end
372
+
227
373
  # Look up the preset hash for a named profile, failing fast on typos.
228
374
  #
229
375
  # @param profile [Symbol, String] one of the {PROFILES} keys
@@ -368,11 +514,12 @@ class Otto
368
514
 
369
515
  private
370
516
 
371
- # Normalize a geo_db_path option to a non-empty String or nil.
517
+ # Normalize a database-path option to a non-empty String or nil. Shared by
518
+ # the geo, ASN and anonymizer paths — the rule is identical for all three.
372
519
  #
373
520
  # @param value [String, nil] raw path option
374
521
  # @return [String, nil]
375
- def normalize_geo_db_path(value)
522
+ def normalize_db_path(value)
376
523
  return nil if value.nil?
377
524
 
378
525
  path = value.to_s.strip
@@ -390,22 +537,22 @@ class Otto
390
537
  # @param path [String] filesystem path to a .mmdb file
391
538
  # @return [MaxMind::DB] in-memory reader
392
539
  # @raise [ArgumentError] if the path is unreadable or the gem is missing
393
- def build_maxmind_reader(path)
394
- raise ArgumentError, "geo_db_path is not readable: #{path.inspect}" unless File.readable?(path)
540
+ def build_maxmind_reader(path, option_name: 'geo_db_path')
541
+ raise ArgumentError, "#{option_name} is not readable: #{path.inspect}" unless File.readable?(path)
395
542
 
396
543
  begin
397
544
  require 'maxmind/db'
398
545
  rescue LoadError
399
546
  raise ArgumentError,
400
- "geo_db_path is set (#{path.inspect}) but the 'maxmind-db' gem is not available. " \
547
+ "#{option_name} is set (#{path.inspect}) but the 'maxmind-db' gem is not available. " \
401
548
  "Add `gem 'maxmind-db'` to your Gemfile, or inject your own reader via " \
402
- 'configure_ip_privacy(geo_db_reader: ...).'
549
+ "configure_ip_privacy(#{option_name.sub('_path', '_reader')}: ...)."
403
550
  end
404
551
 
405
552
  begin
406
553
  MaxMind::DB.new(path, mode: MaxMind::DB::MODE_MEMORY)
407
554
  rescue StandardError => e
408
- raise ArgumentError, "Failed to open geo_db_path #{path.inspect}: #{e.class}: #{e.message}"
555
+ raise ArgumentError, "Failed to open #{option_name} #{path.inspect}: #{e.class}: #{e.message}"
409
556
  end
410
557
  end
411
558
 
@@ -475,5 +622,6 @@ class Otto
475
622
  key
476
623
  end
477
624
  end
625
+ # rubocop:enable Metrics/ClassLength
478
626
  end
479
627
  end
@@ -102,10 +102,23 @@ class Otto
102
102
  # them into a hash would only obscure the supported settings.
103
103
  def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil,
104
104
  correlation_secret: nil, geo_header: nil, geo_db_path: nil,
105
- geo_db_reader: nil, profile: nil)
105
+ geo_db_reader: nil, profile: nil, asn: nil, asn_db_path: nil,
106
+ asn_db_reader: nil, anonymizer: nil, anonymizer_db_path: nil,
107
+ anonymizer_db_reader: nil)
106
108
  # rubocop:enable Metrics/ParameterLists
107
109
  ensure_not_frozen!
108
110
  config = @security_config.ip_privacy_config
111
+
112
+ # Geo headers are honored only for peers matching enumerated CIDR
113
+ # matchers, never for count-trusted hops, so a geo_header configured
114
+ # alongside depth mode could never be consulted. Fail loud here
115
+ # (depth-then-geo order; the trusted_proxy_depth= setter catches
116
+ # geo-then-depth). A blank geo_header canonicalizes to nil ("clear"),
117
+ # which stays legal under depth.
118
+ if Otto::Privacy::Config.canonicalize_geo_header(geo_header) &&
119
+ @security_config.trusted_proxy_depth_mode?
120
+ raise ArgumentError, Otto::Security::Config::GEO_HEADER_DEPTH_CONFLICT_MESSAGE
121
+ end
109
122
  knobs = { profile: profile, octet_precision: octet_precision,
110
123
  hash_rotation: hash_rotation, geo: geo,
111
124
  correlation_secret: correlation_secret, redis: redis }
@@ -119,6 +132,8 @@ class Otto
119
132
 
120
133
  apply_geo_config(config, geo: geo, geo_header: geo_header,
121
134
  geo_db_path: geo_db_path, geo_db_reader: geo_db_reader)
135
+ apply_enrichment_signal(config, :asn, asn, asn_db_path, asn_db_reader)
136
+ apply_enrichment_signal(config, :anonymizer, anonymizer, anonymizer_db_path, anonymizer_db_reader)
122
137
  end
123
138
 
124
139
  private
@@ -173,6 +188,54 @@ class Otto
173
188
 
174
189
  config.load_geo_database! if geo_touched
175
190
  end
191
+
192
+ # Apply one opt-in enrichment signal (ASN or anonymizer classification).
193
+ #
194
+ # Same contract as {#apply_geo_config}: nil means "leave unchanged", a
195
+ # reader supplied in this call wins over a path, and a path supplied on
196
+ # its own clears any prior injected reader so it actually takes effect.
197
+ # Any touched knob triggers a boot-time (re)load so a bad path fails
198
+ # here, not on the first request that needs a lookup.
199
+ #
200
+ # These deliberately live outside apply_privacy_knobs. That runs twice —
201
+ # once against a shallow config.dup for the dry-run validation — and a
202
+ # dup shares reader ivars with the original, so loading a database there
203
+ # would touch live state during what is supposed to be a rehearsal.
204
+ #
205
+ # @param config [Otto::Privacy::Config] the privacy config to mutate
206
+ # @param prefix [Symbol] :asn or :anonymizer, selecting the config members
207
+ # @api private
208
+ def apply_enrichment_signal(config, prefix, enabled, path, reader)
209
+ previous_enabled = config.public_send(:"#{prefix}_enabled")
210
+ config.public_send(:"#{prefix}_enabled=", enabled) unless enabled.nil?
211
+ source_replaced = replace_db_source?(config, prefix, path, reader)
212
+ config.public_send(:"load_#{prefix}_database!") if !source_replaced && [enabled, path, reader].any? { |v| !v.nil? }
213
+ rescue StandardError
214
+ # A path replacement is validated before it is committed, but restoring
215
+ # the enabled flag here also keeps a combined `enabled: ..., path: ...`
216
+ # call all-or-nothing when the replacement cannot be opened.
217
+ config.public_send(:"#{prefix}_enabled=", previous_enabled) unless previous_enabled.nil?
218
+ raise
219
+ end
220
+
221
+ # Reader-vs-path precedence for one enrichment signal, mirroring the geo
222
+ # branch in {#apply_geo_config}.
223
+ #
224
+ # @api private
225
+ def replace_db_source?(config, prefix, path, reader)
226
+ if !reader.nil?
227
+ config.public_send(:"#{prefix}_db_reader=", reader)
228
+ config.public_send(:"#{prefix}_db_path=", path) unless path.nil?
229
+ false
230
+ elsif !path.nil?
231
+ # Build the replacement before changing the active source. A failed
232
+ # path must leave the previous reader available for future requests.
233
+ config.replace_enrichment_database_path!(prefix, path)
234
+ true
235
+ else
236
+ false
237
+ end
238
+ end
176
239
  end
177
240
  end
178
241
  end
@@ -22,8 +22,8 @@ class Otto
22
22
  #
23
23
  class RedactedFingerprint
24
24
  attr_reader :session_id, :timestamp, :masked_ip, :hashed_ip,
25
- :country, :anonymized_ua, :request_path,
26
- :request_method, :referer
25
+ :country, :asn, :anonymizer, :anonymized_ua,
26
+ :request_path, :request_method, :referer
27
27
 
28
28
  # IP-bearing forwarded headers overwritten with the masked IP in the
29
29
  # geo-resolution env view. Mirrors the set
@@ -60,6 +60,17 @@ class Otto
60
60
  @country = if config.geo_enabled
61
61
  GeoResolver.resolve(@masked_ip, geo_env(env), config, headers_trusted: geo_headers_trusted)
62
62
  end
63
+ # ASN keeps the masked-IP contract: IPv4 routes are not announced longer
64
+ # than /24, so a masked address falls in the same announced prefix and
65
+ # therefore the same ASN.
66
+ @asn = AsnResolver.resolve(@masked_ip, config) if config.asn_enabled
67
+ # The anonymizer is the one lookup that gets the REAL address, for the
68
+ # same reason hashed_ip above does: masking would destroy the answer.
69
+ # Anonymizer databases list individual egress nodes at or near /32, so a
70
+ # /24-masked lookup would report on the node's neighbours instead of the
71
+ # node. Only the resulting label is retained — the address goes no
72
+ # further than the resolver, exactly as it goes no further than hash_ip.
73
+ @anonymizer = AnonymizerResolver.resolve(remote_ip, config) if config.anonymizer_enabled
63
74
  @anonymized_ua = anonymize_user_agent(env['HTTP_USER_AGENT'])
64
75
  @request_path = env['PATH_INFO']
65
76
  @request_method = env['REQUEST_METHOD']
@@ -78,6 +89,8 @@ class Otto
78
89
  masked_ip: @masked_ip,
79
90
  hashed_ip: @hashed_ip,
80
91
  country: @country,
92
+ asn: @asn,
93
+ anonymizer: @anonymizer,
81
94
  anonymized_ua: @anonymized_ua,
82
95
  request_method: @request_method,
83
96
  request_path: @request_path,