aikido-zen 0.2.0-arm64-linux → 1.0.0.pre.beta.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 +4 -4
- data/.simplecov +6 -0
- data/benchmarks/README.md +8 -12
- data/docs/rails.md +1 -1
- data/lib/aikido/zen/agent.rb +10 -8
- data/lib/aikido/zen/api_client.rb +14 -4
- data/lib/aikido/zen/background_worker.rb +52 -0
- data/lib/aikido/zen/collector.rb +12 -1
- data/lib/aikido/zen/config.rb +11 -0
- data/lib/aikido/zen/detached_agent/agent.rb +78 -0
- data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
- data/lib/aikido/zen/detached_agent/server.rb +41 -0
- data/lib/aikido/zen/detached_agent.rb +2 -0
- data/lib/aikido/zen/errors.rb +8 -0
- data/lib/aikido/zen/middleware/rack_throttler.rb +9 -3
- data/lib/aikido/zen/outbound_connection_monitor.rb +4 -0
- data/lib/aikido/zen/rails_engine.rb +4 -0
- data/lib/aikido/zen/rate_limiter/breaker.rb +3 -3
- data/lib/aikido/zen/rate_limiter.rb +6 -11
- data/lib/aikido/zen/request/rails_router.rb +6 -18
- data/lib/aikido/zen/request/schema/auth_schemas.rb +14 -0
- data/lib/aikido/zen/request/schema.rb +18 -0
- data/lib/aikido/zen/runtime_settings.rb +2 -2
- data/lib/aikido/zen/scanners/path_traversal_scanner.rb +4 -2
- data/lib/aikido/zen/scanners/shell_injection_scanner.rb +4 -2
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +4 -2
- data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +33 -21
- data/lib/aikido/zen/scanners/ssrf_scanner.rb +6 -1
- data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +6 -0
- data/lib/aikido/zen/sink.rb +6 -1
- data/lib/aikido/zen/sinks/action_controller.rb +9 -4
- data/lib/aikido/zen/sinks/socket.rb +13 -0
- data/lib/aikido/zen/version.rb +1 -1
- data/lib/aikido/zen/worker.rb +5 -0
- data/lib/aikido/zen.rb +41 -8
- data/tasklib/bench.rake +29 -6
- data/tasklib/wrk.rb +88 -0
- metadata +8 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b2ec39a099fb18356244ef6c4634f1a03190a486d08b89d2e455badd06d4f5a2
|
4
|
+
data.tar.gz: 65950e78805aec6fe99cd38957748f989bcad7c90462ad1ee41648290ad6d5f4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 836a0bca75d8e2d0dda23b8820eedce546274207e22f9603d1a49b749f72c17d56df5878f6a7d627b35b2d0c49e9e1449dc79ff346a843000087871e4869a9b2
|
7
|
+
data.tar.gz: 0d603bb1618c423e7c0ac47807d6ff442aa54a2e094d12ca4936f87e004fab05995e41d69f706555188804b8cc2ec27276b87364e1654f5b33b468c2637367e7
|
data/.simplecov
CHANGED
@@ -15,6 +15,12 @@ SimpleCov.start do
|
|
15
15
|
minimum_coverage line: 95, branch: 85
|
16
16
|
|
17
17
|
add_filter "/test/"
|
18
|
+
|
19
|
+
# WebMock excludes EM-HTTP-Request on Ruby 3.4:
|
20
|
+
# https://github.com/c960657/webmock/commit/34d16285dbcc574c90b273a89f16cb5fb9f4222a
|
21
|
+
if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4.0") && Gem.loaded_specs["em-http-request"].version <= Gem::Version.new("1.1.7")
|
22
|
+
add_filter "lib/aikido/zen/sinks/em_http.rb"
|
23
|
+
end
|
18
24
|
end
|
19
25
|
|
20
26
|
# vim: ft=ruby
|
data/benchmarks/README.md
CHANGED
@@ -1,27 +1,23 @@
|
|
1
1
|
# Benchmarking Zen for Ruby
|
2
2
|
|
3
|
-
This directory contains the benchmarking scripts that we use to ensure adding
|
4
|
-
Zen to your application does not impact performance significantly.
|
5
3
|
|
6
|
-
We use [Grafana K6](https://k6.io) for these.
|
7
|
-
include in this repo under [sample_apps](../sample_apps), you should find
|
8
|
-
a script here that runs certain benchmarks against that app.
|
4
|
+
We use [WRK](https://github.com/wg/wrk) & [Grafana K6](https://k6.io) for these.
|
9
5
|
|
10
|
-
|
6
|
+
WRK benchmarks are only requesting a URL (`/benchmark`). In case you want to add more
|
7
|
+
of those test, you have to code them in the file `tasklib/bench.rake`.
|
11
8
|
|
12
|
-
|
13
|
-
|
14
|
-
```
|
9
|
+
K6 tests are defined in `benchmarks` folder. They are a javascript file, with calls
|
10
|
+
to different endpoints.
|
15
11
|
|
16
12
|
In order to run a benchmarks against a single application, run the following
|
17
13
|
from the root of the project:
|
18
14
|
|
19
15
|
```
|
20
|
-
$ bundle exec rake bench:{app}:
|
16
|
+
$ BUNDLE_GEMFILE=./sample_apps/{app}/Gemfile bundle exec rake bench:{app}:(k6|wrk)_run
|
21
17
|
```
|
22
18
|
|
23
|
-
For example, for the `rails7.
|
19
|
+
For example, for the WRK of `rails7.1_benchmark` application:
|
24
20
|
|
25
21
|
```
|
26
|
-
$ bundle exec rake bench:rails7.
|
22
|
+
$ BUNDLE_GEMFILE=./sample_apps/rails7.1_benchmark/Gemfile bundle exec rake bench:rails7.1_benchmark:wrk_run
|
27
23
|
```
|
data/docs/rails.md
CHANGED
@@ -40,7 +40,7 @@ You can just tell Zen to use it like so:
|
|
40
40
|
|
41
41
|
``` ruby
|
42
42
|
# config/initializers/zen.rb
|
43
|
-
Rails.application.config.zen.
|
43
|
+
Rails.application.config.zen.token = Rails.application.credentials.zen.token
|
44
44
|
```
|
45
45
|
|
46
46
|
[creds]: https://guides.rubyonrails.org/security.html#environmental-security
|
data/lib/aikido/zen/agent.rb
CHANGED
@@ -19,6 +19,7 @@ module Aikido::Zen
|
|
19
19
|
def initialize(
|
20
20
|
config: Aikido::Zen.config,
|
21
21
|
collector: Aikido::Zen.collector,
|
22
|
+
detached_agent: Aikido::Zen.detached_agent,
|
22
23
|
worker: Aikido::Zen::Worker.new(config: config),
|
23
24
|
api_client: Aikido::Zen::APIClient.new(config: config)
|
24
25
|
)
|
@@ -28,6 +29,7 @@ module Aikido::Zen
|
|
28
29
|
@worker = worker
|
29
30
|
@api_client = api_client
|
30
31
|
@collector = collector
|
32
|
+
@detached_agent = detached_agent
|
31
33
|
end
|
32
34
|
|
33
35
|
def started?
|
@@ -35,7 +37,7 @@ module Aikido::Zen
|
|
35
37
|
end
|
36
38
|
|
37
39
|
def start!
|
38
|
-
@config.logger.info "Starting Aikido agent"
|
40
|
+
@config.logger.info "Starting Aikido agent v#{Aikido::Zen::VERSION}"
|
39
41
|
|
40
42
|
raise Aikido::ZenError, "Aikido Agent already started!" if started?
|
41
43
|
@started_at = Time.now.utc
|
@@ -57,7 +59,7 @@ module Aikido::Zen
|
|
57
59
|
at_exit { stop! if started? }
|
58
60
|
|
59
61
|
report(Events::Started.new(time: @started_at)) do |response|
|
60
|
-
Aikido::Zen.runtime_settings.update_from_json(response)
|
62
|
+
updated_settings! if Aikido::Zen.runtime_settings.update_from_json(response)
|
61
63
|
@config.logger.info "Updated runtime settings."
|
62
64
|
rescue => err
|
63
65
|
@config.logger.error(err.message)
|
@@ -141,11 +143,11 @@ module Aikido::Zen
|
|
141
143
|
def send_heartbeat(at: Time.now.utc)
|
142
144
|
return unless @api_client.can_make_requests?
|
143
145
|
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
146
|
+
@collector.flush_heartbeats.each do |heartbeat|
|
147
|
+
report(heartbeat) do |response|
|
148
|
+
updated_settings! if Aikido::Zen.runtime_settings.update_from_json(response)
|
149
|
+
@config.logger.info "Updated runtime settings after heartbeat"
|
150
|
+
end
|
149
151
|
end
|
150
152
|
end
|
151
153
|
|
@@ -159,7 +161,7 @@ module Aikido::Zen
|
|
159
161
|
def poll_for_setting_updates
|
160
162
|
@worker.every(@config.polling_interval) do
|
161
163
|
if @api_client.should_fetch_settings?
|
162
|
-
Aikido::Zen.runtime_settings.update_from_json(@api_client.fetch_settings)
|
164
|
+
updated_settings! if Aikido::Zen.runtime_settings.update_from_json(@api_client.fetch_settings)
|
163
165
|
@config.logger.info "Updated runtime settings after polling"
|
164
166
|
end
|
165
167
|
end
|
@@ -72,16 +72,26 @@ module Aikido::Zen
|
|
72
72
|
# @return (see #fetch_settings)
|
73
73
|
# @raise (see #request)
|
74
74
|
def report(event)
|
75
|
-
if
|
76
|
-
|
75
|
+
event_type = if event.respond_to?(:type)
|
76
|
+
event.type
|
77
|
+
else
|
78
|
+
event[:type]
|
79
|
+
end
|
80
|
+
|
81
|
+
if @rate_limiter.throttle?(event_type)
|
82
|
+
@config.logger.error("Not reporting #{event_type.upcase} event due to rate limiting")
|
77
83
|
return
|
78
84
|
end
|
79
85
|
|
80
|
-
@config.logger.debug("Reporting #{
|
86
|
+
@config.logger.debug("Reporting #{event_type.upcase} event")
|
81
87
|
|
82
88
|
req = Net::HTTP::Post.new("/api/runtime/events", default_headers)
|
83
89
|
req.content_type = "application/json"
|
84
|
-
req.body =
|
90
|
+
req.body = if event.respond_to?(:as_json)
|
91
|
+
@config.json_encoder.call(event.as_json)
|
92
|
+
else
|
93
|
+
@config.json_encoder.call(event)
|
94
|
+
end
|
85
95
|
|
86
96
|
request(req)
|
87
97
|
rescue Aikido::Zen::RateLimitedError
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module Aikido::Zen
|
2
|
+
# Generic background worker class backed by queue. Meant to be used by any
|
3
|
+
# background process that needs to do heavy tasks.
|
4
|
+
class BackgroundWorker
|
5
|
+
# @param block [block] A block that receives 1 message directly from the queue
|
6
|
+
def initialize(&block)
|
7
|
+
@queue = Queue.new
|
8
|
+
@block = block
|
9
|
+
end
|
10
|
+
|
11
|
+
# starts the background thread, blocking the thread until a new messages arrives
|
12
|
+
# or the queue is stopped.
|
13
|
+
def start
|
14
|
+
@thread = Thread.new do
|
15
|
+
while running? || actions?
|
16
|
+
action = wait_for_action
|
17
|
+
@block.call(action) unless action.nil?
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def restart
|
23
|
+
stop
|
24
|
+
@queue = Queue.new # re-open the queue
|
25
|
+
start
|
26
|
+
end
|
27
|
+
|
28
|
+
# Drain the queue to do not lose any messages
|
29
|
+
def stop
|
30
|
+
@queue.close # stop accepting messages
|
31
|
+
@thread.join # wait for the queue to be drained
|
32
|
+
end
|
33
|
+
|
34
|
+
def enqueue(scan)
|
35
|
+
@queue.push(scan)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def actions?
|
41
|
+
!@queue.empty?
|
42
|
+
end
|
43
|
+
|
44
|
+
def running?
|
45
|
+
!@queue.closed?
|
46
|
+
end
|
47
|
+
|
48
|
+
def wait_for_action
|
49
|
+
@queue.pop(false)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
data/lib/aikido/zen/collector.rb
CHANGED
@@ -11,6 +11,7 @@ module Aikido::Zen
|
|
11
11
|
@users = Concurrent::AtomicReference.new(Users.new(@config))
|
12
12
|
@hosts = Concurrent::AtomicReference.new(Hosts.new(@config))
|
13
13
|
@routes = Concurrent::AtomicReference.new(Routes.new(@config))
|
14
|
+
@heartbeats = Queue.new
|
14
15
|
@middleware_installed = Concurrent::AtomicBoolean.new
|
15
16
|
end
|
16
17
|
|
@@ -34,6 +35,16 @@ module Aikido::Zen
|
|
34
35
|
)
|
35
36
|
end
|
36
37
|
|
38
|
+
# Put heartbeats coming from child processes into the internal queue.
|
39
|
+
def push_heartbeat(heartbeat)
|
40
|
+
@heartbeats << heartbeat
|
41
|
+
end
|
42
|
+
|
43
|
+
# Drains into an array all the queued heartbeats
|
44
|
+
def flush_heartbeats
|
45
|
+
Array.new(@heartbeats.size) { @heartbeats.pop }
|
46
|
+
end
|
47
|
+
|
37
48
|
# Sets the start time for this collection period.
|
38
49
|
#
|
39
50
|
# @param at [Time] defaults to now.
|
@@ -46,7 +57,7 @@ module Aikido::Zen
|
|
46
57
|
#
|
47
58
|
# @param request [Aikido::Zen::Request]
|
48
59
|
# @return [void]
|
49
|
-
def track_request(
|
60
|
+
def track_request(*)
|
50
61
|
synchronize(@stats) { |stats| stats.add_request }
|
51
62
|
end
|
52
63
|
|
data/lib/aikido/zen/config.rb
CHANGED
@@ -55,6 +55,11 @@ module Aikido::Zen
|
|
55
55
|
# @return [Logger]
|
56
56
|
attr_reader :logger
|
57
57
|
|
58
|
+
# @return [string] Path of the socket where the detached agent will listen.
|
59
|
+
# By default, is stored under the root application path with file name
|
60
|
+
# `aikido-detached-agent.socket`
|
61
|
+
attr_reader :detached_agent_socket_path
|
62
|
+
|
58
63
|
# @return [Boolean] is the agent in debugging mode?
|
59
64
|
attr_accessor :debugging
|
60
65
|
alias_method :debugging?, :debugging
|
@@ -153,6 +158,7 @@ module Aikido::Zen
|
|
153
158
|
self.debugging = read_boolean_from_env(ENV.fetch("AIKIDO_DEBUG", false))
|
154
159
|
self.logger = Logger.new($stdout, progname: "aikido", level: debugging ? Logger::DEBUG : Logger::INFO)
|
155
160
|
self.max_performance_samples = 5000
|
161
|
+
self.detached_agent_socket_path = "aikido-detached-agent.socket"
|
156
162
|
self.max_compressed_stats = 100
|
157
163
|
self.max_outbound_connections = 200
|
158
164
|
self.max_users_tracked = 1000
|
@@ -210,6 +216,11 @@ module Aikido::Zen
|
|
210
216
|
@api_timeouts.update(value)
|
211
217
|
end
|
212
218
|
|
219
|
+
def detached_agent_socket_path=(path)
|
220
|
+
@detached_agent_socket_path = path
|
221
|
+
@detached_agent_socket_path = "drbunix:" + @detached_agent_socket_path unless @detached_agent_socket_path.start_with?("drbunix:")
|
222
|
+
end
|
223
|
+
|
213
224
|
private
|
214
225
|
|
215
226
|
def read_boolean_from_env(value)
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "drb/drb"
|
4
|
+
require "drb/unix"
|
5
|
+
require_relative "front_object"
|
6
|
+
require_relative "../background_worker"
|
7
|
+
|
8
|
+
module Aikido::Zen::DetachedAgent
|
9
|
+
# Agent that runs in forked processes. It communicates with the parent process to dRB
|
10
|
+
# calls. It's in charge of schedule and send heartbeats to the *parent process*, to be
|
11
|
+
# later pushed.
|
12
|
+
#
|
13
|
+
# heartbeat & polling interval are configured to 10s , because they are connecting with
|
14
|
+
# parent process. We want to have the freshest data.
|
15
|
+
#
|
16
|
+
# It's possible to use `extend Forwardable` here for one-line forward calls to the
|
17
|
+
# @detached_agent_front object. Unfortunately, the methods to be called are
|
18
|
+
# created at runtime by `DRbObject`, which leads to an ugly warning about
|
19
|
+
# private methods after the delegator is bound.
|
20
|
+
class Agent
|
21
|
+
attr_reader :worker
|
22
|
+
|
23
|
+
def initialize(
|
24
|
+
heartbeat_interval: 10,
|
25
|
+
polling_interval: 10,
|
26
|
+
config: Aikido::Zen.config,
|
27
|
+
collector: Aikido::Zen.collector,
|
28
|
+
worker: Aikido::Zen::Worker.new(config: config)
|
29
|
+
)
|
30
|
+
@config = config
|
31
|
+
@heartbeat_interval = heartbeat_interval
|
32
|
+
@polling_interval = polling_interval
|
33
|
+
@worker = worker
|
34
|
+
@collector = collector
|
35
|
+
@detached_agent_front = DRbObject.new_with_uri(config.detached_agent_socket_path)
|
36
|
+
@has_forked = false
|
37
|
+
schedule_tasks
|
38
|
+
end
|
39
|
+
|
40
|
+
def send_heartbeat(at: Time.now.utc)
|
41
|
+
return unless @collector.stats.any?
|
42
|
+
|
43
|
+
heartbeat = @collector.flush(at: at)
|
44
|
+
@detached_agent_front.send_heartbeat_to_parent_process(heartbeat.as_json)
|
45
|
+
end
|
46
|
+
|
47
|
+
private def schedule_tasks
|
48
|
+
# For heartbeats is correct to send them from parent or child process. Otherwise, we'll lose
|
49
|
+
# stats made by the parent process.
|
50
|
+
@worker.every(@heartbeat_interval, run_now: false) { send_heartbeat }
|
51
|
+
|
52
|
+
# Runtime_settings fetch must happens only in the child processes, otherwise, due to
|
53
|
+
# we are updating the global runtime_settings, we could have an infinite recursion.
|
54
|
+
if @has_forked
|
55
|
+
@worker.every(@polling_interval) do
|
56
|
+
Aikido::Zen.runtime_settings = @detached_agent_front.updated_settings
|
57
|
+
@config.logger.debug "Updated runtime settings after polling from child process #{Process.pid}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def calculate_rate_limits(request)
|
63
|
+
@detached_agent_front.calculate_rate_limits(request.route, request.ip, request.actor.to_json)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Every time a fork occurs (a new child process is created), we need to start
|
67
|
+
# a DRb service in a background thread within the child process. This service
|
68
|
+
# will manage the connection and handle resource cleanup.
|
69
|
+
def handle_fork
|
70
|
+
@has_forked = true
|
71
|
+
DRb.start_service
|
72
|
+
# we need to ensure that there are not more jobs in the queue, but
|
73
|
+
# we reuse the same object
|
74
|
+
@worker.restart
|
75
|
+
schedule_tasks
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# dRB Front object that will work as a bridge communication between child & parent
|
4
|
+
# processes.
|
5
|
+
# Every method is called from the child but it runs in the parent process.
|
6
|
+
module Aikido::Zen::DetachedAgent
|
7
|
+
class FrontObject
|
8
|
+
def initialize(
|
9
|
+
config: Aikido::Zen.config,
|
10
|
+
collector: Aikido::Zen.collector,
|
11
|
+
runtime_settings: Aikido::Zen.runtime_settings,
|
12
|
+
rate_limiter: Aikido::Zen::RateLimiter.new
|
13
|
+
)
|
14
|
+
@config = config
|
15
|
+
@collector = collector
|
16
|
+
@rate_limiter = rate_limiter
|
17
|
+
@runtime_settings = runtime_settings
|
18
|
+
end
|
19
|
+
|
20
|
+
RequestKind = Struct.new(:route, :schema, :ip, :actor)
|
21
|
+
|
22
|
+
def send_heartbeat_to_parent_process(heartbeat)
|
23
|
+
@collector.push_heartbeat(heartbeat)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Method called by child processes to get an up-to-date version of the
|
27
|
+
# runtime_settings
|
28
|
+
def updated_settings
|
29
|
+
@runtime_settings
|
30
|
+
end
|
31
|
+
|
32
|
+
def calculate_rate_limits(route, ip, actor_hash)
|
33
|
+
actor = Aikido::Zen::Actor(actor_hash) if actor_hash
|
34
|
+
@rate_limiter.calculate_rate_limits(RequestKind.new(route, nil, ip, actor))
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen::DetachedAgent
|
4
|
+
class Server
|
5
|
+
def initialize(config: Aikido::Zen.config)
|
6
|
+
@detached_agent_front = FrontObject.new
|
7
|
+
@drb_server = DRb.start_service(config.detached_agent_socket_path, @detached_agent_front)
|
8
|
+
|
9
|
+
# We don't want to see drb logs unless in debug mode
|
10
|
+
@drb_server.verbose = config.logger.debug?
|
11
|
+
end
|
12
|
+
|
13
|
+
def alive?
|
14
|
+
@drb_server.alive?
|
15
|
+
end
|
16
|
+
|
17
|
+
def stop!
|
18
|
+
@drb_server.stop_service
|
19
|
+
DRb.stop_service
|
20
|
+
end
|
21
|
+
|
22
|
+
class << self
|
23
|
+
def start!
|
24
|
+
Aikido::Zen.config.logger.debug("Starting DRb Server...")
|
25
|
+
max_attempts = 10
|
26
|
+
@server = new
|
27
|
+
|
28
|
+
attempts = 0
|
29
|
+
until @server.alive?
|
30
|
+
Aikido::Zen.config.logger.info("DRb Server still not alive. #{max_attempts - attempts} attempts remaining")
|
31
|
+
sleep 0.1
|
32
|
+
attempts += 1
|
33
|
+
raise Aikido::Zen::DetachedAgentError.new("Impossible to start the dRB server (socket=#{Aikido::Zen.config.detached_agent_socket_path})") \
|
34
|
+
if attempts == max_attempts
|
35
|
+
end
|
36
|
+
|
37
|
+
@server
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
data/lib/aikido/zen/errors.rb
CHANGED
@@ -12,12 +12,12 @@ module Aikido::Zen
|
|
12
12
|
app,
|
13
13
|
config: Aikido::Zen.config,
|
14
14
|
settings: Aikido::Zen.runtime_settings,
|
15
|
-
|
15
|
+
detached_agent: Aikido::Zen.detached_agent
|
16
16
|
)
|
17
17
|
@app = app
|
18
18
|
@config = config
|
19
19
|
@settings = settings
|
20
|
-
@
|
20
|
+
@detached_agent = detached_agent
|
21
21
|
end
|
22
22
|
|
23
23
|
def call(env)
|
@@ -33,9 +33,15 @@ module Aikido::Zen
|
|
33
33
|
private
|
34
34
|
|
35
35
|
def should_throttle?(request)
|
36
|
+
return false unless @settings.endpoints[request.route].rate_limiting.enabled?
|
36
37
|
return false if @settings.skip_protection_for_ips.include?(request.ip)
|
37
38
|
|
38
|
-
@
|
39
|
+
result = @detached_agent.calculate_rate_limits(request)
|
40
|
+
|
41
|
+
return false unless result
|
42
|
+
|
43
|
+
request.env["aikido.rate_limiting"] = result
|
44
|
+
request.env["aikido.rate_limiting"].throttled?
|
39
45
|
end
|
40
46
|
end
|
41
47
|
end
|
@@ -5,6 +5,10 @@ module Aikido::Zen
|
|
5
5
|
# any Sink that wraps an HTTP library, and lets us keep track of any hosts to
|
6
6
|
# which the app communicates over HTTP.
|
7
7
|
module OutboundConnectionMonitor
|
8
|
+
def self.skips_on_nil_context?
|
9
|
+
false
|
10
|
+
end
|
11
|
+
|
8
12
|
# This simply reports the connection to the Agent, and always returns +nil+
|
9
13
|
# as it's not scanning for any particular attack.
|
10
14
|
#
|
@@ -61,6 +61,10 @@ module Aikido::Zen
|
|
61
61
|
# accordingly.
|
62
62
|
Aikido::Zen.load_sinks!
|
63
63
|
|
64
|
+
# It's important we start after loading sinks, so we can report the installed packages
|
65
|
+
Aikido::Zen.start!
|
66
|
+
Aikido::Zen.start!
|
67
|
+
|
64
68
|
# Agent's bootstrap process has finished —Controllers are patched to block
|
65
69
|
# unwanted requests, sinks are loaded, scanners are running—, so we mark
|
66
70
|
# the agent as installed.
|
@@ -31,13 +31,13 @@ module Aikido::Zen
|
|
31
31
|
@opened_at = @clock.call
|
32
32
|
end
|
33
33
|
|
34
|
-
# @param
|
34
|
+
# @param event_type [String] an event type which we'll use to decide
|
35
35
|
# if we should throttle it.
|
36
36
|
# @return [Boolean]
|
37
|
-
def throttle?(
|
37
|
+
def throttle?(event_type)
|
38
38
|
return true if open? && !try_close
|
39
39
|
|
40
|
-
result = @bucket.increment(
|
40
|
+
result = @bucket.increment(event_type)
|
41
41
|
result.throttled?
|
42
42
|
end
|
43
43
|
|
@@ -24,23 +24,18 @@ module Aikido::Zen
|
|
24
24
|
}
|
25
25
|
end
|
26
26
|
|
27
|
-
#
|
28
|
-
#
|
29
|
-
# the result of the check, and including useful stats in case you want to
|
30
|
-
# return RateLimit headers..
|
27
|
+
# Calculate based on the configuration whether a request will be
|
28
|
+
# rate-limited or not.
|
31
29
|
#
|
32
30
|
# @param request [Aikido::Zen::Request]
|
33
|
-
# @return [
|
34
|
-
|
35
|
-
# @see Aikido::Zen::RateLimiter::Result
|
36
|
-
def throttle?(request)
|
31
|
+
# @return [Aikido::Zen::RateLimiter::Result, nil]
|
32
|
+
def calculate_rate_limits(request)
|
37
33
|
settings = settings_for(request.route)
|
38
|
-
return
|
34
|
+
return nil unless settings.enabled?
|
39
35
|
|
40
36
|
bucket = @buckets[request.route]
|
41
37
|
key = @config.rate_limiting_discriminator.call(request)
|
42
|
-
|
43
|
-
request.env["aikido.rate_limiting"].throttled?
|
38
|
+
bucket.increment(key)
|
44
39
|
end
|
45
40
|
|
46
41
|
private
|
@@ -58,27 +58,15 @@ module Aikido::Zen
|
|
58
58
|
end
|
59
59
|
|
60
60
|
private def build_route(route, request, prefix: request.script_name)
|
61
|
-
|
62
|
-
end
|
63
|
-
end
|
64
|
-
|
65
|
-
module Rails
|
66
|
-
class Route < Aikido::Zen::Route
|
67
|
-
attr_reader :verb
|
61
|
+
route_wrapper = ActionDispatch::Routing::RouteWrapper.new(route)
|
68
62
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
63
|
+
path = if prefix.present?
|
64
|
+
File.join(prefix.to_s, route_wrapper.path).chomp("/")
|
65
|
+
else
|
66
|
+
route_wrapper.path
|
73
67
|
end
|
74
68
|
|
75
|
-
|
76
|
-
if @prefix.present?
|
77
|
-
File.join(@prefix.to_s, @route.path).chomp("/")
|
78
|
-
else
|
79
|
-
@route.path
|
80
|
-
end
|
81
|
-
end
|
69
|
+
Aikido::Zen::Route.new(verb: request.request_method, path: path)
|
82
70
|
end
|
83
71
|
end
|
84
72
|
end
|
@@ -18,6 +18,20 @@ module Aikido::Zen
|
|
18
18
|
@schemas.map(&:as_json) unless @schemas.empty?
|
19
19
|
end
|
20
20
|
|
21
|
+
def self.from_json(schemas_array)
|
22
|
+
return NONE if !schemas_array || schemas_array.empty?
|
23
|
+
|
24
|
+
AuthSchemas.new(schemas_array.map do |schema|
|
25
|
+
if schema[:type] == "http"
|
26
|
+
Authorization.new(schema[:scheme])
|
27
|
+
elsif schema[:type] == "apiKey"
|
28
|
+
ApiKey.new(schema[:in], schema[:name])
|
29
|
+
else
|
30
|
+
raise "Invalid schema type: #{schema[:type]}"
|
31
|
+
end
|
32
|
+
end)
|
33
|
+
end
|
34
|
+
|
21
35
|
def ==(other)
|
22
36
|
other.is_a?(self.class) && schemas == other.schemas
|
23
37
|
end
|
@@ -48,6 +48,24 @@ module Aikido::Zen
|
|
48
48
|
{body: body, query: query_schema.as_json, auth: auth_schema.as_json}.compact
|
49
49
|
end
|
50
50
|
|
51
|
+
def self.from_json(data)
|
52
|
+
if data.empty?
|
53
|
+
return Request::Schema.new(
|
54
|
+
content_type: nil,
|
55
|
+
body_schema: EMPTY_SCHEMA,
|
56
|
+
query_schema: EMPTY_SCHEMA,
|
57
|
+
auth_schema: Aikido::Zen::Request::Schema::AuthSchemas.new([])
|
58
|
+
)
|
59
|
+
end
|
60
|
+
|
61
|
+
Request::Schema.new(
|
62
|
+
content_type: data[:body].nil? ? nil : data[:body][:type],
|
63
|
+
body_schema: data[:body].nil? ? EMPTY_SCHEMA : Aikido::Zen::Request::Schema::Definition.new(data[:body][:schema]),
|
64
|
+
query_schema: data[:query].nil? ? EMPTY_SCHEMA : Aikido::Zen::Request::Schema::Definition.new(data[:query]),
|
65
|
+
auth_schema: Aikido::Zen::Request::Schema::AuthSchemas.from_json(data[:auth])
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
51
69
|
# Merges the request specification with another request's specification.
|
52
70
|
#
|
53
71
|
# @param other [Aikido::Zen::Request::Schema, nil]
|
@@ -45,7 +45,7 @@ module Aikido::Zen
|
|
45
45
|
# @param data [Hash] the decoded JSON payload from the /api/runtime/config
|
46
46
|
# API endpoint.
|
47
47
|
#
|
48
|
-
# @return [
|
48
|
+
# @return [bool]
|
49
49
|
def update_from_json(data)
|
50
50
|
last_updated_at = updated_at
|
51
51
|
|
@@ -56,7 +56,7 @@ module Aikido::Zen
|
|
56
56
|
self.skip_protection_for_ips = RuntimeSettings::IPSet.from_json(data["allowedIPAddresses"])
|
57
57
|
self.received_any_stats = data["receivedAnyStats"]
|
58
58
|
|
59
|
-
|
59
|
+
updated_at != last_updated_at
|
60
60
|
end
|
61
61
|
end
|
62
62
|
end
|
@@ -5,6 +5,10 @@ require_relative "path_traversal/helpers"
|
|
5
5
|
module Aikido::Zen
|
6
6
|
module Scanners
|
7
7
|
class PathTraversalScanner
|
8
|
+
def self.skips_on_nil_context?
|
9
|
+
true
|
10
|
+
end
|
11
|
+
|
8
12
|
# Checks if the user introduced input is trying to access other path using
|
9
13
|
# Path Traversal kind of attacks.
|
10
14
|
#
|
@@ -16,8 +20,6 @@ module Aikido::Zen
|
|
16
20
|
# @return [Aikido::Zen::Attacks::PathTraversalAttack, nil] an Attack if any
|
17
21
|
# user input is detected to be attempting a Path Traversal Attack, or +nil+ if not.
|
18
22
|
def self.call(filepath:, sink:, context:, operation:)
|
19
|
-
return unless context
|
20
|
-
|
21
23
|
context.payloads.each do |payload|
|
22
24
|
next unless new(filepath, payload.value).attack?
|
23
25
|
|
@@ -5,14 +5,16 @@ require_relative "shell_injection/helpers"
|
|
5
5
|
module Aikido::Zen
|
6
6
|
module Scanners
|
7
7
|
class ShellInjectionScanner
|
8
|
+
def self.skips_on_nil_context?
|
9
|
+
true
|
10
|
+
end
|
11
|
+
|
8
12
|
# @param command [String]
|
9
13
|
# @param sink [Aikido::Zen::Sink]
|
10
14
|
# @param context [Aikido::Zen::Context]
|
11
15
|
# @param operation [Symbol, String]
|
12
16
|
#
|
13
17
|
def self.call(command:, sink:, context:, operation:)
|
14
|
-
return unless context
|
15
|
-
|
16
18
|
context.payloads.each do |payload|
|
17
19
|
next unless new(command, payload.value).attack?
|
18
20
|
|
@@ -6,6 +6,10 @@ require_relative "../internals"
|
|
6
6
|
module Aikido::Zen
|
7
7
|
module Scanners
|
8
8
|
class SQLInjectionScanner
|
9
|
+
def self.skips_on_nil_context?
|
10
|
+
true
|
11
|
+
end
|
12
|
+
|
9
13
|
# Checks if the given SQL query may have dangerous user input injected,
|
10
14
|
# and returns an Attack if so, based on the current request.
|
11
15
|
#
|
@@ -22,8 +26,6 @@ module Aikido::Zen
|
|
22
26
|
# @raise [Aikido::Zen::InternalsError] if an error occurs when loading or
|
23
27
|
# calling zenlib. See Sink#scan.
|
24
28
|
def self.call(query:, dialect:, sink:, context:, operation:)
|
25
|
-
return if context.nil?
|
26
|
-
|
27
29
|
dialect = DIALECTS.fetch(dialect) do
|
28
30
|
Aikido::Zen.config.logger.warn "Unknown SQL dialect #{dialect.inspect}"
|
29
31
|
DIALECTS[:common]
|
@@ -31,35 +31,47 @@ module Aikido::Zen
|
|
31
31
|
# @return [Boolean]
|
32
32
|
def private?(hostname_or_address)
|
33
33
|
resolve(hostname_or_address).any? do |ip|
|
34
|
-
|
34
|
+
PRIVATE_RANGES.any? { |range| range === ip }
|
35
35
|
end
|
36
36
|
end
|
37
37
|
|
38
38
|
private
|
39
39
|
|
40
|
-
|
41
|
-
|
42
|
-
IPAddr.new("
|
43
|
-
IPAddr.new("
|
44
|
-
IPAddr.new("
|
45
|
-
IPAddr.new("
|
46
|
-
IPAddr.new("
|
47
|
-
IPAddr.new("
|
48
|
-
IPAddr.new("192.
|
49
|
-
IPAddr.new("192.
|
50
|
-
IPAddr.new("192.
|
51
|
-
IPAddr.new("
|
52
|
-
IPAddr.new("
|
53
|
-
IPAddr.new("
|
54
|
-
IPAddr.new("
|
55
|
-
IPAddr.new("
|
56
|
-
IPAddr.new("
|
40
|
+
# Source: https://github.com/AikidoSec/firewall-node/blob/main/library/vulnerabilities/ssrf/isPrivateIP.ts
|
41
|
+
PRIVATE_IPV4_RANGES = [
|
42
|
+
IPAddr.new("0.0.0.0/8"), # "This" network (RFC 1122)
|
43
|
+
IPAddr.new("10.0.0.0/8"), # Private-Use Networks (RFC 1918)
|
44
|
+
IPAddr.new("100.64.0.0/10"), # Shared Address Space (RFC 6598)
|
45
|
+
IPAddr.new("127.0.0.0/8"), # Loopback (RFC 1122)
|
46
|
+
IPAddr.new("169.254.0.0/16"), # Link Local (RFC 3927)
|
47
|
+
IPAddr.new("172.16.0.0/12"), # Private-Use Networks (RFC 1918)
|
48
|
+
IPAddr.new("192.0.0.0/24"), # IETF Protocol Assignments (RFC 5736)
|
49
|
+
IPAddr.new("192.0.2.0/24"), # TEST-NET-1 (RFC 5737)
|
50
|
+
IPAddr.new("192.31.196.0/24"), # AS112 Redirection Anycast (RFC 7535)
|
51
|
+
IPAddr.new("192.52.193.0/24"), # Automatic Multicast Tunneling (RFC 7450)
|
52
|
+
IPAddr.new("192.88.99.0/24"), # 6to4 Relay Anycast (RFC 3068)
|
53
|
+
IPAddr.new("192.168.0.0/16"), # Private-Use Networks (RFC 1918)
|
54
|
+
IPAddr.new("192.175.48.0/24"), # AS112 Redirection Anycast (RFC 7535)
|
55
|
+
IPAddr.new("198.18.0.0/15"), # Network Interconnect Device Benchmark Testing (RFC 2544)
|
56
|
+
IPAddr.new("198.51.100.0/24"), # TEST-NET-2 (RFC 5737)
|
57
|
+
IPAddr.new("203.0.113.0/24"), # TEST-NET-3 (RFC 5737)
|
58
|
+
IPAddr.new("224.0.0.0/4"), # Multicast (RFC 3171)
|
59
|
+
IPAddr.new("240.0.0.0/4"), # Reserved for Future Use (RFC 1112)
|
60
|
+
IPAddr.new("255.255.255.255/32") # Limited Broadcast (RFC 919)
|
61
|
+
]
|
57
62
|
|
58
|
-
|
59
|
-
IPAddr.new("
|
60
|
-
IPAddr.new("::
|
63
|
+
PRIVATE_IPV6_RANGES = [
|
64
|
+
IPAddr.new("::/128"), # Unspecified address (RFC 4291)
|
65
|
+
IPAddr.new("::1/128"), # Loopback address (RFC 4291)
|
66
|
+
IPAddr.new("fc00::/7"), # Unique local address (ULA) (RFC 4193
|
67
|
+
IPAddr.new("fe80::/10"), # Link-local address (LLA) (RFC 4291)
|
68
|
+
IPAddr.new("100::/64"), # Discard prefix (RFC 6666)
|
69
|
+
IPAddr.new("2001:db8::/32"), # Documentation prefix (RFC 3849)
|
70
|
+
IPAddr.new("3fff::/20") # Documentation prefix (RFC 9637)
|
61
71
|
]
|
62
72
|
|
73
|
+
PRIVATE_RANGES = PRIVATE_IPV4_RANGES + PRIVATE_IPV6_RANGES + PRIVATE_IPV4_RANGES.map(&:ipv4_mapped)
|
74
|
+
|
63
75
|
def resolved_in_current_context
|
64
76
|
context = Aikido::Zen.current_context
|
65
77
|
context && context["dns.lookups"]
|
@@ -6,6 +6,12 @@ require_relative "ssrf/dns_lookups"
|
|
6
6
|
module Aikido::Zen
|
7
7
|
module Scanners
|
8
8
|
class SSRFScanner
|
9
|
+
# SSRF attacks can be triggered through external inputs, so it is essential
|
10
|
+
# to have a valid context to safeguard against these attacks.
|
11
|
+
def self.skips_on_nil_context?
|
12
|
+
true
|
13
|
+
end
|
14
|
+
|
9
15
|
# Checks if an outbound HTTP request is to a hostname supplied from user
|
10
16
|
# input that resolves to a "dangerous" address. This is called from two
|
11
17
|
# different places:
|
@@ -32,7 +38,6 @@ module Aikido::Zen
|
|
32
38
|
# @return [Aikido::Zen::Attacks::SSRFAttack, nil] an Attack if any user
|
33
39
|
# input is detected to be attempting SSRF, or +nil+ if not.
|
34
40
|
def self.call(request:, sink:, context:, operation:, **)
|
35
|
-
return if context.nil?
|
36
41
|
return if request.nil? # See NOTE above.
|
37
42
|
|
38
43
|
context["ssrf.redirects"] ||= RedirectChains.new
|
@@ -5,6 +5,12 @@ module Aikido::Zen
|
|
5
5
|
# Inspects the result of DNS lookups, to determine if we're being the target
|
6
6
|
# of a stored SSRF targeting IMDS addresses (169.254.169.254).
|
7
7
|
class StoredSSRFScanner
|
8
|
+
# Stored-SSRF can occur without external input, so we do not require a
|
9
|
+
# context to determine if an attack is happening.
|
10
|
+
def self.skips_on_nil_context?
|
11
|
+
false
|
12
|
+
end
|
13
|
+
|
8
14
|
def self.call(hostname:, addresses:, operation:, sink:, context:, **opts)
|
9
15
|
offending_address = new(hostname, addresses).attack?
|
10
16
|
return if offending_address.nil?
|
data/lib/aikido/zen/sink.rb
CHANGED
@@ -84,11 +84,16 @@ module Aikido::Zen
|
|
84
84
|
|
85
85
|
scan = Scan.new(sink: self, context: context)
|
86
86
|
|
87
|
+
scans_performed = 0
|
87
88
|
scan.perform do
|
88
89
|
result = nil
|
89
90
|
|
90
91
|
scanners.each do |scanner|
|
92
|
+
next if scanner.skips_on_nil_context? && context.nil?
|
93
|
+
|
91
94
|
result = scanner.call(sink: self, context: context, **scan_params)
|
95
|
+
scans_performed += 1
|
96
|
+
|
92
97
|
break result if result
|
93
98
|
rescue Aikido::Zen::InternalsError => error
|
94
99
|
Aikido::Zen.config.logger.warn(error.message)
|
@@ -100,7 +105,7 @@ module Aikido::Zen
|
|
100
105
|
result
|
101
106
|
end
|
102
107
|
|
103
|
-
@reporter.call(scan)
|
108
|
+
@reporter.call(scan) if scans_performed > 0
|
104
109
|
|
105
110
|
scan
|
106
111
|
end
|
@@ -12,11 +12,11 @@ module Aikido::Zen
|
|
12
12
|
def initialize(
|
13
13
|
config: Aikido::Zen.config,
|
14
14
|
settings: Aikido::Zen.runtime_settings,
|
15
|
-
|
15
|
+
detached_agent: Aikido::Zen.detached_agent
|
16
16
|
)
|
17
17
|
@config = config
|
18
18
|
@settings = settings
|
19
|
-
@
|
19
|
+
@detached_agent = detached_agent
|
20
20
|
end
|
21
21
|
|
22
22
|
def block?(controller)
|
@@ -43,16 +43,21 @@ module Aikido::Zen
|
|
43
43
|
end
|
44
44
|
|
45
45
|
private def should_throttle?(request)
|
46
|
+
return false unless @settings.endpoints[request.route].rate_limiting.enabled?
|
46
47
|
return false if @settings.skip_protection_for_ips.include?(request.ip)
|
47
48
|
|
48
|
-
@
|
49
|
+
result = @detached_agent.calculate_rate_limits(request)
|
50
|
+
return false unless result
|
51
|
+
|
52
|
+
request.env["aikido.rate_limiting"] = result
|
53
|
+
request.env["aikido.rate_limiting"].throttled?
|
49
54
|
end
|
50
55
|
|
51
56
|
# @param request [Aikido::Zen::Request]
|
52
57
|
private def should_block_user?(request)
|
53
58
|
return false if request.actor.nil?
|
54
59
|
|
55
|
-
|
60
|
+
Aikido::Zen.runtime_settings.blocked_user_ids&.include?(request.actor.id)
|
56
61
|
end
|
57
62
|
end
|
58
63
|
|
@@ -15,6 +15,19 @@ module Aikido::Zen
|
|
15
15
|
|
16
16
|
module IPSocketExtensions
|
17
17
|
def self.scan_socket(hostname, socket)
|
18
|
+
# We're patching IPSocket.open(..) method.
|
19
|
+
# The IPSocket class hierarchy is:
|
20
|
+
# IPSocket
|
21
|
+
# / \
|
22
|
+
# TCPSocket UDPSocket
|
23
|
+
# / \
|
24
|
+
# TCPServer SOCKSSocket
|
25
|
+
#
|
26
|
+
# Because we want to scan only HTTP requests, we skip in case the
|
27
|
+
# socket is not *exactly* an instance of TCPSocket — it's any
|
28
|
+
# of it subclasses
|
29
|
+
return unless socket.instance_of?(TCPSocket)
|
30
|
+
|
18
31
|
# ["AF_INET", 80, "10.0.0.1", "10.0.0.1"]
|
19
32
|
addr_family, *, remote_address = socket.peeraddr
|
20
33
|
|
data/lib/aikido/zen/version.rb
CHANGED
data/lib/aikido/zen/worker.rb
CHANGED
data/lib/aikido/zen.rb
CHANGED
@@ -10,6 +10,7 @@ require_relative "zen/worker"
|
|
10
10
|
require_relative "zen/agent"
|
11
11
|
require_relative "zen/api_client"
|
12
12
|
require_relative "zen/context"
|
13
|
+
require_relative "zen/detached_agent"
|
13
14
|
require_relative "zen/middleware/check_allowed_addresses"
|
14
15
|
require_relative "zen/middleware/middleware"
|
15
16
|
require_relative "zen/middleware/request_tracker"
|
@@ -34,6 +35,10 @@ module Aikido
|
|
34
35
|
@runtime_settings ||= RuntimeSettings.new
|
35
36
|
end
|
36
37
|
|
38
|
+
def self.runtime_settings=(settings)
|
39
|
+
@runtime_settings = settings
|
40
|
+
end
|
41
|
+
|
37
42
|
# Gets information about the current system configuration, which is sent to
|
38
43
|
# the server along with any events.
|
39
44
|
def self.system_info
|
@@ -43,9 +48,15 @@ module Aikido
|
|
43
48
|
# Manages runtime metrics extracted from your app, which are uploaded to the
|
44
49
|
# Aikido servers if configured to do so.
|
45
50
|
def self.collector
|
51
|
+
check_and_handle_fork
|
46
52
|
@collector ||= Collector.new
|
47
53
|
end
|
48
54
|
|
55
|
+
def self.detached_agent
|
56
|
+
check_and_handle_fork
|
57
|
+
@detached_agent ||= DetachedAgent::Agent.new
|
58
|
+
end
|
59
|
+
|
49
60
|
# Gets the current context object that holds all information about the
|
50
61
|
# current request.
|
51
62
|
#
|
@@ -68,12 +79,10 @@ module Aikido
|
|
68
79
|
# @param request [Aikido::Zen::Request]
|
69
80
|
# @return [void]
|
70
81
|
def self.track_request(request)
|
71
|
-
|
72
|
-
collector.track_request(request)
|
82
|
+
collector.track_request
|
73
83
|
end
|
74
84
|
|
75
85
|
def self.track_discovered_route(request)
|
76
|
-
autostart
|
77
86
|
collector.track_route(request)
|
78
87
|
end
|
79
88
|
|
@@ -82,7 +91,6 @@ module Aikido
|
|
82
91
|
# @param connection [Aikido::Zen::OutboundConnection]
|
83
92
|
# @return [void]
|
84
93
|
def self.track_outbound(connection)
|
85
|
-
autostart
|
86
94
|
collector.track_outbound(connection)
|
87
95
|
end
|
88
96
|
|
@@ -94,7 +102,6 @@ module Aikido
|
|
94
102
|
# @raise [Aikido::Zen::UnderAttackError] if the scan detected an Attack
|
95
103
|
# and blocking_mode is enabled.
|
96
104
|
def self.track_scan(scan)
|
97
|
-
autostart
|
98
105
|
collector.track_scan(scan)
|
99
106
|
agent.handle_attack(scan.attack) if scan.attack?
|
100
107
|
end
|
@@ -107,7 +114,6 @@ module Aikido
|
|
107
114
|
return if config.disabled?
|
108
115
|
|
109
116
|
if (actor = Aikido::Zen::Actor(user))
|
110
|
-
autostart
|
111
117
|
collector.track_user(actor)
|
112
118
|
current_context.request.actor = actor if current_context
|
113
119
|
else
|
@@ -140,7 +146,8 @@ module Aikido
|
|
140
146
|
# @!visibility private
|
141
147
|
# Stop any background threads.
|
142
148
|
def self.stop!
|
143
|
-
agent&.stop!
|
149
|
+
@agent&.stop!
|
150
|
+
@detached_agent_server&.stop!
|
144
151
|
end
|
145
152
|
|
146
153
|
# @!visibility private
|
@@ -149,8 +156,34 @@ module Aikido
|
|
149
156
|
@agent ||= Agent.start
|
150
157
|
end
|
151
158
|
|
159
|
+
def self.detached_agent_server
|
160
|
+
@detached_agent_server ||= DetachedAgent::Server.start!
|
161
|
+
end
|
162
|
+
|
152
163
|
class << self
|
153
|
-
|
164
|
+
# `agent` and `detached_agent` are started on the first method call.
|
165
|
+
# A mutex controls thread execution to prevent multiple attempts.
|
166
|
+
LOCK = Mutex.new
|
167
|
+
|
168
|
+
def start!
|
169
|
+
@pid = Process.pid
|
170
|
+
LOCK.synchronize do
|
171
|
+
agent
|
172
|
+
detached_agent_server
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def check_and_handle_fork
|
177
|
+
if has_forked
|
178
|
+
@detached_agent&.handle_fork
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def has_forked
|
183
|
+
pid_changed = Process.pid != @pid
|
184
|
+
@pid = Process.pid
|
185
|
+
pid_changed
|
186
|
+
end
|
154
187
|
end
|
155
188
|
end
|
156
189
|
end
|
data/tasklib/bench.rake
CHANGED
@@ -2,8 +2,11 @@
|
|
2
2
|
|
3
3
|
require "socket"
|
4
4
|
require "timeout"
|
5
|
+
require_relative "wrk"
|
5
6
|
|
6
7
|
SERVER_PIDS = {}
|
8
|
+
PORT_PROTECTED = 3001
|
9
|
+
PORT_UNPROTECTED = 3002
|
7
10
|
|
8
11
|
def stop_servers
|
9
12
|
SERVER_PIDS.each { |_, pid| Process.kill("TERM", pid) }
|
@@ -11,6 +14,8 @@ def stop_servers
|
|
11
14
|
end
|
12
15
|
|
13
16
|
def boot_server(dir, port:, env: {})
|
17
|
+
env["RAILS_MIN_THREADS"] = NUMBER_OF_THREADS
|
18
|
+
env["RAILS_MAX_THREADS"] = NUMBER_OF_THREADS
|
14
19
|
env["PORT"] = port.to_s
|
15
20
|
env["SECRET_KEY_BASE"] = rand(36**64).to_s(36)
|
16
21
|
|
@@ -49,8 +54,28 @@ end
|
|
49
54
|
Pathname.glob("sample_apps/*").select(&:directory?).each do |dir|
|
50
55
|
namespace :bench do
|
51
56
|
namespace dir.basename.to_s do
|
52
|
-
desc "Run benchmarks for the #{dir.basename} sample app"
|
53
|
-
task
|
57
|
+
desc "Run WRK benchmarks for the #{dir.basename} sample app"
|
58
|
+
task wrk_run: [:boot_protected_app, :boot_unprotected_app] do
|
59
|
+
throughput_decrease_limit_perc = 25
|
60
|
+
if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.0.0") && Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.1.0")
|
61
|
+
# add higher limit for ruby 3.0
|
62
|
+
throughput_decrease_limit_perc = 35
|
63
|
+
end
|
64
|
+
|
65
|
+
wait_for_servers
|
66
|
+
run_benchmark(
|
67
|
+
route_zen: "http://localhost:#{PORT_PROTECTED}/benchmark", # Application with Zen
|
68
|
+
route_no_zen: "http://localhost:#{PORT_UNPROTECTED}/benchmark", # Application without Zen
|
69
|
+
description: "An empty route (1ms simulated delay)",
|
70
|
+
throughput_decrease_limit_perc: throughput_decrease_limit_perc,
|
71
|
+
latency_increase_limit_ms: 200
|
72
|
+
)
|
73
|
+
ensure
|
74
|
+
stop_servers
|
75
|
+
end
|
76
|
+
|
77
|
+
desc "Run K6 benchmarks for the #{dir.basename} sample app"
|
78
|
+
task k6_run: [:boot_protected_app, :boot_unprotected_app] do
|
54
79
|
wait_for_servers
|
55
80
|
Dir.chdir("benchmarks") { sh "k6 run #{dir.basename}.js" }
|
56
81
|
ensure
|
@@ -58,14 +83,12 @@ Pathname.glob("sample_apps/*").select(&:directory?).each do |dir|
|
|
58
83
|
end
|
59
84
|
|
60
85
|
task :boot_protected_app do
|
61
|
-
boot_server(dir, port:
|
86
|
+
boot_server(dir, port: PORT_PROTECTED)
|
62
87
|
end
|
63
88
|
|
64
89
|
task :boot_unprotected_app do
|
65
|
-
boot_server(dir, port:
|
90
|
+
boot_server(dir, port: PORT_UNPROTECTED, env: {"AIKIDO_DISABLED" => "true"})
|
66
91
|
end
|
67
92
|
end
|
68
|
-
|
69
|
-
task default: "#{dir.basename}:run"
|
70
93
|
end
|
71
94
|
end
|
data/tasklib/wrk.rb
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
require "open3"
|
2
|
+
require "time"
|
3
|
+
|
4
|
+
NUMBER_OF_THREADS = ENV.fetch("BENCHMARK_NUMBER_OF_THREADS") { 12 }.to_s
|
5
|
+
CONNECTIONS = ENV.fetch("BENCHMARK_WRK_CONNECTIONS") { 400 }
|
6
|
+
|
7
|
+
def generate_wrk_command_for_url(url)
|
8
|
+
# Define the command with wrk included
|
9
|
+
"wrk --threads #{NUMBER_OF_THREADS} --connections #{CONNECTIONS} --duration 15s --timeout 5s --latency #{url}"
|
10
|
+
end
|
11
|
+
|
12
|
+
def cold_start(url)
|
13
|
+
10.times do
|
14
|
+
_, err, status = Open3.capture3("curl #{url}")
|
15
|
+
|
16
|
+
if status != 0
|
17
|
+
puts err
|
18
|
+
exit(-1)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def extract_requests_and_latency_tuple(out, err, status)
|
24
|
+
if status == 0
|
25
|
+
# Extracting requests/sec
|
26
|
+
requests_sec_match = out.match(/Requests\/sec:\s+([\d.]+)/)
|
27
|
+
requests_sec = requests_sec_match[1].to_f if requests_sec_match
|
28
|
+
|
29
|
+
# Extracting latency
|
30
|
+
latency_match = out.match(/Latency\s+([\d.]+)(ms|s)/)
|
31
|
+
latency = latency_match[1].to_f if latency_match
|
32
|
+
latency_unit = latency_match[2] if latency_match
|
33
|
+
|
34
|
+
if latency_unit == "s"
|
35
|
+
latency *= 1000
|
36
|
+
end
|
37
|
+
|
38
|
+
{requests_sec: requests_sec, latency: latency}
|
39
|
+
else
|
40
|
+
puts "Error occurred running benchmark command:"
|
41
|
+
puts err.strip
|
42
|
+
exit(1)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def run_benchmark(route_no_zen:, route_zen:, description:, throughput_decrease_limit_perc:, latency_increase_limit_ms:)
|
47
|
+
# Cold start
|
48
|
+
cold_start(route_no_zen)
|
49
|
+
cold_start(route_zen)
|
50
|
+
|
51
|
+
out, err, status = Open3.capture3(generate_wrk_command_for_url(route_zen))
|
52
|
+
puts <<~MSG
|
53
|
+
WRK OUTPUT
|
54
|
+
================
|
55
|
+
FIREWALL ENABLED:
|
56
|
+
#{out}
|
57
|
+
----------------
|
58
|
+
MSG
|
59
|
+
result_zen_enabled = extract_requests_and_latency_tuple(out, err, status)
|
60
|
+
|
61
|
+
out, err, status = Open3.capture3(generate_wrk_command_for_url(route_no_zen))
|
62
|
+
puts <<~MSG
|
63
|
+
FIREWALL DISABLED:
|
64
|
+
#{out}
|
65
|
+
================
|
66
|
+
MSG
|
67
|
+
result_zen_disabled = extract_requests_and_latency_tuple(out, err, status)
|
68
|
+
|
69
|
+
# Check if the command was successful
|
70
|
+
if result_zen_enabled && result_zen_disabled
|
71
|
+
# Print the output, which should be the Requests/sec value
|
72
|
+
puts "[ZEN ENABLED ] Requests/sec: #{result_zen_enabled[:requests_sec]} | Latency in ms: #{result_zen_enabled[:latency]}"
|
73
|
+
puts "[ZEN DISABLED] Requests/sec: #{result_zen_disabled[:requests_sec]} | Latency in ms: #{result_zen_disabled[:latency]}"
|
74
|
+
|
75
|
+
latency_increase_ms = (result_zen_enabled[:latency] - result_zen_disabled[:latency]).round(2)
|
76
|
+
puts "-> Delta in ms: #{latency_increase_ms}ms after running load test on #{description}"
|
77
|
+
|
78
|
+
throughput_decrease_perc = ((result_zen_disabled[:requests_sec] - result_zen_enabled[:requests_sec]) / result_zen_disabled[:requests_sec] * 100).round
|
79
|
+
puts "-> #{throughput_decrease_perc}% decrease in throughput after running load test on #{description}\n"
|
80
|
+
|
81
|
+
if latency_increase_ms >= latency_increase_limit_ms
|
82
|
+
exit(1)
|
83
|
+
end
|
84
|
+
if throughput_decrease_perc >= throughput_decrease_limit_perc
|
85
|
+
exit(1)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
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: 0.
|
4
|
+
version: 1.0.0.pre.beta.1
|
5
5
|
platform: arm64-linux
|
6
6
|
authors:
|
7
7
|
- Nicolas Sanguinetti
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-
|
11
|
+
date: 2025-06-04 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: concurrent-ruby
|
@@ -82,6 +82,7 @@ files:
|
|
82
82
|
- lib/aikido/zen/agent/heartbeats_manager.rb
|
83
83
|
- lib/aikido/zen/api_client.rb
|
84
84
|
- lib/aikido/zen/attack.rb
|
85
|
+
- lib/aikido/zen/background_worker.rb
|
85
86
|
- lib/aikido/zen/capped_collections.rb
|
86
87
|
- lib/aikido/zen/collector.rb
|
87
88
|
- lib/aikido/zen/collector/hosts.rb
|
@@ -93,6 +94,10 @@ files:
|
|
93
94
|
- lib/aikido/zen/context.rb
|
94
95
|
- lib/aikido/zen/context/rack_request.rb
|
95
96
|
- lib/aikido/zen/context/rails_request.rb
|
97
|
+
- lib/aikido/zen/detached_agent.rb
|
98
|
+
- lib/aikido/zen/detached_agent/agent.rb
|
99
|
+
- lib/aikido/zen/detached_agent/front_object.rb
|
100
|
+
- lib/aikido/zen/detached_agent/server.rb
|
96
101
|
- lib/aikido/zen/errors.rb
|
97
102
|
- lib/aikido/zen/event.rb
|
98
103
|
- lib/aikido/zen/internals.rb
|
@@ -164,6 +169,7 @@ files:
|
|
164
169
|
- lib/aikido/zen/worker.rb
|
165
170
|
- tasklib/bench.rake
|
166
171
|
- tasklib/libzen.rake
|
172
|
+
- tasklib/wrk.rb
|
167
173
|
homepage: https://aikido.dev
|
168
174
|
licenses:
|
169
175
|
- AGPL-3.0-or-later
|