aikido-zen 1.9.0.beta.3-arm64-linux → 1.9.0.beta.4-arm64-linux

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d47c525917403194ad5eccc4498beaed1f636238707b6b0ab5e5b9fcd3e605f
4
- data.tar.gz: e990ba5929f8a1eca2f2cb895bbdcab46eac0be4898dc0332cc85cb8c6239c46
3
+ metadata.gz: ada5e55768a0adb9c9311bf01868950a02636af957a14da9766e62a0e0fcb6a6
4
+ data.tar.gz: 8ace3f901aec31ba41afb86cac79dec53be98a60ec55226b65351aa417b73313
5
5
  SHA512:
6
- metadata.gz: 217c17f0dd195b33401cb73971b74174dde3f1101bd1f07be46a19317e808d1b887e4bdb47088e80c53b97dd9c7795bd9fe8628bec2d87a022480e75af9a1cea
7
- data.tar.gz: fcace5b55b5ffcbcb5b76c019addc01d730b7d301ba436afa31d85b61f19219592cb97d284907423f0a293848d2d3209559b63dd9e2855a9c2ccb66dfc602fba
6
+ metadata.gz: da321436b2e4cdb30c8fa6d3456c771bb9bb56f44afd9b5269a5f5cef1f6af8bdb59ab4dca7a05ce2336d46ac98b57aa407307514270cb7ec7f55707d2fd857c
7
+ data.tar.gz: 9ae53b66ccd877aae99d0fd59735c23e5e6bc9ffcc71231835eb52e0f4cfce4f51ebb594458df56dcbb9ec855646c46178e59f26040bd7a4a1d870056fc957cb
data/docs/rails.md CHANGED
@@ -75,6 +75,20 @@ class ApplicationController < ActionController::Base
75
75
  end
76
76
  ```
77
77
 
78
+ ### Rate limiting groups
79
+
80
+ To limit the number of requests for a group of users, you can use the
81
+ `Aikido::Zen.set_rate_limit_group` method. For example, this is useful if you
82
+ want to limit the number of requests per team or company.
83
+
84
+ ```ruby
85
+ Aikido::Zen.set_rate_limit_group(current_user.team_id)
86
+ ```
87
+
88
+ > [!NOTE]
89
+ > If a rate limiting group is set, the configured rate limits are only applied
90
+ > to the group and not to individual users or IP addresses.
91
+
78
92
  ## Configuration
79
93
 
80
94
  Zen exposes its configuration object to the Rails configuration, which you can
@@ -134,12 +134,19 @@ module Aikido::Zen
134
134
  def handle_attack(attack)
135
135
  attack.will_be_blocked! if Aikido::Zen.blocking_mode?
136
136
 
137
- @config.logger.error(
138
- format("Zen has %s a %s: %s", attack.blocked? ? "blocked" : "detected", attack.humanized_name, attack.as_json.to_json)
139
- )
140
- report(Events::Attack.new(attack: attack)) if @api_client.can_make_requests?
137
+ # Recording the attack must never prevent us from blocking it.
138
+ begin
139
+ @config.logger.error(
140
+ format("Zen has %s a %s: %s", attack.blocked? ? "blocked" : "detected", attack.humanized_name, attack.as_json.to_json)
141
+ )
142
+
143
+ report(Events::Attack.new(attack: attack)) if @api_client.can_make_requests?
144
+
145
+ @collector.track_attack(attack)
146
+ rescue => err
147
+ @config.logger.error("Zen failed to record an attack: #{err.class}: #{err.message}")
148
+ end
141
149
 
142
- @collector.track_attack(attack)
143
150
  raise attack if attack.blocked?
144
151
  end
145
152
 
@@ -69,8 +69,10 @@ module Aikido::Zen
69
69
  end
70
70
 
71
71
  def metadata
72
+ safe_filepath = Aikido::Zen::Helpers.encode_safely(filepath)
73
+
72
74
  {
73
- "filename" => filepath
75
+ "filename" => safe_filepath
74
76
  }
75
77
  end
76
78
 
@@ -106,8 +108,10 @@ module Aikido::Zen
106
108
  end
107
109
 
108
110
  def metadata
111
+ safe_command = Aikido::Zen::Helpers.encode_safely(@command)
112
+
109
113
  {
110
- "command" => @command
114
+ "command" => safe_command
111
115
  }
112
116
  end
113
117
 
@@ -138,8 +142,10 @@ module Aikido::Zen
138
142
  end
139
143
 
140
144
  def metadata
145
+ safe_query = Aikido::Zen::Helpers.encode_safely(@query)
146
+
141
147
  {
142
- "sql" => @query,
148
+ "sql" => safe_query,
143
149
  "dialect" => @dialect.name,
144
150
  "failedToTokenize" => @failed_to_tokenize || nil
145
151
  }.compact
@@ -56,6 +56,10 @@ module Aikido::Zen
56
56
  # each initial heartbeat event.
57
57
  attr_accessor :initial_heartbeat_delays
58
58
 
59
+ # @return [Integer] the maximum number of levels deep we want to traverse
60
+ # when extracting payloads from a nested request property. Defaults to 64.
61
+ attr_accessor :extract_payloads_max_depth
62
+
59
63
  # @return [Symbol] the agent mode for forked worker processes. Can be set
60
64
  # through the AIKIDO_AGENT_MODE environment variable.
61
65
  attr_reader :agent_mode
@@ -107,6 +111,10 @@ module Aikido::Zen
107
111
  # the oldest seen users.
108
112
  attr_accessor :max_users_tracked
109
113
 
114
+ # @return [Integer] the maximum number of custom events tracked per request.
115
+ # Defaults to 25.
116
+ attr_accessor :max_custom_events
117
+
110
118
  # @return [Proc{(Aikido::Zen::Request, Symbol, reason: String=nil) => Array(Integer, Hash, #each)}]
111
119
  # Rack handler used to respond to requests from IPs, users or others blocked in the Aikido
112
120
  # dashboard.
@@ -249,6 +257,7 @@ module Aikido::Zen
249
257
  self.api_timeouts = 10
250
258
  self.polling_interval = 60 # 1 min
251
259
  self.initial_heartbeat_delays = [30, 60 * 2] # 30 sec, 2 min
260
+ self.extract_payloads_max_depth = 64
252
261
  self.agent_mode = ENV.fetch("AIKIDO_AGENT_MODE", "shared")
253
262
  self.worker_process_polling_interval = 10
254
263
  self.worker_process_polling_jitter = 10
@@ -261,6 +270,7 @@ module Aikido::Zen
261
270
  self.max_compressed_stats = 100
262
271
  self.max_outbound_connections = 200
263
272
  self.max_users_tracked = 1000
273
+ self.max_custom_events = 25
264
274
  self.blocked_responder = DEFAULT_BLOCKED_RESPONDER
265
275
  self.rate_limited_responder = DEFAULT_RATE_LIMITED_RESPONDER
266
276
  self.rate_limiting_discriminator = DEFAULT_RATE_LIMITING_DISCRIMINATOR
@@ -442,7 +452,13 @@ module Aikido::Zen
442
452
 
443
453
  # @!visibility private
444
454
  DEFAULT_RATE_LIMITING_DISCRIMINATOR = ->(request) {
445
- request.actor ? "actor:#{request.actor.id}" : request.client_ip
455
+ rate_limiting_group = request.rate_limiting_group
456
+ return "group:#{rate_limiting_group}" unless rate_limiting_group.nil?
457
+
458
+ actor = request.actor
459
+ return "actor:#{request.actor.id}" unless actor.nil?
460
+
461
+ request.client_ip
446
462
  }
447
463
  end
448
464
  end
@@ -38,6 +38,9 @@ module Aikido::Zen
38
38
  attr_accessor :idor_protection_enabled
39
39
  alias_method :idor_protection_enabled?, :idor_protection_enabled
40
40
 
41
+ # @return [Integer]
42
+ attr_accessor :custom_events
43
+
41
44
  # @param request [Rack::Request] a Request object that implements the
42
45
  # Rack::Request API, to which we will delegate behavior.
43
46
  # @param settings [Aikido::Zen::RuntimeSettings]
@@ -45,16 +48,20 @@ module Aikido::Zen
45
48
  # @yieldparam request [Rack::Request] the given request object.
46
49
  # @yieldreturn [Hash<Symbol, #flat_map>] map of payload source types
47
50
  # to the actual data from the request to populate them.
48
- def initialize(request, settings: Aikido::Zen.runtime_settings, &sources)
51
+ def initialize(request, zen: Aikido::Zen, config: zen.config, settings: zen.runtime_settings, &sources)
49
52
  @request = request
53
+ @config = config
50
54
  @settings = settings
51
55
  @payload_sources = sources
52
56
 
57
+ @max_depth = @config.extract_payloads_max_depth
58
+
53
59
  @metadata = {}
54
60
  @scanning = false
55
61
  @request_bypassed = nil
56
62
  @protection_disabled = false
57
63
  @idor_protection_enabled = false
64
+ @custom_events = 0
58
65
  end
59
66
 
60
67
  # Fetch some metadata stored in the Context.
@@ -102,31 +109,46 @@ module Aikido::Zen
102
109
  private
103
110
 
104
111
  # @!visibility private
105
- def extract_payloads_from(data, source_type, prefix = nil)
112
+ def extract_payloads_from(data, source_type, prefix = nil, depth = 0)
113
+ return [] if depth > @max_depth
114
+
106
115
  if data.is_a?(String)
107
116
  [Payload.new(data, source_type, prefix.to_s)]
108
117
  elsif data.respond_to?(:to_hash)
109
118
  data.to_hash.flat_map do |key, value|
110
- extract_payloads_from(value, source_type, [prefix, key].compact.join("."))
119
+ extract_payloads_from(value, source_type, [prefix, key].compact.join("."), depth + 1)
111
120
  end
112
121
  elsif data.respond_to?(:to_ary)
113
122
  array = data.to_ary
114
123
  return array if array.empty?
115
124
 
116
125
  payloads = array.flat_map.with_index do |value, index|
117
- extract_payloads_from(value, source_type, [prefix, index].compact.join("."))
126
+ extract_payloads_from(value, source_type, [prefix, index].compact.join("."), depth + 1)
118
127
  end
119
128
 
120
129
  unless Aikido::Zen.config.harden?
121
130
  # Special case for File.join given a possibly nested array of strings,
122
131
  # as might occur when a query parameter is an array.
123
- begin
124
- string = File.join__internal_for_aikido_zen(*array)
125
- if unsafe_path?(string)
126
- payloads << Payload.new(string, source_type, [prefix, "__File.join__"].compact.join("."))
132
+
133
+ # File.join recursively joins nested string arrays, and can overflow
134
+ # the stack given deeply nested arrays.
135
+ #
136
+ # Flatten array to max depth and check that the flattened array is an
137
+ # array of strings before calling File.join__internal_for_aikido_zen,
138
+ # only if the array was fully flattened, to prevent a stack overflow.
139
+ #
140
+ # Checking that all values are Strings handily prevents a TypeError
141
+ # from being raised.
142
+ flattened_array = array.flatten(@max_depth)
143
+ if flattened_array.all? { |string| string.is_a?(String) }
144
+ begin
145
+ string = File.join__internal_for_aikido_zen(*flattened_array)
146
+ if unsafe_path?(string)
147
+ payloads << Payload.new(string, source_type, [prefix, "__File.join__"].compact.join("."))
148
+ end
149
+ rescue
150
+ # Could not create special payload for File.join.
127
151
  end
128
- rescue
129
- # Could not create special payload for File.join.
130
152
  end
131
153
  end
132
154
 
@@ -29,6 +29,22 @@ module Aikido
29
29
 
30
30
  Regexp.new(regexp.source, regexp.options, timeout: timeout)
31
31
  end
32
+
33
+ # Returns a copy of the string safely encoded as UTF-8.
34
+ #
35
+ # Web servers may deliver request data as binary strings. These strings
36
+ # are relabeled as UTF-8 instead of being converted, because conversion
37
+ # would replace every non-ASCII byte with U+FFFD, even in valid UTF-8.
38
+ #
39
+ # @param string [String] the string
40
+ # @return [String] the valid UTF-8 string
41
+ def self.encode_safely(string)
42
+ if string.encoding == Encoding::BINARY
43
+ string.dup.force_encoding(Encoding::UTF_8).scrub
44
+ else
45
+ string.encode("UTF-8", invalid: :replace, undef: :replace)
46
+ end
47
+ end
32
48
  end
33
49
  end
34
50
  end
@@ -103,8 +103,8 @@ module Aikido::Zen
103
103
  # @raise [Aikido::Zen::InternalsError] if there's a problem loading or
104
104
  # calling libzen.
105
105
  def self.detect_sql_injection(query, input, dialect)
106
- query_bytes = encode_safely(query)
107
- input_bytes = encode_safely(input)
106
+ query_bytes = Aikido::Zen::Helpers.encode_safely(query)
107
+ input_bytes = Aikido::Zen::Helpers.encode_safely(input)
108
108
 
109
109
  query_ptr = FFI::MemoryPointer.new(:uint8, query_bytes.bytesize)
110
110
  input_ptr = FFI::MemoryPointer.new(:uint8, input_bytes.bytesize)
@@ -123,7 +123,7 @@ module Aikido::Zen
123
123
  end
124
124
 
125
125
  def self.idor_analyze_sql(query, dialect)
126
- query_bytes = encode_safely(query)
126
+ query_bytes = Aikido::Zen::Helpers.encode_safely(query)
127
127
  query_ptr = FFI::MemoryPointer.new(:uint8, query_bytes.bytesize)
128
128
  query_ptr.put_bytes(0, query_bytes)
129
129
 
@@ -134,13 +134,5 @@ module Aikido::Zen
134
134
  JSON.parse(result_json)
135
135
  end
136
136
  end
137
-
138
- class << self
139
- private
140
-
141
- def encode_safely(string)
142
- string.encode("UTF-8", invalid: :replace, undef: :replace)
143
- end
144
- end
145
137
  end
146
138
  end
@@ -24,10 +24,13 @@ module Aikido::Zen
24
24
  end
25
25
 
26
26
  def as_json
27
+ safe_value = Aikido::Zen::Helpers.encode_safely(value.to_s)
28
+ safe_path = Aikido::Zen::Helpers.encode_safely(path.to_s)
29
+
27
30
  {
28
- "payload" => value.to_s,
31
+ "payload" => safe_value,
29
32
  "source" => SOURCE_SERIALIZATIONS[source],
30
- "path" => ".#{path}"
33
+ "path" => ".#{safe_path}"
31
34
  }
32
35
  end
33
36
 
@@ -17,6 +17,11 @@ module Aikido::Zen
17
17
  # @see Aikido::Zen.track_user
18
18
  attr_accessor :actor
19
19
 
20
+ # The current rate limiting group, if set by the host app.
21
+ #
22
+ # @return [String, nil]
23
+ attr_accessor :rate_limiting_group
24
+
20
25
  # The current tenant, if set by the host app.
21
26
  #
22
27
  # @return [Integer, String, nil]
@@ -86,13 +91,17 @@ module Aikido::Zen
86
91
  end
87
92
 
88
93
  def as_json
94
+ safe_url = Aikido::Zen::Helpers.encode_safely(url)
95
+ safe_user_agent = Aikido::Zen::Helpers.encode_safely(user_agent) if user_agent
96
+ safe_route_path = Aikido::Zen::Helpers.encode_safely(route.path) if route&.path
97
+
89
98
  {
90
99
  "method" => request_method.upcase,
91
- "url" => url,
100
+ "url" => safe_url,
92
101
  "ipAddress" => client_ip,
93
- "userAgent" => user_agent,
102
+ "userAgent" => safe_user_agent,
94
103
  "source" => framework,
95
- "route" => route&.path
104
+ "route" => safe_route_path
96
105
  }
97
106
  end
98
107
 
@@ -40,8 +40,8 @@ module Aikido::Zen
40
40
  end
41
41
 
42
42
  def initialize(filepath, input)
43
- @filepath = filepath.downcase
44
- @input = input.downcase
43
+ @filepath = Aikido::Zen::Helpers.encode_safely(filepath).downcase
44
+ @input = Aikido::Zen::Helpers.encode_safely(input).downcase
45
45
  end
46
46
 
47
47
  def attack?
@@ -37,8 +37,8 @@ module Aikido::Zen
37
37
  # @param command [String]
38
38
  # @param input [String]
39
39
  def initialize(command, input)
40
- @command = command
41
- @input = input
40
+ @command = Aikido::Zen::Helpers.encode_safely(command)
41
+ @input = Aikido::Zen::Helpers.encode_safely(input)
42
42
  end
43
43
 
44
44
  def attack?
@@ -53,8 +53,8 @@ module Aikido::Zen
53
53
  attr_reader :failed_to_tokenize
54
54
 
55
55
  def initialize(query, input, dialect)
56
- @query = query.downcase
57
- @input = input.downcase.strip
56
+ @query = Aikido::Zen::Helpers.encode_safely(query).downcase
57
+ @input = Aikido::Zen::Helpers.encode_safely(input).downcase.strip
58
58
  @dialect = dialect
59
59
  end
60
60
 
@@ -85,9 +85,11 @@ module Aikido::Zen
85
85
  # Matches strings that contain only characters that appear in some
86
86
  # form of IP address.
87
87
  #
88
+ # An IPv6 zone ID (e.g. "%eth0") may follow the address.
89
+ #
88
90
  # Hostnames are not expected to match, allowing `parse_address` to
89
91
  # skip the `Socket.getaddrinfo` call.
90
- ADDRESS_REGEXP = /\A[0-9a-fx:.]+\z/i
92
+ ADDRESS_REGEXP = /\A[0-9a-fx:.]+(?:%[^%\s]+)?\z/i
91
93
 
92
94
  # Parses `address` as an IP address, in any form that is accepted
93
95
  # by `getaddrinfo`:
@@ -96,6 +98,7 @@ module Aikido::Zen
96
98
  # * Plain integer IPv4 notation, in decimal, octal, or hexadecimal
97
99
  # (e.g. "2130706433", "017700000001", "0x7f000001")
98
100
  # * Shorthand dotted IPv4 notation (e.g. "127.1")
101
+ # * IPv6 notation with zone ID (e.g. "fe80::1%eth0")
99
102
  #
100
103
  # Delegates to `Socket.getaddrinfo` with the `AI_NUMERICHOST` flag,
101
104
  # which never performs a DNS lookup.
@@ -105,8 +108,10 @@ module Aikido::Zen
105
108
  def parse_address(address)
106
109
  return nil unless address.is_a?(String) && ADDRESS_REGEXP.match?(address)
107
110
 
108
- Socket.getaddrinfo(address, nil, :UNSPEC, :STREAM, nil, Socket::AI_NUMERICHOST)
109
- .map { |info| IPAddr.new(info[3]) }
111
+ Socket.getaddrinfo(address, nil, :UNSPEC, :STREAM, nil, Socket::AI_NUMERICHOST).map do |info|
112
+ # `IPAddr.new` only accepts a zone ID on Ruby 3.1+.
113
+ IPAddr.new(info[3].partition("%").first)
114
+ end
110
115
  rescue SocketError
111
116
  nil
112
117
  end
@@ -146,7 +146,9 @@ module Aikido::Zen
146
146
  #
147
147
  # @return [Array<URI>] a list of unique URIs based on the above criteria.
148
148
  def uris_from_input
149
- input = @input.to_s
149
+ # Percent-encode input so URI() can parse it, as an app may do before
150
+ # making the request.
151
+ input = percent_encode(@input.to_s)
150
152
 
151
153
  # If you build a URI manually and set the hostname to an IPv6 string,
152
154
  # the URI library will be helpful to wrap it in brackets so it's a
@@ -167,6 +169,17 @@ module Aikido::Zen
167
169
  nil
168
170
  end
169
171
 
172
+ # Matches bytes not allowed in a URI (RFC 3986): any byte other than
173
+ # the unreserved and reserved characters, and any "%" that does not
174
+ # start a percent-encoded byte.
175
+ NOT_ALLOWED_IN_URI = /%(?![0-9A-Fa-f]{2})|[^A-Za-z0-9\-._~:\/?#\[\]@!$&'()*+,;=%]/n
176
+
177
+ # Percent-encodes characters not allowed in a URI, preserving existing
178
+ # percent-encoding.
179
+ def percent_encode(string)
180
+ string.b.gsub(NOT_ALLOWED_IN_URI) { |byte| format("%%%02X", byte.ord) }
181
+ end
182
+
170
183
  # Check if the input is an IPv6 that is not surrounded by square brackets.
171
184
  def unescaped_ipv6?(input)
172
185
  (
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "1.9.0.beta.3"
5
+ VERSION = "1.9.0.beta.4"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
8
  LIBZEN_VERSION = "0.1.74"
@@ -64,7 +64,10 @@ module Aikido::Zen::WorkerProcess
64
64
  def calculate_rate_limits(request)
65
65
  result = @rpc_client.invoke(
66
66
  "calculate_rate_limits", Aikido::Zen::IPC::READ_TIMEOUT,
67
- request.route.as_json, request.client_ip, request.actor&.as_json
67
+ request.route.as_json,
68
+ request.client_ip,
69
+ request.actor&.as_json,
70
+ request.rate_limiting_group
68
71
  )
69
72
 
70
73
  Aikido::Zen::RateLimiter::Result.from_json(result) if result
@@ -22,8 +22,8 @@ module Aikido::Zen::WorkerProcess
22
22
  send_collector_events(events_data)
23
23
  end
24
24
 
25
- @rpc_server.handle("calculate_rate_limits") do |respond, route_data, ip, actor_data|
26
- result = calculate_rate_limits(route_data, ip, actor_data)
25
+ @rpc_server.handle("calculate_rate_limits") do |respond, route_data, ip, actor_data, rate_limiting_group|
26
+ result = calculate_rate_limits(route_data, ip, actor_data, rate_limiting_group)
27
27
  respond.call(result&.as_json)
28
28
  end
29
29
 
@@ -66,7 +66,7 @@ module Aikido::Zen::WorkerProcess
66
66
 
67
67
  # @api private
68
68
  # @note Visible for testing.
69
- RequestKind = Struct.new(:route, :schema, :client_ip, :actor)
69
+ RequestKind = Struct.new(:route, :schema, :client_ip, :actor, :rate_limiting_group)
70
70
 
71
71
  def updated_settings(known_config_generation = nil, known_firewall_lists_generation = nil)
72
72
  result = {}
@@ -91,10 +91,10 @@ module Aikido::Zen::WorkerProcess
91
91
  end
92
92
  end
93
93
 
94
- def calculate_rate_limits(route_data, ip, actor_data)
94
+ def calculate_rate_limits(route_data, ip, actor_data, rate_limiting_group)
95
95
  actor = Aikido::Zen::Actor.from_json(actor_data) if actor_data
96
96
  route = Aikido::Zen::Route.from_json(route_data)
97
- Aikido::Zen.rate_limiter.calculate_rate_limits(RequestKind.new(route, nil, ip, actor))
97
+ Aikido::Zen.rate_limiter.calculate_rate_limits(RequestKind.new(route, nil, ip, actor, rate_limiting_group))
98
98
  end
99
99
 
100
100
  # @return [Array<Hash>, nil]
data/lib/aikido/zen.rb CHANGED
@@ -278,6 +278,37 @@ module Aikido
278
278
  alias_method :set_user, :track_user
279
279
  end
280
280
 
281
+ # Set the rate limiting group.
282
+ #
283
+ # @param group [String, Integer] the rate limiting group id
284
+ # @return [void]
285
+ def self.set_rate_limiting_group(group)
286
+ return if config.disabled?
287
+
288
+ if group.nil? || group == ""
289
+ config.logger.warn("set_rate_limiting_group expects a non-empty group")
290
+ return
291
+ end
292
+
293
+ unless group.is_a?(String) || group.is_a?(Integer)
294
+ config.logger.warn("set_rate_limiting_group expects a String or Integer group, got #{group.class}")
295
+ return
296
+ end
297
+
298
+ context = current_context
299
+ if context.nil?
300
+ config.logger.warn("set_rate_limiting_group was called without a context")
301
+ return
302
+ end
303
+
304
+ context.request.rate_limiting_group = group.to_s
305
+ end
306
+
307
+ # Align with other Zen implementations, while keeping internal consistency.
308
+ class << self
309
+ alias_method :set_rate_limit_group, :set_rate_limiting_group
310
+ end
311
+
281
312
  # Track a custom event happening in your application, like a failed login or
282
313
  # a password reset request. Zen automatically attaches the IP address, user
283
314
  # agent, and current user (if you called .track_user) from the request.
@@ -300,6 +331,13 @@ module Aikido
300
331
 
301
332
  return if request_bypassed?
302
333
 
334
+ if context.custom_events >= config.max_custom_events
335
+ config.logger.warn("Dropped custom event #{name}: too many custom events were tracked for this request")
336
+ return
337
+ end
338
+
339
+ context.custom_events += 1
340
+
303
341
  event = Events::Custom.new(
304
342
  name: name,
305
343
  request: context.request,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aikido-zen
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.0.beta.3
4
+ version: 1.9.0.beta.4
5
5
  platform: arm64-linux
6
6
  authors:
7
7
  - Aikido Security
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-01 00:00:00.000000000 Z
11
+ date: 2026-10-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby