aikido-zen 1.7.2.beta.1 → 1.7.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: 9df9011f3e8f6d0df4ea0b12df6ed7202c0b91eda333f4318a6217b379702fc9
4
- data.tar.gz: d13cd7f6f6cf72aa8dd0d76ff5bfc44dbb5620701ea3179f10f543587703695c
3
+ metadata.gz: 41f1982e134a806c513cc166247059c3f290f6bbc88ff32dcf4bdc65da9f377d
4
+ data.tar.gz: d97dac2f830555239836a143b815ca3c85c4993776183ce73bc5dc392fed9618
5
5
  SHA512:
6
- metadata.gz: 5853f8f8cb5f74f439d155165ec137a965778741707537bce4dbc035f2f76529ed92910569b89137599f61b9e706f5fcacd95391e0157100f93326417e913df5
7
- data.tar.gz: 1c742e2f46dde686ae8f70c65d7cfb0dd8fb552d10b9bff5b9b22934e25be3d8cc2c3a92cf7487e2d3c5884ca1803f585a4e7029f90564cb353563bc5960c220
6
+ metadata.gz: 526140c45a47c29f4f36e1508bc3305bafe535e92479c877deeb92a1f74c618fa19d7c46a7d67183310fbc414f05912b93c88f2cdb109730d04c0b4b6a54b301
7
+ data.tar.gz: 6c43bc7461a52ded8331b6ba2188afd9d273e1b012f5912b2d9786c439e2890380003779925bd3f37fdd4422f4b34aefe13af6d29d0a6a7f3b4e351c5019dca1
@@ -0,0 +1,38 @@
1
+ # Bypass Zen for a specific request
2
+
3
+ To disable Zen for a specific request, call `Aikido::Zen.request_bypassed!` from a Rack middleware. This bypasses Zen's security checks and stats collection for that request.
4
+
5
+ > [!NOTE]
6
+ > Zen already has a built-in feature for bypassing specific IP addresses. This feature offers more flexibility, letting you bypass Zen for a request based on any criteria you choose.
7
+
8
+ `Aikido::Zen.request_bypassed!` needs the request's context to already be set up, so your middleware must run after Zen's `ContextSetter` middleware. The following example shows how to bypass a specific request in a Rails application:
9
+
10
+ ```ruby
11
+ # config/application.rb
12
+ class RequestBypasser
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(env)
18
+ if your_custom_logic?(env)
19
+ Aikido::Zen.request_bypassed! # Bypasses Zen for this request
20
+ end
21
+
22
+ @app.call(env)
23
+ end
24
+ end
25
+
26
+ module YourApp
27
+ class Application < Rails::Application
28
+ # ...
29
+
30
+ initializer "your_app.insert_request_bypasser", after: "aikido.add_middleware" do |app|
31
+ app.middleware.insert_after Aikido::Zen::Middleware::ContextSetter, RequestBypasser
32
+ end
33
+ end
34
+ end
35
+ ```
36
+
37
+ > [!WARNING]
38
+ > Use this feature with caution, as it can potentially expose your application to security risks if not used properly.
@@ -13,6 +13,16 @@ 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
 
@@ -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: AttackWave::Helpers.original_fullpath(request)
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?
@@ -24,6 +24,10 @@ module Aikido::Zen
24
24
  attr_accessor :scanning
25
25
  alias_method :scanning?, :scanning
26
26
 
27
+ # @return [Boolean, nil] whether the request is bypassed.
28
+ attr_accessor :request_bypassed
29
+ alias_method :request_bypassed?, :request_bypassed
30
+
27
31
  # @return [Boolean] whether attack protection for the currently requested
28
32
  # endpoint was disabled on the Aikido dashboard, or if the source IP for
29
33
  # this request is in the "Bypass List".
@@ -48,6 +52,7 @@ module Aikido::Zen
48
52
 
49
53
  @metadata = {}
50
54
  @scanning = false
55
+ @request_bypassed = nil
51
56
  @protection_disabled = false
52
57
  @idor_protection_enabled = false
53
58
  end
@@ -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)
@@ -5,8 +5,9 @@ module Aikido::Zen
5
5
  # Middleware that only allows allowed IPs when allowed IPs are configured for
6
6
  # any matching route in the Aikido dashboard.
7
7
  class AllowedAddressChecker
8
- def initialize(app, config: Aikido::Zen.config, settings: Aikido::Zen.runtime_settings)
8
+ def initialize(app, zen: Aikido::Zen, config: zen.config, settings: zen.runtime_settings)
9
9
  @app = app
10
+ @zen = zen
10
11
  @config = config
11
12
  @settings = settings
12
13
  end
@@ -22,7 +23,7 @@ module Aikido::Zen
22
23
  end
23
24
 
24
25
  private def allowed?(request)
25
- return true if @settings.bypassed_ips.include?(request.client_ip)
26
+ return true if @zen.request_bypassed?
26
27
 
27
28
  matches = @settings.endpoints.matched_settings(request.route)
28
29
 
@@ -19,7 +19,7 @@ module Aikido::Zen
19
19
  end
20
20
 
21
21
  private def protection_disabled?(request)
22
- return true if @settings.bypassed_ips.include?(request.client_ip)
22
+ return true if @zen.request_bypassed?
23
23
 
24
24
  !@settings.endpoints.matched_settings(request.route).all?(&:protected?)
25
25
  end
@@ -4,10 +4,9 @@ module Aikido
4
4
  module Zen
5
5
  module Middleware
6
6
  class AttackWaveProtector
7
- def initialize(app, zen: Aikido::Zen, settings: Aikido::Zen.runtime_settings)
7
+ def initialize(app, zen: Aikido::Zen)
8
8
  @app = app
9
9
  @zen = zen
10
- @settings = settings
11
10
  end
12
11
 
13
12
  def call(env)
@@ -21,43 +20,46 @@ module Aikido
21
20
  end
22
21
 
23
22
  # @api private
24
- # Visible for testing.
25
- def attack_wave?(context, status_code = nil)
23
+ # @note Visible for testing.
24
+ #
25
+ # @param context [Aikido::Zen::Context]
26
+ # @param status_code [Integer, nil]
27
+ # @return [Array<Aikido::Zen::AttackWave::Sample>, nil]
28
+ def detect_attack_wave(context, status_code = nil)
26
29
  request = context.request
27
- return false if request.nil?
30
+ return nil if request.nil?
28
31
 
29
- return false if @settings.bypassed_ips.include?(request.client_ip)
32
+ return nil if @zen.request_bypassed?
30
33
 
31
- @zen.attack_wave_detector.attack_wave?(context, status_code)
34
+ @zen.detect_attack_wave(context, status_code)
32
35
  end
33
36
 
34
37
  # @api private
35
- # Visible for testing.
38
+ # @note Visible for testing.
36
39
  def protect(context, status_code = nil)
37
- if attack_wave?(context, status_code)
38
- client_ip = context.request.client_ip
40
+ samples = detect_attack_wave(context, status_code)
41
+ return unless samples
39
42
 
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
- )
43
+ client_ip = context.request.client_ip
45
44
 
46
- samples = @zen.attack_wave_detector.samples[client_ip].to_a
45
+ request = Aikido::Zen::AttackWave::Request.new(
46
+ ip_address: client_ip,
47
+ user_agent: context.request.user_agent,
48
+ source: context.request.framework
49
+ )
47
50
 
48
- attack = Aikido::Zen::AttackWave::Attack.new(
49
- samples: samples,
50
- user: context.request.actor
51
- )
51
+ attack = Aikido::Zen::AttackWave::Attack.new(
52
+ samples: samples,
53
+ user: context.request.actor
54
+ )
52
55
 
53
- attack_wave = Aikido::Zen::Events::AttackWave.new(
54
- request: request,
55
- attack: attack
56
- )
56
+ attack_wave = Aikido::Zen::Events::AttackWave.new(
57
+ request: request,
58
+ attack: attack
59
+ )
57
60
 
58
- @zen.track_attack_wave(attack_wave)
59
- @zen.agent.report(attack_wave)
60
- end
61
+ @zen.track_attack_wave(attack_wave)
62
+ @zen.agent.report(attack_wave)
61
63
  end
62
64
  end
63
65
  end
@@ -15,7 +15,7 @@ module Aikido::Zen
15
15
 
16
16
  client_ip = request.client_ip
17
17
 
18
- return @app.call(env) if bypassed_ip?(client_ip)
18
+ return @app.call(env) if @zen.request_bypassed?
19
19
 
20
20
  if !@settings.allowed_ip?(client_ip)
21
21
  return @config.blocked_responder.call(request, :ip_allowed_list)
@@ -38,10 +38,6 @@ module Aikido::Zen
38
38
 
39
39
  @app.call(env)
40
40
  end
41
-
42
- def bypassed_ip?(client_ip)
43
- @settings.bypassed_ips.include?(client_ip)
44
- end
45
41
  end
46
42
  end
47
43
  end
@@ -34,7 +34,7 @@ module Aikido::Zen
34
34
  private
35
35
 
36
36
  def should_throttle?(request)
37
- return false if @settings.bypassed_ips.include?(request.client_ip)
37
+ return false if @zen.request_bypassed?
38
38
 
39
39
  return false if request.actor && @settings.user_excluded_from_rate_limiting?(request.actor.id)
40
40
 
@@ -5,8 +5,9 @@ module Aikido::Zen
5
5
  # Rack middleware used to track request
6
6
  # It implements the logic under that which is considered worthy of being tracked.
7
7
  class RequestTracker
8
- def initialize(app, settings: Aikido::Zen.runtime_settings)
8
+ def initialize(app, zen: Aikido::Zen, settings: zen.runtime_settings)
9
9
  @app = app
10
+ @zen = zen
10
11
  @settings = settings
11
12
  end
12
13
 
@@ -17,13 +18,12 @@ module Aikido::Zen
17
18
  if request.route && track?(
18
19
  status_code: response[0],
19
20
  route: request.route.path,
20
- http_method: request.request_method,
21
- ip: request.client_ip
21
+ http_method: request.request_method
22
22
  )
23
- Aikido::Zen.track_request(request)
23
+ @zen.track_request(request)
24
24
 
25
- if Aikido::Zen.config.collect_api_schema?
26
- Aikido::Zen.track_discovered_route(request)
25
+ if @zen.config.collect_api_schema?
26
+ @zen.track_discovered_route(request)
27
27
  end
28
28
  end
29
29
 
@@ -128,8 +128,8 @@ module Aikido::Zen
128
128
  # @param status_code [Integer]
129
129
  # @param route [String]
130
130
  # @param http_method [String]
131
- def track?(status_code:, route:, http_method:, ip: nil)
132
- return false if @settings.bypassed_ips.include?(ip)
131
+ def track?(status_code:, route:, http_method:)
132
+ return false if @zen.request_bypassed?
133
133
 
134
134
  # In the UI we want to show only successful (2xx) or redirect (3xx) responses
135
135
  # anything else is discarded.
@@ -13,7 +13,7 @@ module Aikido::Zen
13
13
  def call(env)
14
14
  request = Aikido::Zen::Middleware.request_from(env)
15
15
 
16
- return @app.call(env) if bypassed?(request)
16
+ return @app.call(env) if @zen.request_bypassed?
17
17
 
18
18
  user_agent = request.user_agent
19
19
 
@@ -31,10 +31,6 @@ module Aikido::Zen
31
31
 
32
32
  @app.call(env)
33
33
  end
34
-
35
- def bypassed?(request)
36
- @settings.bypassed_ips.include?(request.client_ip)
37
- end
38
34
  end
39
35
  end
40
36
  end
@@ -30,7 +30,7 @@ module Aikido::Zen
30
30
 
31
31
  request = context.request
32
32
 
33
- return false if @settings.bypassed_ips.include?(request.client_ip)
33
+ return false if @zen.request_bypassed?
34
34
 
35
35
  if should_block_user?(request)
36
36
  status, headers, body = @config.blocked_responder.call(request, :user)
@@ -52,9 +52,7 @@ module Aikido::Zen
52
52
 
53
53
  settings = Aikido::Zen.runtime_settings
54
54
 
55
- client_ip = context&.request&.client_ip
56
-
57
- unless settings.bypassed_ip?(client_ip)
55
+ unless Aikido::Zen.request_bypassed?
58
56
  Aikido::Zen.track_outbound(connection)
59
57
 
60
58
  if settings.block_outbound?(connection)
@@ -64,9 +64,7 @@ module Aikido::Zen
64
64
 
65
65
  settings = Aikido::Zen.runtime_settings
66
66
 
67
- client_ip = context&.request&.client_ip
68
-
69
- unless settings.bypassed_ip?(client_ip)
67
+ unless Aikido::Zen.request_bypassed?
70
68
  Aikido::Zen.track_outbound(connection)
71
69
 
72
70
  if settings.block_outbound?(connection)
@@ -50,9 +50,7 @@ module Aikido::Zen
50
50
 
51
51
  settings = Aikido::Zen.runtime_settings
52
52
 
53
- client_ip = context&.request&.client_ip
54
-
55
- unless settings.bypassed_ip?(client_ip)
53
+ unless Aikido::Zen.request_bypassed?
56
54
  Aikido::Zen.track_outbound(connection)
57
55
 
58
56
  if settings.block_outbound?(connection)
@@ -56,9 +56,7 @@ module Aikido::Zen
56
56
 
57
57
  settings = Aikido::Zen.runtime_settings
58
58
 
59
- client_ip = context&.request&.client_ip
60
-
61
- unless settings.bypassed_ip?(client_ip)
59
+ unless Aikido::Zen.request_bypassed?
62
60
  Aikido::Zen.track_outbound(connection)
63
61
 
64
62
  if settings.block_outbound?(connection)
@@ -70,9 +70,7 @@ module Aikido::Zen
70
70
 
71
71
  settings = Aikido::Zen.runtime_settings
72
72
 
73
- client_ip = context&.request&.client_ip
74
-
75
- unless settings.bypassed_ip?(client_ip)
73
+ unless Aikido::Zen.request_bypassed?
76
74
  Aikido::Zen.track_outbound(connection)
77
75
 
78
76
  if settings.block_outbound?(connection)
@@ -50,9 +50,7 @@ module Aikido::Zen
50
50
 
51
51
  settings = Aikido::Zen.runtime_settings
52
52
 
53
- client_ip = context&.request&.client_ip
54
-
55
- unless settings.bypassed_ip?(client_ip)
53
+ unless Aikido::Zen.request_bypassed?
56
54
  Aikido::Zen.track_outbound(connection)
57
55
 
58
56
  if settings.block_outbound?(connection)
@@ -55,9 +55,7 @@ module Aikido::Zen
55
55
 
56
56
  settings = Aikido::Zen.runtime_settings
57
57
 
58
- client_ip = context&.request&.client_ip
59
-
60
- unless settings.bypassed_ip?(client_ip)
58
+ unless Aikido::Zen.request_bypassed?
61
59
  Aikido::Zen.track_outbound(connection)
62
60
 
63
61
  if settings.block_outbound?(connection)
@@ -86,9 +86,7 @@ module Aikido::Zen
86
86
 
87
87
  settings = Aikido::Zen.runtime_settings
88
88
 
89
- client_ip = context&.request&.client_ip
90
-
91
- unless settings.bypassed_ip?(client_ip)
89
+ unless Aikido::Zen.request_bypassed?
92
90
  Aikido::Zen.track_outbound(connection)
93
91
 
94
92
  if settings.block_outbound?(connection)
@@ -59,9 +59,7 @@ module Aikido::Zen
59
59
 
60
60
  settings = Aikido::Zen.runtime_settings
61
61
 
62
- client_ip = context&.request&.client_ip
63
-
64
- unless settings.bypassed_ip?(client_ip)
62
+ unless Aikido::Zen.request_bypassed?
65
63
  Aikido::Zen.track_outbound(connection)
66
64
 
67
65
  if settings.block_outbound?(connection)
@@ -26,9 +26,7 @@ module Aikido::Zen
26
26
 
27
27
  settings = Aikido::Zen.runtime_settings
28
28
 
29
- client_ip = context&.request&.client_ip
30
-
31
- unless settings.bypassed_ip?(client_ip)
29
+ unless Aikido::Zen.request_bypassed?
32
30
  Aikido::Zen.track_outbound(connection)
33
31
 
34
32
  if settings.block_outbound?(connection)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "1.7.2.beta.1"
5
+ VERSION = "1.7.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
@@ -153,6 +153,23 @@ module Aikido
153
153
  Fiber.current.aikido_current_context = context
154
154
  end
155
155
 
156
+ # @return [Boolean] whether the current request is bypassed.
157
+ def self.request_bypassed?
158
+ context = current_context
159
+ return unless context
160
+
161
+ return context.request_bypassed unless context.request_bypassed.nil?
162
+
163
+ context.request_bypassed = runtime_settings.bypassed_ip?(context.request.client_ip)
164
+ end
165
+
166
+ # Marks the current request as bypassed.
167
+ #
168
+ # @return [void]
169
+ def self.request_bypassed!
170
+ current_context&.request_bypassed = true
171
+ end
172
+
156
173
  # Track statistics about an HTTP request the app is handling.
157
174
  #
158
175
  # @param request [Aikido::Zen::Request]
@@ -249,6 +266,49 @@ module Aikido
249
266
  @attack_wave_detector ||= AttackWave::Detector.new
250
267
  end
251
268
 
269
+ # Classifies the current request and, if it is classified as suspicious,
270
+ # records a sample against the attack wave detector.
271
+ #
272
+ # @param context [Aikido::Zen::Context]
273
+ # @param status_code [Integer, nil]
274
+ # @return [Array<Aikido::Zen::AttackWave::Sample>, nil]
275
+ def self.detect_attack_wave(context, status_code = nil)
276
+ client_ip = context.request.client_ip
277
+ return nil unless client_ip
278
+
279
+ return nil unless AttackWave::Helpers.web_scanner?(context, status_code)
280
+
281
+ record_attack_wave(client_ip, AttackWave::Helpers.sample_for(context))
282
+ end
283
+
284
+ # Records a suspicious sample and, if the threshold for triggering
285
+ # an attack wave has been crossed, flags the client IP as having
286
+ # just triggered an attack wave.
287
+ #
288
+ # In multiprocess deployments, on RPC failure, the worker process
289
+ # records against its local detector.
290
+ #
291
+ # If RPC failures are intermittent, it is possible that an attack
292
+ # wave may be missed or duplicated, because state is split between
293
+ # the global and local detectors.
294
+ #
295
+ # @param client_ip [String]
296
+ # @param sample [Aikido::Zen::AttackWave::Sample]
297
+ # @return [Array<Aikido::Zen::AttackWave::Sample>, nil]
298
+ def self.record_attack_wave(client_ip, sample)
299
+ worker_process_client = @worker_process_client
300
+
301
+ if worker_process_client
302
+ begin
303
+ worker_process_client.record_attack_wave(client_ip, sample)
304
+ rescue
305
+ attack_wave_detector.record(client_ip, sample)
306
+ end
307
+ else
308
+ attack_wave_detector.record(client_ip, sample)
309
+ end
310
+ end
311
+
252
312
  # @return [Aikido::Zen::IDOR::Protector]
253
313
  def self.idor_protector
254
314
  @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.2.beta.1
4
+ version: 1.7.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-11 00:00:00.000000000 Z
11
+ date: 2026-08-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -93,6 +93,7 @@ files:
93
93
  - docs/invalid-sql-queries.md
94
94
  - docs/proxy.md
95
95
  - docs/rails.md
96
+ - docs/request-bypassing.md
96
97
  - docs/troubleshooting.md
97
98
  - lib/aikido-zen.rb
98
99
  - lib/aikido/zen.rb