aikido-zen 0.2.0-arm64-darwin → 1.0.0.pre.beta.1-arm64-darwin

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/.simplecov +6 -0
  3. data/benchmarks/README.md +8 -12
  4. data/docs/rails.md +1 -1
  5. data/lib/aikido/zen/agent.rb +10 -8
  6. data/lib/aikido/zen/api_client.rb +14 -4
  7. data/lib/aikido/zen/background_worker.rb +52 -0
  8. data/lib/aikido/zen/collector.rb +12 -1
  9. data/lib/aikido/zen/config.rb +11 -0
  10. data/lib/aikido/zen/detached_agent/agent.rb +78 -0
  11. data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
  12. data/lib/aikido/zen/detached_agent/server.rb +41 -0
  13. data/lib/aikido/zen/detached_agent.rb +2 -0
  14. data/lib/aikido/zen/errors.rb +8 -0
  15. data/lib/aikido/zen/middleware/rack_throttler.rb +9 -3
  16. data/lib/aikido/zen/outbound_connection_monitor.rb +4 -0
  17. data/lib/aikido/zen/rails_engine.rb +4 -0
  18. data/lib/aikido/zen/rate_limiter/breaker.rb +3 -3
  19. data/lib/aikido/zen/rate_limiter.rb +6 -11
  20. data/lib/aikido/zen/request/rails_router.rb +6 -18
  21. data/lib/aikido/zen/request/schema/auth_schemas.rb +14 -0
  22. data/lib/aikido/zen/request/schema.rb +18 -0
  23. data/lib/aikido/zen/runtime_settings.rb +2 -2
  24. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +4 -2
  25. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +4 -2
  26. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +4 -2
  27. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +33 -21
  28. data/lib/aikido/zen/scanners/ssrf_scanner.rb +6 -1
  29. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +6 -0
  30. data/lib/aikido/zen/sink.rb +6 -1
  31. data/lib/aikido/zen/sinks/action_controller.rb +9 -4
  32. data/lib/aikido/zen/sinks/socket.rb +13 -0
  33. data/lib/aikido/zen/version.rb +1 -1
  34. data/lib/aikido/zen/worker.rb +5 -0
  35. data/lib/aikido/zen.rb +41 -8
  36. data/tasklib/bench.rake +29 -6
  37. data/tasklib/wrk.rb +88 -0
  38. metadata +8 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f21c896f36460f4e901d44c5a235d0d7a677f86af95a9924326b28cfa0c8318
4
- data.tar.gz: 70973e6cf74ca6a9ad25d69c88a137851ff5734d27f390470eb9ff6134a38822
3
+ metadata.gz: c9af80650dd2c7ede7a512e331275cc550787862f6126b4abe3d71fbb5d46cc9
4
+ data.tar.gz: d6210cdf5178bacf4de86938cd38ee6f046921ac8f6dbd343a4ee2c46995187d
5
5
  SHA512:
6
- metadata.gz: 415faa79a7a49fc526c05b0418d3358933c7d297aabf3d84bad137ed2f67146c08d2c2dfaf1149c7b303da7a7d680a64317c6c44fff8d1db1169e7526fa5a994
7
- data.tar.gz: 82c78ff577fe4695d8d003fa58e8ea78e3a1c78741684b65dbc14f2fbe315156ad79d265c8d8035fb279adf77b8b9805e0a88c72ab7da87a773a8c2db7dfdc7b
6
+ metadata.gz: ca670414aaab281b6eb34db7feaaf0ed13ccb32c93a4ddb00cafcbdce2603974756874df58947ac53910c1d70b411f8eef65843609cde1973c21ee3563468568
7
+ data.tar.gz: f5897199a127528a6c17d4768c1a8c55d42bd52fc53bcbdee2a7ab0cfdc4b1c68e2fd27f775e4fca336a1096c0faa85505765437d28665e6737d7bc6b476044b
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. For each sample application we
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
- To run all the benchmarks, run the following from the root of the project:
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
- $ bundle exec rake bench
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}:run
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.1_sql_injection` application:
19
+ For example, for the WRK of `rails7.1_benchmark` application:
24
20
 
25
21
  ```
26
- $ bundle exec rake bench:rails7.1_sql_injection:run
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.api_token = Rails.application.credentials.zen.token
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
@@ -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
- event = @collector.flush(at: at)
145
-
146
- report(event) do |response|
147
- Aikido::Zen.runtime_settings.update_from_json(response)
148
- @config.logger.info "Updated runtime settings after heartbeat"
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 @rate_limiter.throttle?(event)
76
- @config.logger.error("Not reporting #{event.type.upcase} event due to rate limiting")
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 #{event.type.upcase} event")
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 = @config.json_encoder.call(event.as_json)
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
@@ -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(request)
60
+ def track_request(*)
50
61
  synchronize(@stats) { |stats| stats.add_request }
51
62
  end
52
63
 
@@ -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
@@ -0,0 +1,2 @@
1
+ require_relative "detached_agent/agent"
2
+ require_relative "detached_agent/server"
@@ -95,5 +95,13 @@ module Aikido
95
95
  MSG
96
96
  end
97
97
  end
98
+
99
+ class DetachedAgentError < ZenError
100
+ extend Forwardable
101
+
102
+ def initialize(msg)
103
+ super
104
+ end
105
+ end
98
106
  end
99
107
  end
@@ -12,12 +12,12 @@ module Aikido::Zen
12
12
  app,
13
13
  config: Aikido::Zen.config,
14
14
  settings: Aikido::Zen.runtime_settings,
15
- rate_limiter: Aikido::Zen::RateLimiter.new
15
+ detached_agent: Aikido::Zen.detached_agent
16
16
  )
17
17
  @app = app
18
18
  @config = config
19
19
  @settings = settings
20
- @rate_limiter = rate_limiter
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
- @rate_limiter.throttle?(request)
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 event [#type] an event which we'll discriminate by type to decide
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?(event)
37
+ def throttle?(event_type)
38
38
  return true if open? && !try_close
39
39
 
40
- result = @bucket.increment(event.type)
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
- # Checks whether the request requires rate limiting. As a side effect, this
28
- # will annotate the request with the "aikido.rate_limiting" ENV key, holding
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 [Boolean]
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 false unless settings.enabled?
34
+ return nil unless settings.enabled?
39
35
 
40
36
  bucket = @buckets[request.route]
41
37
  key = @config.rate_limiting_discriminator.call(request)
42
- request.env["aikido.rate_limiting"] = bucket.increment(key)
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
- Rails::Route.new(route, prefix: prefix, verb: request.request_method)
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
- def initialize(rails_route, verb: rails_route.verb, prefix: nil)
70
- @route = ActionDispatch::Routing::RouteWrapper.new(rails_route)
71
- @verb = verb
72
- @prefix = prefix
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
- def path
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 [void]
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
- Aikido::Zen.agent.updated_settings! if updated_at != last_updated_at
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
- ip.loopback? || ip.private? || RFC5735.any? { |range| range === ip }
34
+ PRIVATE_RANGES.any? { |range| range === ip }
35
35
  end
36
36
  end
37
37
 
38
38
  private
39
39
 
40
- RFC5735 = [
41
- IPAddr.new("0.0.0.0/8"),
42
- IPAddr.new("100.64.0.0/10"),
43
- IPAddr.new("127.0.0.0/8"),
44
- IPAddr.new("169.254.0.0/16"),
45
- IPAddr.new("192.0.0.0/24"),
46
- IPAddr.new("192.0.2.0/24"),
47
- IPAddr.new("192.31.196.0/24"),
48
- IPAddr.new("192.52.193.0/24"),
49
- IPAddr.new("192.88.99.0/24"),
50
- IPAddr.new("192.175.48.0/24"),
51
- IPAddr.new("198.18.0.0/15"),
52
- IPAddr.new("198.51.100.0/24"),
53
- IPAddr.new("203.0.113.0/24"),
54
- IPAddr.new("240.0.0.0/4"),
55
- IPAddr.new("224.0.0.0/4"),
56
- IPAddr.new("255.255.255.255/32"),
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
- IPAddr.new("::/128"), # Unspecified address
59
- IPAddr.new("fe80::/10"), # Link-local address (LLA)
60
- IPAddr.new("::ffff:127.0.0.1/128") # IPv4-mapped address
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?
@@ -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
- rate_limiter: Aikido::Zen::RateLimiter.new
15
+ detached_agent: Aikido::Zen.detached_agent
16
16
  )
17
17
  @config = config
18
18
  @settings = settings
19
- @rate_limiter = rate_limiter
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
- @rate_limiter.throttle?(request)
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
- @settings.blocked_user_ids&.include?(request.actor.id)
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
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "0.2.0"
5
+ VERSION = "1.0.0-beta.1"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
8
  LIBZEN_VERSION = "0.1.37"
@@ -78,5 +78,10 @@ module Aikido::Zen
78
78
  @executor.shutdown
79
79
  @executor.wait_for_termination(30)
80
80
  end
81
+
82
+ def restart
83
+ shutdown
84
+ @executor = Concurrent::SingleThreadExecutor.new
85
+ end
81
86
  end
82
87
  end
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
- autostart
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
- alias_method :autostart, :agent
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 run: [:boot_protected_app, :boot_unprotected_app] do
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: 3001)
86
+ boot_server(dir, port: PORT_PROTECTED)
62
87
  end
63
88
 
64
89
  task :boot_unprotected_app do
65
- boot_server(dir, port: 3002, env: {"AIKIDO_DISABLED" => "true"})
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.2.0
4
+ version: 1.0.0.pre.beta.1
5
5
  platform: arm64-darwin
6
6
  authors:
7
7
  - Nicolas Sanguinetti
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-03-10 00:00:00.000000000 Z
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