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 +4 -4
- data/lib/aikido/zen/attack_wave/helpers.rb +30 -1
- data/lib/aikido/zen/attack_wave.rb +52 -16
- data/lib/aikido/zen/cache.rb +3 -3
- data/lib/aikido/zen/collector/routes.rb +2 -2
- data/lib/aikido/zen/collector.rb +0 -5
- data/lib/aikido/zen/idor/protector.rb +1 -1
- data/lib/aikido/zen/middleware/attack_wave_protector.rb +28 -25
- data/lib/aikido/zen/version.rb +1 -1
- data/lib/aikido/zen/worker_process/agent/client.rb +19 -0
- data/lib/aikido/zen/worker_process/agent/server.rb +14 -3
- data/lib/aikido/zen.rb +43 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aedfb64ca08c10194c0483138460f984e086ad2ac95250d3ce570b4c08b48263
|
|
4
|
+
data.tar.gz: 6332c4155469c35483394a76683169e973feea9ba417fb4c4f434f8df3231b2e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
request_count = @request_counts[client_ip] += 1
|
|
34
70
|
|
|
35
|
-
|
|
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
|
-
|
|
73
|
+
return nil if request_count < @config.attack_wave_threshold
|
|
43
74
|
|
|
44
|
-
|
|
75
|
+
flag!(client_ip)
|
|
45
76
|
|
|
46
|
-
|
|
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
|
|
data/lib/aikido/zen/cache.rb
CHANGED
|
@@ -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
|
data/lib/aikido/zen/collector.rb
CHANGED
|
@@ -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?
|
|
@@ -21,43 +21,46 @@ module Aikido
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
# @api private
|
|
24
|
-
# Visible for testing.
|
|
25
|
-
|
|
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
|
|
31
|
+
return nil if request.nil?
|
|
28
32
|
|
|
29
|
-
return
|
|
33
|
+
return nil if @settings.bypassed_ips.include?(request.client_ip)
|
|
30
34
|
|
|
31
|
-
@zen.
|
|
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
|
-
|
|
38
|
-
|
|
41
|
+
samples = detect_attack_wave(context, status_code)
|
|
42
|
+
return unless samples
|
|
39
43
|
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
attack = Aikido::Zen::AttackWave::Attack.new(
|
|
53
|
+
samples: samples,
|
|
54
|
+
user: context.request.actor
|
|
55
|
+
)
|
|
52
56
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
attack_wave = Aikido::Zen::Events::AttackWave.new(
|
|
58
|
+
request: request,
|
|
59
|
+
attack: attack
|
|
60
|
+
)
|
|
57
61
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
end
|
|
62
|
+
@zen.track_attack_wave(attack_wave)
|
|
63
|
+
@zen.agent.report(attack_wave)
|
|
61
64
|
end
|
|
62
65
|
end
|
|
63
66
|
end
|
data/lib/aikido/zen/version.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
11
|
+
date: 2026-08-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: concurrent-ruby
|