aikido-zen 1.9.0.beta.3-arm64-linux → 1.9.1-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: df231e175ddfcbc1a8c21d8c0e23575d13de65a47214a0c191b9b94bdc7d7b51
4
+ data.tar.gz: 257407c5c9630af6ea66536e4ffc2ae9e8042b5eba98eb53f5baa61bf04d1ba8
5
5
  SHA512:
6
- metadata.gz: 217c17f0dd195b33401cb73971b74174dde3f1101bd1f07be46a19317e808d1b887e4bdb47088e80c53b97dd9c7795bd9fe8628bec2d87a022480e75af9a1cea
7
- data.tar.gz: fcace5b55b5ffcbcb5b76c019addc01d730b7d301ba436afa31d85b61f19219592cb97d284907423f0a293848d2d3209559b63dd9e2855a9c2ccb66dfc602fba
6
+ metadata.gz: d758d8a41ab1387576a0202135df2526f62aaa0d594104cce44812051e3941b24edcc7d06d86a9837d8542eab521c08f5d72513b687619c2609183f403a70b3f
7
+ data.tar.gz: 23997d7f758c63551deff8cefb2d067fa6c0816744f247f6ab15a89d7554aa6f9bfef0e8420a81ec4c66ad1043b7b6702990a6402cb64aed01063f0061478aae
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,17 @@ 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(attack_log_message(attack))
140
+
141
+ report(Events::Attack.new(attack: attack)) if @api_client.can_make_requests?
142
+
143
+ @collector.track_attack(attack)
144
+ rescue => err
145
+ @config.logger.error("Zen failed to record an attack: #{err.class}: #{err.message}")
146
+ end
141
147
 
142
- @collector.track_attack(attack)
143
148
  raise attack if attack.blocked?
144
149
  end
145
150
 
@@ -220,6 +225,21 @@ module Aikido::Zen
220
225
 
221
226
  private
222
227
 
228
+ def attack_log_message(attack)
229
+ input = attack.as_json
230
+
231
+ format(
232
+ "Zen has %s a %s: %s(...) originating from %s%s",
233
+ attack.blocked? ? "blocked" : "detected",
234
+ attack.humanized_name,
235
+ attack.operation,
236
+ input["source"],
237
+ # The path is built from names in the request, so it can't be trusted
238
+ # not to split the log line.
239
+ input["path"].to_s.gsub(/[\r\n]/, " ")
240
+ )
241
+ end
242
+
223
243
  def settings_updated(event)
224
244
  updated_at = Time.at(event[:data]["configUpdatedAt"].to_i)
225
245
 
@@ -16,12 +16,6 @@ module Aikido::Zen
16
16
  @config = config
17
17
  @system_info = system_info
18
18
  @rate_limiter = rate_limiter
19
-
20
- @should_fetch_settings_endpoint = @config.realtime_endpoint
21
- end
22
-
23
- def should_fetch_settings_endpoint=(uri)
24
- @should_fetch_settings_endpoint = URI(uri)
25
19
  end
26
20
 
27
21
  # @return [Boolean] whether we have a configured token.
@@ -44,16 +38,16 @@ module Aikido::Zen
44
38
 
45
39
  response = request(
46
40
  Net::HTTP::Get.new("/config", default_headers),
47
- base_url: @should_fetch_settings_endpoint
41
+ base_url: @config.realtime_endpoint,
42
+ timeout: @config.api_config_timeout
48
43
  )
49
44
 
50
45
  new_updated_at = Time.at(response["configUpdatedAt"].to_i)
51
46
  new_updated_at > last_updated_at
52
47
  end
53
48
 
54
- # Fetches the runtime config from the server. In case of a timeout or
55
- # other low-lever error, the request will be automatically retried up to two
56
- # times, after which it will raise an error.
49
+ # Fetches the runtime config from the server. Requests are never retried;
50
+ # on a timeout or other low-level error this raises an error.
57
51
  #
58
52
  # @return [Hash] decoded JSON response from the server with the runtime
59
53
  # settings.
@@ -61,7 +55,11 @@ module Aikido::Zen
61
55
  def fetch_runtime_config
62
56
  @config.logger.debug("Fetching new runtime config")
63
57
 
64
- request(Net::HTTP::Get.new("/api/runtime/config", default_headers))
58
+ request(
59
+ Net::HTTP::Get.new("/api/runtime/config", default_headers),
60
+ base_url: @config.api_endpoint,
61
+ timeout: @config.api_config_timeout
62
+ )
65
63
  end
66
64
 
67
65
  def fetch_runtime_firewall_lists
@@ -71,7 +69,11 @@ module Aikido::Zen
71
69
  "Accept-Encoding" => "gzip"
72
70
  })
73
71
 
74
- request(Net::HTTP::Get.new("/api/runtime/firewall/lists", headers))
72
+ request(
73
+ Net::HTTP::Get.new("/api/runtime/firewall/lists", headers),
74
+ base_url: @config.api_endpoint,
75
+ timeout: @config.api_config_timeout
76
+ )
75
77
  end
76
78
 
77
79
  # @overload report(event)
@@ -111,7 +113,7 @@ module Aikido::Zen
111
113
  @config.json_encoder.call(event)
112
114
  end
113
115
 
114
- request(req)
116
+ request(req, base_url: @config.api_endpoint, timeout: @config.api_events_timeout)
115
117
  rescue Aikido::Zen::RateLimitedError
116
118
  @rate_limiter.open!
117
119
  raise
@@ -121,15 +123,16 @@ module Aikido::Zen
121
123
  # response.
122
124
  #
123
125
  # @param request [Net::HTTPRequest]
124
- # @param base_url [URI] which API to use. Defaults to +Config#api_endpoint+.
126
+ # @param base_url [URI] which API to use.
127
+ # @param timeout [Numeric] seconds to wait on each connect, read and write.
125
128
  #
126
129
  # @return [Object] the result of decoding the JSON response from the server.
127
130
  #
128
131
  # @raise [Aikido::Zen::APIError] in case of a 4XX or 5XX response.
129
132
  # @raise [Aikido::Zen::NetworkError] if an error occurs trying to make the
130
133
  # request.
131
- private def request(request, base_url: @config.api_endpoint)
132
- Net::HTTP.start(base_url.host, base_url.port, http_settings(base_url)) do |http|
134
+ private def request(request, base_url:, timeout:)
135
+ Net::HTTP.start(base_url.host, base_url.port, http_settings(base_url, timeout)) do |http|
133
136
  response = http.request(request)
134
137
 
135
138
  case response
@@ -168,11 +171,14 @@ module Aikido::Zen
168
171
  end
169
172
  end
170
173
 
171
- private def http_settings(base_url)
172
- @http_settings ||= {
174
+ private def http_settings(base_url, timeout)
175
+ {
173
176
  use_ssl: base_url.scheme == "https",
174
- max_retries: 2
175
- }.merge(@config.api_timeouts)
177
+ max_retries: 0,
178
+ open_timeout: timeout,
179
+ read_timeout: timeout,
180
+ write_timeout: timeout
181
+ }
176
182
  end
177
183
 
178
184
  private def default_headers
@@ -180,7 +186,7 @@ module Aikido::Zen
180
186
  "Authorization" => @config.api_token,
181
187
  "Accept" => "application/json",
182
188
  "User-Agent" => "#{@system_info.library_name} v#{@system_info.library_version}"
183
- }
189
+ }.merge(@system_info.agent_headers)
184
190
  end
185
191
  end
186
192
  end
@@ -7,7 +7,9 @@ require "json"
7
7
  module Aikido::Zen
8
8
  class APIStream
9
9
  def initialize(
10
- config: Aikido::Zen.config,
10
+ zen: Aikido::Zen,
11
+ config: zen.config,
12
+ system_info: zen.system_info,
11
13
  min_backoff: 5,
12
14
  max_backoff: 60,
13
15
  backoff_reset: 30,
@@ -16,6 +18,7 @@ module Aikido::Zen
16
18
  read_timeout: 70
17
19
  )
18
20
  @config = config
21
+ @system_info = system_info
19
22
  @min_backoff = min_backoff
20
23
  @max_backoff = max_backoff
21
24
  @backoff_reset = backoff_reset
@@ -123,8 +126,7 @@ module Aikido::Zen
123
126
  request["Authorization"] = @token
124
127
  request["Accept"] = "text/event-stream"
125
128
  request["Cache-Control"] = "no-cache"
126
- request["X-Agent-Platform"] = "ruby"
127
- request["X-Agent-Version"] = Aikido::Zen::VERSION
129
+ @system_info.agent_headers.each { |name, value| request[name] = value }
128
130
  # Working around Net::HTTP cleverness; auto-negotiates gzip and buffers
129
131
  # decompressed output internally.
130
132
  request["Accept-Encoding"] = "identity"
@@ -43,13 +43,19 @@ module Aikido::Zen
43
43
  end
44
44
 
45
45
  def as_json
46
- {
46
+ attack_json = {
47
47
  "kind" => kind,
48
48
  "blocked" => blocked?,
49
49
  "metadata" => metadata,
50
50
  "operation" => @operation,
51
51
  "stack" => @stack
52
52
  }.compact.merge(input.as_json)
53
+
54
+ if (actor = @context&.request&.actor)
55
+ attack_json["user"] = {"id" => actor.id, "name" => actor.name}.compact
56
+ end
57
+
58
+ attack_json
53
59
  end
54
60
 
55
61
  def exception(*)
@@ -69,8 +75,10 @@ module Aikido::Zen
69
75
  end
70
76
 
71
77
  def metadata
78
+ safe_filepath = Aikido::Zen::Helpers.encode_safely(filepath)
79
+
72
80
  {
73
- "filename" => filepath
81
+ "filename" => safe_filepath
74
82
  }
75
83
  end
76
84
 
@@ -106,8 +114,10 @@ module Aikido::Zen
106
114
  end
107
115
 
108
116
  def metadata
117
+ safe_command = Aikido::Zen::Helpers.encode_safely(@command)
118
+
109
119
  {
110
- "command" => @command
120
+ "command" => safe_command
111
121
  }
112
122
  end
113
123
 
@@ -138,8 +148,10 @@ module Aikido::Zen
138
148
  end
139
149
 
140
150
  def metadata
151
+ safe_query = Aikido::Zen::Helpers.encode_safely(@query)
152
+
141
153
  {
142
- "sql" => @query,
154
+ "sql" => safe_query,
143
155
  "dialect" => @dialect.name,
144
156
  "failedToTokenize" => @failed_to_tokenize || nil
145
157
  }.compact
@@ -45,8 +45,15 @@ module Aikido::Zen
45
45
  # +https://guard.aikido.dev+.
46
46
  attr_reader :realtime_endpoint
47
47
 
48
- # @return [Hash] HTTP timeouts for communicating with the API.
49
- attr_reader :api_timeouts
48
+ # @return [Numeric] the timeout in seconds for reporting events to the
49
+ # API (+POST /api/runtime/events+). Applied to each connect, read and
50
+ # write wait. Defaults to 60 seconds.
51
+ attr_accessor :api_events_timeout
52
+
53
+ # @return [Numeric] the timeout in seconds for fetching config, runtime
54
+ # config and firewall lists from the API. Applied to each connect, read
55
+ # and write wait. Defaults to 30 seconds.
56
+ attr_accessor :api_config_timeout
50
57
 
51
58
  # @return [Integer] the interval in seconds to poll the runtime API for
52
59
  # settings changes. Defaults to evey 60 seconds.
@@ -56,6 +63,10 @@ module Aikido::Zen
56
63
  # each initial heartbeat event.
57
64
  attr_accessor :initial_heartbeat_delays
58
65
 
66
+ # @return [Integer] the maximum number of levels deep we want to traverse
67
+ # when extracting payloads from a nested request property. Defaults to 64.
68
+ attr_accessor :extract_payloads_max_depth
69
+
59
70
  # @return [Symbol] the agent mode for forked worker processes. Can be set
60
71
  # through the AIKIDO_AGENT_MODE environment variable.
61
72
  attr_reader :agent_mode
@@ -107,6 +118,10 @@ module Aikido::Zen
107
118
  # the oldest seen users.
108
119
  attr_accessor :max_users_tracked
109
120
 
121
+ # @return [Integer] the maximum number of custom events tracked per request.
122
+ # Defaults to 25.
123
+ attr_accessor :max_custom_events
124
+
110
125
  # @return [Proc{(Aikido::Zen::Request, Symbol, reason: String=nil) => Array(Integer, Hash, #each)}]
111
126
  # Rack handler used to respond to requests from IPs, users or others blocked in the Aikido
112
127
  # dashboard.
@@ -246,9 +261,11 @@ module Aikido::Zen
246
261
  self.api_token = ENV.fetch("AIKIDO_TOKEN", nil)
247
262
  self.api_endpoint = ENV.fetch("AIKIDO_ENDPOINT") { regional_api_endpoint(api_token) }
248
263
  self.realtime_endpoint = api_endpoint
249
- self.api_timeouts = 10
264
+ self.api_events_timeout = 60 # sec
265
+ self.api_config_timeout = 30 # sec
250
266
  self.polling_interval = 60 # 1 min
251
267
  self.initial_heartbeat_delays = [30, 60 * 2] # 30 sec, 2 min
268
+ self.extract_payloads_max_depth = 64
252
269
  self.agent_mode = ENV.fetch("AIKIDO_AGENT_MODE", "shared")
253
270
  self.worker_process_polling_interval = 10
254
271
  self.worker_process_polling_jitter = 10
@@ -261,6 +278,7 @@ module Aikido::Zen
261
278
  self.max_compressed_stats = 100
262
279
  self.max_outbound_connections = 200
263
280
  self.max_users_tracked = 1000
281
+ self.max_custom_events = 25
264
282
  self.blocked_responder = DEFAULT_BLOCKED_RESPONDER
265
283
  self.rate_limited_responder = DEFAULT_RATE_LIMITED_RESPONDER
266
284
  self.rate_limiting_discriminator = DEFAULT_RATE_LIMITING_DISCRIMINATOR
@@ -322,25 +340,6 @@ module Aikido::Zen
322
340
  @logger.level = Logger::DEBUG if debugging
323
341
  end
324
342
 
325
- # @overload def api_timeouts=(timeouts)
326
- # Configure granular connection timeouts for the Aikido Zen API. You
327
- # can set any of these per call.
328
- # @param timeouts [Hash]
329
- # @option timeouts [Integer] :open_timeout Duration in seconds.
330
- # @option timeouts [Integer] :read_timeout Duration in seconds.
331
- # @option timeouts [Integer] :write_timeout Duration in seconds.
332
- #
333
- # @overload def api_timeouts=(duration)
334
- # Configure the connection timeouts for the Aikido Zen API.
335
- # @param duration [Integer] Duration in seconds to set for all three
336
- # timeouts (open, read, and write).
337
- def api_timeouts=(value)
338
- value = {open_timeout: value, read_timeout: value, write_timeout: value} if value.respond_to?(:to_int)
339
-
340
- @api_timeouts ||= {}
341
- @api_timeouts.update(value)
342
- end
343
-
344
343
  def api_token_hash
345
344
  return unless api_token
346
345
 
@@ -442,7 +441,13 @@ module Aikido::Zen
442
441
 
443
442
  # @!visibility private
444
443
  DEFAULT_RATE_LIMITING_DISCRIMINATOR = ->(request) {
445
- request.actor ? "actor:#{request.actor.id}" : request.client_ip
444
+ rate_limiting_group = request.rate_limiting_group
445
+ return "group:#{rate_limiting_group}" unless rate_limiting_group.nil?
446
+
447
+ actor = request.actor
448
+ return "actor:#{request.actor.id}" unless actor.nil?
449
+
450
+ request.client_ip
446
451
  }
447
452
  end
448
453
  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
  (
@@ -85,36 +85,40 @@ module Aikido::Zen
85
85
  # @raise [Aikido::UnderAttackError] if an attack is detected and
86
86
  # blocking_mode is enabled.
87
87
  def scan(context: Aikido::Zen.current_context, **scan_params)
88
+ # Nested scans must return early without clearing the scanning state.
88
89
  return if context&.scanning
90
+
89
91
  context&.scanning = true
90
92
 
91
- return if context&.protection_disabled?
93
+ begin
94
+ return if context&.protection_disabled?
92
95
 
93
- scan = Scan.new(sink: self, context: context)
96
+ scan = Scan.new(sink: self, context: context)
94
97
 
95
- scans_performed = 0
96
- scan.perform do
97
- result = nil
98
+ scans_performed = 0
99
+ scan.perform do
100
+ result = nil
98
101
 
99
- scanners.each do |scanner|
100
- next if scanner.skips_on_nil_context? && context.nil?
102
+ scanners.each do |scanner|
103
+ next if scanner.skips_on_nil_context? && context.nil?
101
104
 
102
- result = scanner.call(scan: scan, sink: self, context: context, **scan_params)
103
- scans_performed += 1
105
+ result = scanner.call(scan: scan, sink: self, context: context, **scan_params)
106
+ scans_performed += 1
104
107
 
105
- break result if result
106
- rescue => error
107
- scan.track_error(error, scanner)
108
- end
108
+ break result if result
109
+ rescue => error
110
+ scan.track_error(error, scanner)
111
+ end
109
112
 
110
- result
111
- end
113
+ result
114
+ end
112
115
 
113
- @reporter.call(scan) if scans_performed > 0
116
+ @reporter.call(scan) if scans_performed > 0
114
117
 
115
- scan
116
- ensure
117
- context&.scanning = false
118
+ scan
119
+ ensure
120
+ context&.scanning = false
121
+ end
118
122
  end
119
123
  end
120
124
  end
@@ -1,13 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "socket"
4
+ require "securerandom"
4
5
  require "rubygems"
5
6
 
6
7
  require_relative "package"
7
8
 
8
9
  module Aikido::Zen
9
- # Provides information about the currently running Agent.
10
+ # Provides information about the currently running agent.
10
11
  class SystemInfo
12
+ # @return [String] a unique identifier for the currently running agent.
13
+ attr_reader :session_id
14
+
15
+ def initialize
16
+ @session_id = generate_session_id
17
+ end
18
+
11
19
  def attacks_block_requests?
12
20
  !!Aikido::Zen.blocking_mode?
13
21
  end
@@ -28,8 +36,29 @@ module Aikido::Zen
28
36
  RUBY_VERSION
29
37
  end
30
38
 
39
+ # @return [String, nil] the hostname, or nil if it cannot be determined.
31
40
  def hostname
32
- @hostname ||= Socket.gethostname
41
+ @hostname ||= begin
42
+ name = Socket.gethostname.to_s
43
+ name unless name.empty?
44
+ rescue SystemCallError
45
+ nil
46
+ end
47
+ end
48
+
49
+ # Headers sent with every request to the Aikido server to describe and
50
+ # identify the currently running agent.
51
+ #
52
+ # @return [Hash{String => String}] the agent headers, keyed by header name.
53
+ def agent_headers
54
+ {
55
+ "X-Agent-Platform" => "ruby",
56
+ "X-Agent-Library" => library_name,
57
+ "X-Agent-Version" => library_version,
58
+ "X-Agent-Hostname" => hostname || "unknown",
59
+ "X-Agent-IP-Address" => ip_address || "unknown",
60
+ "X-Agent-Session-Id" => session_id
61
+ }
33
62
  end
34
63
 
35
64
  # @return [Array<Aikido::Zen::Package>] a list of loaded rubygems that are
@@ -41,14 +70,18 @@ module Aikido::Zen
41
70
  .select(&:supported?)
42
71
  end
43
72
 
44
- # @return [String] the first non-loopback IPv4 address that we can use
45
- # to identify this host. If the machine is solely identified by IPv6
46
- # addresses, then this will instead return an IPv6 address.
73
+ # Find the first non-loopback IP address, preferring IPv4.
74
+ #
75
+ # @return [String, nil] the IP address, or nil if it cannot be determined.
47
76
  def ip_address
48
- @ip_address ||= Socket.ip_address_list
49
- .reject { |ip| ip.ipv4_loopback? || ip.ipv6_loopback? || ip.unix? }
50
- .min_by { |ip| ip.ipv4? ? 0 : 1 }
51
- .ip_address
77
+ @ip_address ||= begin
78
+ address = Socket.ip_address_list
79
+ .reject { |ip| ip.ipv4_loopback? || ip.ipv6_loopback? || ip.unix? }
80
+ .min_by { |ip| ip.ipv4? ? 0 : 1 }
81
+ address&.ip_address
82
+ rescue SystemCallError
83
+ nil
84
+ end
52
85
  end
53
86
 
54
87
  def os_name
@@ -76,5 +109,17 @@ module Aikido::Zen
76
109
  "preventedPrototypePollution" => false
77
110
  }
78
111
  end
112
+
113
+ private def generate_session_id
114
+ # Code coverage is disabled here because `SecureRandom.uuid_v7` is only
115
+ # defined in Ruby 3.3+.
116
+ # :nocov:
117
+ if SecureRandom.respond_to?(:uuid_v7)
118
+ SecureRandom.uuid_v7
119
+ else
120
+ SecureRandom.uuid
121
+ end
122
+ # :nocov:
123
+ end
79
124
  end
80
125
  end
@@ -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.1"
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,
@@ -522,6 +560,7 @@ module Aikido
522
560
  @agent = nil
523
561
  agent&.stop!
524
562
 
563
+ @system_info = nil
525
564
  @agent = Agent.start
526
565
  end
527
566
 
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.1
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-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby