aikido-zen 1.7.1 → 1.7.2.beta.2

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: ddee51760626c0c9d7af69137f88286d0b49ac9d5aab02d17bebbff71136bb0f
4
- data.tar.gz: 7b5ea9701c89111b1423c99750495b7a4d85b725e78008d93b110e4a2be9007c
3
+ metadata.gz: aedfb64ca08c10194c0483138460f984e086ad2ac95250d3ce570b4c08b48263
4
+ data.tar.gz: 6332c4155469c35483394a76683169e973feea9ba417fb4c4f434f8df3231b2e
5
5
  SHA512:
6
- metadata.gz: edada0391a4cc9f6982dddedd67e6972a13ddb60fad9c723cd1c369e36c3ba5699637dbfacce686c1472e6009e9927f69dcb16945ae7c41c65745e110cff614d
7
- data.tar.gz: aa43771cf0fb387c55d35fc1f26b3e765ce0c36ed29764ad677fca7d4f69459310a3115088f73a2df65e35d2bc145dc6a6e2294d20ec4aed35cc81fce96de80a
6
+ metadata.gz: 97a1b03ca7b5b92d944793c650f11791f7a36794e5ebcc85c6c41e6a93f71c1fbab3a5e32f0beeae6254c89ea06f23535275f20cc3732f18c8db722e4b6ec482
7
+ data.tar.gz: 35ba0d4afe57aba7dac30c89294a875779fba9f36413c1c335d97efb62a47b5d3beaa8c5b96d31a9dda491d89b2dd0a78851a4d5ca83dd34071732c981d92b56
@@ -13,10 +13,39 @@ module Aikido::Zen
13
13
  false
14
14
  end
15
15
 
16
+ # Builds the sample for a request that has been classified as suspicious.
17
+ #
18
+ # @param context [Aikido::Zen::Context]
19
+ # @return [Aikido::Zen::AttackWave::Sample]
20
+ def self.sample_for(context)
21
+ request = context.request
22
+
23
+ Sample.new(verb: request.request_method, path: original_fullpath(request))
24
+ end
25
+
16
26
  def self.suspicious_request?(context, status_code)
17
27
  request = context.request
18
28
 
19
- suspicious_method?(request.request_method) || suspicious_path?(request.path_info, status_code)
29
+ suspicious_method?(request.request_method) || suspicious_path?(original_path(request), status_code)
30
+ end
31
+
32
+ # Rails rewrites PATH_INFO to /status before invoking an exceptions_app.
33
+ # Returns the path that Rails rewrote.
34
+ # @param request [Aikido::Zen::Request]
35
+ # @return [String]
36
+ def self.original_path(request)
37
+ request.env["action_dispatch.original_path"] || request.path_info
38
+ end
39
+
40
+ # Rails rewrites PATH_INFO to /status before invoking an exceptions_app.
41
+ # Returns the path that Rails rewrote, including the same query string.
42
+ # @param request [Aikido::Zen::Request]
43
+ # @return [String]
44
+ def self.original_fullpath(request)
45
+ path = original_path(request)
46
+ query = request.query_string
47
+
48
+ query.empty? ? path : "#{path}?#{query}"
20
49
  end
21
50
 
22
51
  def self.suspicious_method?(method)
@@ -5,6 +5,11 @@ require_relative "attack_wave/helpers"
5
5
 
6
6
  module Aikido::Zen
7
7
  module AttackWave
8
+ # Tracks per-client-IP attack wave state.
9
+ #
10
+ # In multiprocess deployments, the main process has a single instance
11
+ # that may be accessed concurrently from each forked worker process's
12
+ # Aikido::Zen::RPC::Server connection thread.
8
13
  class Detector
9
14
  # @return [Aikido::Zen::CappedSet]
10
15
  attr_reader :samples
@@ -12,6 +17,8 @@ module Aikido::Zen
12
17
  def initialize(config: Aikido::Zen.config, clock: nil)
13
18
  @config = config
14
19
 
20
+ @mutex = Mutex.new
21
+
15
22
  @event_times = Cache.new(@config.attack_wave_max_cache_entries, ttl: @config.attack_wave_min_time_between_events, clock: clock)
16
23
 
17
24
  @request_counts = Cache.new(@config.attack_wave_max_cache_entries, 0, ttl: @config.attack_wave_min_time_between_requests, clock: clock)
@@ -21,29 +28,54 @@ module Aikido::Zen
21
28
  end
22
29
  end
23
30
 
24
- def attack_wave?(context, status_code = nil)
25
- client_ip = context.request.client_ip
26
-
27
- return false unless client_ip
31
+ # @api private
32
+ # @note Visible for testing.
33
+ #
34
+ # Whether the client IP is within the cooldown period after triggering
35
+ # an attack wave.
36
+ #
37
+ # @param client_ip [String]
38
+ # @return [Boolean]
39
+ def flagged?(client_ip)
40
+ !!@event_times[client_ip]
41
+ end
28
42
 
29
- return false if @event_times[client_ip]
43
+ # @api private
44
+ # @note Visible for testing.
45
+ #
46
+ # Flags the client IP as having triggered an attack wave for the
47
+ # cooldown period.
48
+ #
49
+ # @param client_ip [String]
50
+ # @return [void]
51
+ def flag!(client_ip)
52
+ @event_times[client_ip] = Time.now.utc
53
+ end
30
54
 
31
- return false unless AttackWave::Helpers.web_scanner?(context, status_code)
55
+ # Records a suspicious sample and, if it crosses the threshold for
56
+ # triggering an attack wave, flags the client IP as having just
57
+ # triggered an attack wave.
58
+ #
59
+ # This method is synchronized to prevent concurrent calls for the
60
+ # same client IP from crossing the threshold in the same instant.
61
+ #
62
+ # @param client_ip [String]
63
+ # @param sample [Aikido::Zen::AttackWave::Sample]
64
+ # @return [Array<Aikido::Zen::AttackWave::Sample>, nil]
65
+ def record(client_ip, sample)
66
+ @mutex.synchronize do
67
+ return nil if flagged?(client_ip)
32
68
 
33
- request_count = @request_counts[client_ip] += 1
69
+ request_count = @request_counts[client_ip] += 1
34
70
 
35
- context.request.then do |request|
36
- @samples[client_ip] <<= Sample.new(
37
- verb: request.request_method,
38
- path: request.fullpath
39
- )
40
- end
71
+ @samples[client_ip] <<= sample
41
72
 
42
- return false if request_count < @config.attack_wave_threshold
73
+ return nil if request_count < @config.attack_wave_threshold
43
74
 
44
- @event_times[client_ip] = Time.now.utc
75
+ flag!(client_ip)
45
76
 
46
- true
77
+ @samples[client_ip].to_a
78
+ end
47
79
  end
48
80
  end
49
81
 
@@ -117,6 +149,10 @@ module Aikido::Zen
117
149
  end
118
150
 
119
151
  class Sample
152
+ def self.from_json(data)
153
+ new(verb: data["method"], path: data["url"])
154
+ end
155
+
120
156
  # @return [String]
121
157
  attr_reader :verb
122
158
 
@@ -5,7 +5,7 @@ module Aikido::Zen
5
5
  extend Forwardable
6
6
 
7
7
  # @api private
8
- # Visible for testing.
8
+ # @note Visible for testing.
9
9
  def_delegators :@data,
10
10
  :size, :empty?
11
11
 
@@ -54,13 +54,13 @@ module Aikido::Zen
54
54
  end
55
55
 
56
56
  # @api private
57
- # Visible for testing.
57
+ # @note Visible for testing.
58
58
  def to_a
59
59
  @data.map { |key, entry| [key, entry.value] }
60
60
  end
61
61
 
62
62
  # @api private
63
- # Visible for testing.
63
+ # @note Visible for testing.
64
64
  def to_h
65
65
  to_a.to_h
66
66
  end
@@ -8,7 +8,7 @@ module Aikido::Zen
8
8
  # Keeps track of the visited routes.
9
9
  class Collector::Routes
10
10
  # @api private
11
- # Visible for testing.
11
+ # @note Visible for testing.
12
12
  attr_reader :visits
13
13
 
14
14
  def initialize(config = Aikido::Zen.config)
@@ -35,7 +35,7 @@ module Aikido::Zen
35
35
  end
36
36
 
37
37
  # @api private
38
- # Visible for testing.
38
+ # @note Visible for testing.
39
39
  def [](route)
40
40
  @visits[route]
41
41
  end
@@ -210,7 +210,6 @@ module Aikido::Zen
210
210
  end
211
211
 
212
212
  # @api private
213
- #
214
213
  # @note Visible for testing.
215
214
  def stats
216
215
  handle
@@ -218,7 +217,6 @@ module Aikido::Zen
218
217
  end
219
218
 
220
219
  # @api private
221
- #
222
220
  # @note Visible for testing.
223
221
  def users
224
222
  handle
@@ -226,7 +224,6 @@ module Aikido::Zen
226
224
  end
227
225
 
228
226
  # @api private
229
- #
230
227
  # @note Visible for testing.
231
228
  def hosts
232
229
  handle
@@ -234,7 +231,6 @@ module Aikido::Zen
234
231
  end
235
232
 
236
233
  # @api private
237
- #
238
234
  # @note Visible for testing.
239
235
  def routes
240
236
  handle
@@ -242,7 +238,6 @@ module Aikido::Zen
242
238
  end
243
239
 
244
240
  # @api private
245
- #
246
241
  # @note Visible for testing.
247
242
  def middleware_installed?
248
243
  @middleware_installed.true?
@@ -7,7 +7,7 @@ module Aikido::Zen
7
7
 
8
8
  class Protector
9
9
  # @api private
10
- # Visible for testing.
10
+ # @note Visible for testing.
11
11
  attr_accessor :cache
12
12
 
13
13
  def initialize(config: Aikido::Zen.config)
@@ -21,43 +21,46 @@ module Aikido
21
21
  end
22
22
 
23
23
  # @api private
24
- # Visible for testing.
25
- def attack_wave?(context, status_code = nil)
24
+ # @note Visible for testing.
25
+ #
26
+ # @param context [Aikido::Zen::Context]
27
+ # @param status_code [Integer, nil]
28
+ # @return [Array<Aikido::Zen::AttackWave::Sample>, nil]
29
+ def detect_attack_wave(context, status_code = nil)
26
30
  request = context.request
27
- return false if request.nil?
31
+ return nil if request.nil?
28
32
 
29
- return false if @settings.bypassed_ips.include?(request.client_ip)
33
+ return nil if @settings.bypassed_ips.include?(request.client_ip)
30
34
 
31
- @zen.attack_wave_detector.attack_wave?(context, status_code)
35
+ @zen.detect_attack_wave(context, status_code)
32
36
  end
33
37
 
34
38
  # @api private
35
- # Visible for testing.
39
+ # @note Visible for testing.
36
40
  def protect(context, status_code = nil)
37
- if attack_wave?(context, status_code)
38
- client_ip = context.request.client_ip
41
+ samples = detect_attack_wave(context, status_code)
42
+ return unless samples
39
43
 
40
- request = Aikido::Zen::AttackWave::Request.new(
41
- ip_address: client_ip,
42
- user_agent: context.request.user_agent,
43
- source: context.request.framework
44
- )
44
+ client_ip = context.request.client_ip
45
45
 
46
- samples = @zen.attack_wave_detector.samples[client_ip].to_a
46
+ request = Aikido::Zen::AttackWave::Request.new(
47
+ ip_address: client_ip,
48
+ user_agent: context.request.user_agent,
49
+ source: context.request.framework
50
+ )
47
51
 
48
- attack = Aikido::Zen::AttackWave::Attack.new(
49
- samples: samples,
50
- user: context.request.actor
51
- )
52
+ attack = Aikido::Zen::AttackWave::Attack.new(
53
+ samples: samples,
54
+ user: context.request.actor
55
+ )
52
56
 
53
- attack_wave = Aikido::Zen::Events::AttackWave.new(
54
- request: request,
55
- attack: attack
56
- )
57
+ attack_wave = Aikido::Zen::Events::AttackWave.new(
58
+ request: request,
59
+ attack: attack
60
+ )
57
61
 
58
- @zen.track_attack_wave(attack_wave)
59
- @zen.agent.report(attack_wave)
60
- end
62
+ @zen.track_attack_wave(attack_wave)
63
+ @zen.agent.report(attack_wave)
61
64
  end
62
65
  end
63
66
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "1.7.1"
5
+ VERSION = "1.7.2.beta.2"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
8
  LIBZEN_VERSION = "0.1.61"
@@ -8,6 +8,11 @@ module Aikido::Zen::WorkerProcess
8
8
  # The keepalive interval must be less than the RPC server read timeout.
9
9
  KEEPALIVE_INTERVAL = 4
10
10
 
11
+ # This read timeout should be kept short because attackers can trigger
12
+ # attack wave detection checks at will, to limit how much latency an
13
+ # attacker can introduce per request.
14
+ ATTACK_WAVE_TIMEOUT = 1.0
15
+
11
16
  def initialize(
12
17
  host,
13
18
  port,
@@ -68,6 +73,20 @@ module Aikido::Zen::WorkerProcess
68
73
  raise
69
74
  end
70
75
 
76
+ # @param client_ip [String]
77
+ # @param sample [Aikido::Zen::AttackWave::Sample]
78
+ # @return [Array<Aikido::Zen::AttackWave::Sample>, nil]
79
+ def record_attack_wave(client_ip, sample)
80
+ result = @rpc_client.invoke(
81
+ "record_attack_wave", ATTACK_WAVE_TIMEOUT,
82
+ client_ip, sample.verb, sample.path
83
+ )
84
+ result&.map { |data| Aikido::Zen::AttackWave::Sample.from_json(data) }
85
+ rescue => err
86
+ @config.logger.error("Forked worker process #{Process.pid}: failed to record attack wave sample with parent: #{err.message}")
87
+ raise
88
+ end
89
+
71
90
  private
72
91
 
73
92
  def updated_settings
@@ -3,8 +3,9 @@
3
3
  module Aikido::Zen::WorkerProcess
4
4
  module Agent
5
5
  class Server
6
- def initialize(config: Aikido::Zen.config)
6
+ def initialize(config: Aikido::Zen.config, detector: Aikido::Zen.attack_wave_detector)
7
7
  @config = config
8
+ @detector = detector
8
9
 
9
10
  @rpc_server = Aikido::Zen::RPC::Server.new(Aikido::Zen.secret)
10
11
 
@@ -25,6 +26,10 @@ module Aikido::Zen::WorkerProcess
25
26
  result = calculate_rate_limits(route_data, ip, actor_data)
26
27
  respond.call(result&.as_json)
27
28
  end
29
+
30
+ @rpc_server.handle("record_attack_wave") do |respond, client_ip, verb, path|
31
+ respond.call(record_attack_wave(client_ip, verb, path))
32
+ end
28
33
  end
29
34
 
30
35
  def host
@@ -60,8 +65,7 @@ module Aikido::Zen::WorkerProcess
60
65
  end
61
66
 
62
67
  # @api private
63
- #
64
- # Visible for testing.
68
+ # @note Visible for testing.
65
69
  RequestKind = Struct.new(:route, :schema, :client_ip, :actor)
66
70
 
67
71
  def updated_settings(known_config_generation = nil, known_firewall_lists_generation = nil)
@@ -92,6 +96,13 @@ module Aikido::Zen::WorkerProcess
92
96
  route = Aikido::Zen::Route.from_json(route_data)
93
97
  Aikido::Zen.rate_limiter.calculate_rate_limits(RequestKind.new(route, nil, ip, actor))
94
98
  end
99
+
100
+ # @return [Array<Hash>, nil]
101
+ def record_attack_wave(client_ip, verb, path)
102
+ sample = Aikido::Zen::AttackWave::Sample.new(verb: verb, path: path)
103
+ samples = @detector.record(client_ip, sample)
104
+ samples&.map(&:as_json)
105
+ end
95
106
  end
96
107
  end
97
108
  end
data/lib/aikido/zen.rb CHANGED
@@ -249,6 +249,49 @@ module Aikido
249
249
  @attack_wave_detector ||= AttackWave::Detector.new
250
250
  end
251
251
 
252
+ # Classifies the current request and, if it is classified as suspicious,
253
+ # records a sample against the attack wave detector.
254
+ #
255
+ # @param context [Aikido::Zen::Context]
256
+ # @param status_code [Integer, nil]
257
+ # @return [Array<Aikido::Zen::AttackWave::Sample>, nil]
258
+ def self.detect_attack_wave(context, status_code = nil)
259
+ client_ip = context.request.client_ip
260
+ return nil unless client_ip
261
+
262
+ return nil unless AttackWave::Helpers.web_scanner?(context, status_code)
263
+
264
+ record_attack_wave(client_ip, AttackWave::Helpers.sample_for(context))
265
+ end
266
+
267
+ # Records a suspicious sample and, if the threshold for triggering
268
+ # an attack wave has been crossed, flags the client IP as having
269
+ # just triggered an attack wave.
270
+ #
271
+ # In multiprocess deployments, on RPC failure, the worker process
272
+ # records against its local detector.
273
+ #
274
+ # If RPC failures are intermittent, it is possible that an attack
275
+ # wave may be missed or duplicated, because state is split between
276
+ # the global and local detectors.
277
+ #
278
+ # @param client_ip [String]
279
+ # @param sample [Aikido::Zen::AttackWave::Sample]
280
+ # @return [Array<Aikido::Zen::AttackWave::Sample>, nil]
281
+ def self.record_attack_wave(client_ip, sample)
282
+ worker_process_client = @worker_process_client
283
+
284
+ if worker_process_client
285
+ begin
286
+ worker_process_client.record_attack_wave(client_ip, sample)
287
+ rescue
288
+ attack_wave_detector.record(client_ip, sample)
289
+ end
290
+ else
291
+ attack_wave_detector.record(client_ip, sample)
292
+ end
293
+ end
294
+
252
295
  # @return [Aikido::Zen::IDOR::Protector]
253
296
  def self.idor_protector
254
297
  @idor_protector ||= IDOR::Protector.new
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.7.1
4
+ version: 1.7.2.beta.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aikido Security
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-10 00:00:00.000000000 Z
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby