aikido-zen 0.1.1-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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/.simplecov +7 -0
  3. data/CHANGELOG.md +4 -0
  4. data/README.md +11 -2
  5. data/benchmarks/README.md +8 -12
  6. data/benchmarks/rails7.1_sql_injection.js +30 -34
  7. data/docs/banner.svg +128 -129
  8. data/docs/config.md +8 -6
  9. data/docs/rails.md +1 -1
  10. data/lib/aikido/zen/agent.rb +13 -9
  11. data/lib/aikido/zen/api_client.rb +17 -7
  12. data/lib/aikido/zen/attack.rb +105 -36
  13. data/lib/aikido/zen/background_worker.rb +52 -0
  14. data/lib/aikido/zen/collector/routes.rb +2 -0
  15. data/lib/aikido/zen/collector.rb +31 -4
  16. data/lib/aikido/zen/config.rb +55 -20
  17. data/lib/aikido/zen/detached_agent/agent.rb +78 -0
  18. data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
  19. data/lib/aikido/zen/detached_agent/server.rb +41 -0
  20. data/lib/aikido/zen/detached_agent.rb +2 -0
  21. data/lib/aikido/zen/errors.rb +18 -1
  22. data/lib/aikido/zen/event.rb +4 -2
  23. data/lib/aikido/zen/libzen-v0.1.37.aarch64.so +0 -0
  24. data/lib/aikido/zen/middleware/check_allowed_addresses.rb +2 -14
  25. data/lib/aikido/zen/middleware/middleware.rb +11 -0
  26. data/lib/aikido/zen/middleware/{throttler.rb → rack_throttler.rb} +11 -13
  27. data/lib/aikido/zen/middleware/request_tracker.rb +190 -0
  28. data/lib/aikido/zen/middleware/set_context.rb +1 -4
  29. data/lib/aikido/zen/outbound_connection_monitor.rb +4 -0
  30. data/lib/aikido/zen/payload.rb +2 -0
  31. data/lib/aikido/zen/rails_engine.rb +12 -0
  32. data/lib/aikido/zen/rate_limiter/breaker.rb +3 -3
  33. data/lib/aikido/zen/rate_limiter.rb +7 -12
  34. data/lib/aikido/zen/request/rails_router.rb +6 -18
  35. data/lib/aikido/zen/request/schema/auth_schemas.rb +14 -0
  36. data/lib/aikido/zen/request/schema/builder.rb +0 -2
  37. data/lib/aikido/zen/request/schema/definition.rb +0 -5
  38. data/lib/aikido/zen/request/schema.rb +18 -3
  39. data/lib/aikido/zen/runtime_settings.rb +2 -2
  40. data/lib/aikido/zen/scanners/path_traversal/helpers.rb +65 -0
  41. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +63 -0
  42. data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
  43. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +64 -0
  44. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +4 -6
  45. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +33 -21
  46. data/lib/aikido/zen/scanners/ssrf_scanner.rb +15 -7
  47. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +6 -0
  48. data/lib/aikido/zen/scanners.rb +2 -0
  49. data/lib/aikido/zen/sink.rb +6 -1
  50. data/lib/aikido/zen/sinks/action_controller.rb +34 -15
  51. data/lib/aikido/zen/sinks/file.rb +120 -0
  52. data/lib/aikido/zen/sinks/kernel.rb +73 -0
  53. data/lib/aikido/zen/sinks/socket.rb +13 -0
  54. data/lib/aikido/zen/sinks.rb +8 -0
  55. data/lib/aikido/zen/system_info.rb +1 -1
  56. data/lib/aikido/zen/version.rb +2 -2
  57. data/lib/aikido/zen/worker.rb +5 -0
  58. data/lib/aikido/zen.rb +54 -8
  59. data/tasklib/bench.rake +31 -7
  60. data/tasklib/wrk.rb +88 -0
  61. metadata +22 -8
  62. data/lib/aikido/zen/libzen-v0.1.31.aarch64.so +0 -0
@@ -3,26 +3,34 @@
3
3
  module Aikido::Zen
4
4
  module Sinks
5
5
  module ActionController
6
- # Implements the "middleware" for rate limiting in Rails apps, where we
7
- # need to check at the end of the `before_action` chain, rather than in
8
- # an actual Rack middleware, to allow for calls to Zen.track_user being
9
- # made from before_actions in the host app, thus allowing rate-limiting
10
- # by user ID rather than solely by IP.
11
- class Throttler
6
+ # Implements the "middleware" for blocking requests (i.e.: rate-limiting or blocking
7
+ # user/bots) in Rails apps, where we need to check at the end of the `before_action`
8
+ # chain, rather than in an actual Rack middleware, to allow for calls to
9
+ # `Zen.track_user` being made from before_actions in the host app, thus allowing
10
+ # block/rate-limit by user ID rather than solely by IP.
11
+ class BlockRequestChecker
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
- def throttle(controller)
22
+ def block?(controller)
23
23
  context = controller.request.env[Aikido::Zen::ENV_KEY]
24
24
  request = context.request
25
25
 
26
+ if should_block_user?(request)
27
+ status, headers, body = @config.blocked_responder.call(request, :user)
28
+ controller.headers.update(headers)
29
+ controller.render plain: Array(body).join, status: status
30
+
31
+ return true
32
+ end
33
+
26
34
  if should_throttle?(request)
27
35
  status, headers, body = @config.rate_limited_responder.call(request)
28
36
  controller.headers.update(headers)
@@ -35,14 +43,26 @@ module Aikido::Zen
35
43
  end
36
44
 
37
45
  private def should_throttle?(request)
46
+ return false unless @settings.endpoints[request.route].rate_limiting.enabled?
38
47
  return false if @settings.skip_protection_for_ips.include?(request.ip)
39
48
 
40
- @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?
54
+ end
55
+
56
+ # @param request [Aikido::Zen::Request]
57
+ private def should_block_user?(request)
58
+ return false if request.actor.nil?
59
+
60
+ Aikido::Zen.runtime_settings.blocked_user_ids&.include?(request.actor.id)
41
61
  end
42
62
  end
43
63
 
44
- def self.throttler
45
- @throttler ||= Aikido::Zen::Sinks::ActionController::Throttler.new
64
+ def self.block_request_checker
65
+ @block_request_checker ||= Aikido::Zen::Sinks::ActionController::BlockRequestChecker.new
46
66
  end
47
67
 
48
68
  module Extensions
@@ -50,10 +70,9 @@ module Aikido::Zen
50
70
  return super unless kind == :process_action
51
71
 
52
72
  super do
53
- rate_limiter = Aikido::Zen::Sinks::ActionController.throttler
54
- throttled = rate_limiter.throttle(self)
73
+ checker = Aikido::Zen::Sinks::ActionController.block_request_checker
55
74
 
56
- yield if block_given? && !throttled
75
+ yield if block_given? && !checker.block?(self)
57
76
  end
58
77
  end
59
78
  end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Sinks
5
+ module File
6
+ SINK = Sinks.add("File", scanners: [
7
+ Aikido::Zen::Scanners::PathTraversalScanner
8
+ ])
9
+
10
+ module Extensions
11
+ def self.scan_path(filepath, operation)
12
+ SINK.scan(
13
+ filepath: filepath,
14
+ operation: operation
15
+ )
16
+ end
17
+
18
+ # Module to extend only the initializer method of `File` (`File.new`)
19
+ module Initiliazer
20
+ def initialize(filename, *, **)
21
+ Extensions.scan_path(filename, "new")
22
+ super
23
+ end
24
+ end
25
+
26
+ def open(filename, *, **)
27
+ Extensions.scan_path(filename, "open")
28
+ super
29
+ end
30
+
31
+ def read(filename, *)
32
+ Extensions.scan_path(filename, "read")
33
+ super
34
+ end
35
+
36
+ def write(filename, *, **)
37
+ Extensions.scan_path(filename, "write")
38
+ super
39
+ end
40
+
41
+ def join(*)
42
+ joined = super
43
+ Extensions.scan_path(joined, "join")
44
+ joined
45
+ end
46
+
47
+ def chmod(mode, *paths)
48
+ paths.each { |path| Extensions.scan_path(path, "chmod") }
49
+ super
50
+ end
51
+
52
+ def chown(user, group, *paths)
53
+ paths.each { |path| Extensions.scan_path(path, "chown") }
54
+ super
55
+ end
56
+
57
+ def rename(from, to)
58
+ Extensions.scan_path(from, "rename")
59
+ Extensions.scan_path(to, "rename")
60
+ super
61
+ end
62
+
63
+ def symlink(from, to)
64
+ Extensions.scan_path(from, "symlink")
65
+ Extensions.scan_path(to, "symlink")
66
+ super
67
+ end
68
+
69
+ def truncate(file_name, *)
70
+ Extensions.scan_path(file_name, "truncate")
71
+ super
72
+ end
73
+
74
+ def unlink(*args)
75
+ args.each do |arg|
76
+ Extensions.scan_path(arg, "unlink")
77
+ end
78
+ super
79
+ end
80
+
81
+ def delete(*args)
82
+ args.each do |arg|
83
+ Extensions.scan_path(arg, "delete")
84
+ end
85
+ super
86
+ end
87
+
88
+ def utime(atime, mtime, *args)
89
+ args.each do |arg|
90
+ Extensions.scan_path(arg, "utime")
91
+ end
92
+ super
93
+ end
94
+
95
+ def expand_path(filename, *)
96
+ Extensions.scan_path(filename, "expand_path")
97
+ super
98
+ end
99
+
100
+ def realpath(filename, *)
101
+ Extensions.scan_path(filename, "realpath")
102
+ super
103
+ end
104
+
105
+ def realdirpath(filename, *)
106
+ Extensions.scan_path(filename, "realdirpath")
107
+ super
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ # Internally, Path Traversal's scanner logic uses `expand_path`, in order to avoid recursion issues we keep
115
+ # a copy of the original method, only to be used internally.
116
+ # It's important to keep this line before prepend the Extensions module, otherwise the alias will call
117
+ # the extended method.
118
+ ::File.singleton_class.alias_method :expand_path__internal_for_aikido_zen, :expand_path
119
+ ::File.singleton_class.prepend(Aikido::Zen::Sinks::File::Extensions)
120
+ ::File.prepend Aikido::Zen::Sinks::File::Extensions::Initiliazer
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Sinks
5
+ module Kernel
6
+ SINK = Sinks.add("Kernel", scanners: [
7
+ Aikido::Zen::Scanners::ShellInjectionScanner
8
+ ])
9
+
10
+ module Extensions
11
+ # Checks if the user introduced input is trying to execute other commands
12
+ # using Shell Injection kind of attacks.
13
+ #
14
+ # @param command [String] the _full command_ that will be executed.
15
+ # @param context [Aikido::Zen::Context]
16
+ # @param sink [Aikido::Zen::Sink] the Sink that is running the scan.
17
+ # @param operation [Symbol, String] name of the method being scanned.
18
+ #
19
+ # @return [Aikido::Zen::Attacks::ShellInjectionAttack, nil] an Attack if any
20
+ # user input is detected as part of a Shell Injection Attack, or +nil+ if it's safe.
21
+ def self.scan_command(command, operation)
22
+ SINK.scan(
23
+ command: command,
24
+ operation: operation
25
+ )
26
+ end
27
+
28
+ # `system, spawn` functions can be invoked in several ways. For more details,
29
+ # see [the documentation](https://ruby-doc.org/3.4.1/Kernel.html#method-i-spawn)
30
+ #
31
+ # In our context, we care primarily about two common scenarios:
32
+ # - one argument (String)
33
+ # e.g.: system("ls"), system("echo something")
34
+ # - two arguments (Hash, String)
35
+ # e.g.: system({"foo" => "bar"}, "ls"), system({"foo" => "bar"}, "echo something")
36
+ #
37
+ # In all other cases, we do not protect against shell argument injections. Specifically:
38
+ #
39
+ # If a user input contains something like $(whoami) and is passed as part of the command
40
+ # arguments (e.g., user_input = "$(whoami)"):
41
+ #
42
+ # system("echo", user_input) This is safe because Ruby automatically escapes arguments
43
+ # passed to system/spawn in this form.
44
+ #
45
+ # system("echo #{user_input}") This is not safe because Ruby interpolates the user_input
46
+ # into the command string, resulting in a potentially harmful
47
+ # command like `echo $(whoami)`.
48
+ def send_arg_to_scan(args, operation)
49
+ if args.size == 1 && args[0].is_a?(String)
50
+ Extensions.scan_command(args[0], operation)
51
+ end
52
+
53
+ if args.size == 2 && args[0].is_a?(Hash)
54
+ Extensions.scan_command(args[1], operation)
55
+ end
56
+ end
57
+
58
+ def system(*args, **)
59
+ send_arg_to_scan(args, "system")
60
+ super
61
+ end
62
+
63
+ def spawn(*args, **)
64
+ send_arg_to_scan(args, "spawn")
65
+ super
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+
72
+ ::Kernel.singleton_class.prepend Aikido::Zen::Sinks::Kernel::Extensions
73
+ ::Kernel.prepend Aikido::Zen::Sinks::Kernel::Extensions
@@ -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
 
@@ -5,6 +5,14 @@ require_relative "sink"
5
5
  require_relative "sinks/socket"
6
6
 
7
7
  require_relative "sinks/action_controller" if defined?(::ActionController)
8
+ require_relative "sinks/file" if defined?(::File)
9
+
10
+ # Sadly, in ruby versions lower than 3.0, it's not possible to patch the
11
+ # Kernel module because how the `prepend` method is applied
12
+ # (https://stackoverflow.com/questions/78110397/prepend-kernel-module-function-globally#comment137713906_78112924)
13
+ if RUBY_VERSION >= "3.0"
14
+ require_relative "sinks/kernel" if defined?(::Kernel)
15
+ end
8
16
  require_relative "sinks/resolv" if defined?(::Resolv)
9
17
  require_relative "sinks/net_http" if defined?(::Net::HTTP)
10
18
  require_relative "sinks/http" if defined?(::HTTP)
@@ -60,7 +60,7 @@ module Aikido::Zen
60
60
  end
61
61
 
62
62
  def os_version
63
- Gem::Platform.local.version
63
+ Gem::Platform.local.version || "unknown"
64
64
  end
65
65
 
66
66
  def as_json
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "0.1.1"
5
+ VERSION = "1.0.0-beta.1"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
- LIBZEN_VERSION = "0.1.31"
8
+ LIBZEN_VERSION = "0.1.37"
9
9
  end
10
10
  end
@@ -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,13 +10,16 @@ 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"
14
+ require_relative "zen/middleware/check_allowed_addresses"
15
+ require_relative "zen/middleware/middleware"
16
+ require_relative "zen/middleware/request_tracker"
13
17
  require_relative "zen/middleware/set_context"
14
18
  require_relative "zen/outbound_connection"
15
19
  require_relative "zen/outbound_connection_monitor"
16
20
  require_relative "zen/runtime_settings"
17
21
  require_relative "zen/rate_limiter"
18
22
  require_relative "zen/scanners"
19
- require_relative "zen/middleware/check_allowed_addresses"
20
23
  require_relative "zen/rails_engine" if defined?(::Rails)
21
24
 
22
25
  module Aikido
@@ -32,6 +35,10 @@ module Aikido
32
35
  @runtime_settings ||= RuntimeSettings.new
33
36
  end
34
37
 
38
+ def self.runtime_settings=(settings)
39
+ @runtime_settings = settings
40
+ end
41
+
35
42
  # Gets information about the current system configuration, which is sent to
36
43
  # the server along with any events.
37
44
  def self.system_info
@@ -41,9 +48,15 @@ module Aikido
41
48
  # Manages runtime metrics extracted from your app, which are uploaded to the
42
49
  # Aikido servers if configured to do so.
43
50
  def self.collector
51
+ check_and_handle_fork
44
52
  @collector ||= Collector.new
45
53
  end
46
54
 
55
+ def self.detached_agent
56
+ check_and_handle_fork
57
+ @detached_agent ||= DetachedAgent::Agent.new
58
+ end
59
+
47
60
  # Gets the current context object that holds all information about the
48
61
  # current request.
49
62
  #
@@ -66,8 +79,11 @@ module Aikido
66
79
  # @param request [Aikido::Zen::Request]
67
80
  # @return [void]
68
81
  def self.track_request(request)
69
- autostart
70
- collector.track_request(request)
82
+ collector.track_request
83
+ end
84
+
85
+ def self.track_discovered_route(request)
86
+ collector.track_route(request)
71
87
  end
72
88
 
73
89
  # Tracks a network connection made to an external service.
@@ -75,7 +91,6 @@ module Aikido
75
91
  # @param connection [Aikido::Zen::OutboundConnection]
76
92
  # @return [void]
77
93
  def self.track_outbound(connection)
78
- autostart
79
94
  collector.track_outbound(connection)
80
95
  end
81
96
 
@@ -87,7 +102,6 @@ module Aikido
87
102
  # @raise [Aikido::Zen::UnderAttackError] if the scan detected an Attack
88
103
  # and blocking_mode is enabled.
89
104
  def self.track_scan(scan)
90
- autostart
91
105
  collector.track_scan(scan)
92
106
  agent.handle_attack(scan.attack) if scan.attack?
93
107
  end
@@ -100,7 +114,6 @@ module Aikido
100
114
  return if config.disabled?
101
115
 
102
116
  if (actor = Aikido::Zen::Actor(user))
103
- autostart
104
117
  collector.track_user(actor)
105
118
  current_context.request.actor = actor if current_context
106
119
  else
@@ -113,6 +126,12 @@ module Aikido
113
126
  end
114
127
  end
115
128
 
129
+ # Marks that the Zen middleware was installed properly
130
+ # @return void
131
+ def self.middleware_installed!
132
+ collector.middleware_installed!
133
+ end
134
+
116
135
  # Load all sinks matching libraries loaded into memory. This method should
117
136
  # be called after all other dependencies have been loaded into memory (i.e.
118
137
  # at the end of the initialization process).
@@ -127,7 +146,8 @@ module Aikido
127
146
  # @!visibility private
128
147
  # Stop any background threads.
129
148
  def self.stop!
130
- agent&.stop!
149
+ @agent&.stop!
150
+ @detached_agent_server&.stop!
131
151
  end
132
152
 
133
153
  # @!visibility private
@@ -136,8 +156,34 @@ module Aikido
136
156
  @agent ||= Agent.start
137
157
  end
138
158
 
159
+ def self.detached_agent_server
160
+ @detached_agent_server ||= DetachedAgent::Server.start!
161
+ end
162
+
139
163
  class << self
140
- 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
141
187
  end
142
188
  end
143
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,12 +14,15 @@ 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
20
+ env["SECRET_KEY_BASE"] = rand(36**64).to_s(36)
15
21
 
16
22
  Dir.chdir(dir) do
17
23
  SERVER_PIDS[port] = Process.spawn(
18
24
  env,
19
- "rails", "server", "--pid", "#{Dir.pwd}/tmp/pids/server.#{port}.pid",
25
+ "rails", "server", "--pid", "#{Dir.pwd}/tmp/pids/server.#{port}.pid", "-e", "production",
20
26
  out: "/dev/null"
21
27
  )
22
28
  rescue
@@ -48,8 +54,28 @@ end
48
54
  Pathname.glob("sample_apps/*").select(&:directory?).each do |dir|
49
55
  namespace :bench do
50
56
  namespace dir.basename.to_s do
51
- desc "Run benchmarks for the #{dir.basename} sample app"
52
- 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
53
79
  wait_for_servers
54
80
  Dir.chdir("benchmarks") { sh "k6 run #{dir.basename}.js" }
55
81
  ensure
@@ -57,14 +83,12 @@ Pathname.glob("sample_apps/*").select(&:directory?).each do |dir|
57
83
  end
58
84
 
59
85
  task :boot_protected_app do
60
- boot_server(dir, port: 3001)
86
+ boot_server(dir, port: PORT_PROTECTED)
61
87
  end
62
88
 
63
89
  task :boot_unprotected_app do
64
- boot_server(dir, port: 3002, env: {"AIKIDO_DISABLE" => "true"})
90
+ boot_server(dir, port: PORT_UNPROTECTED, env: {"AIKIDO_DISABLED" => "true"})
65
91
  end
66
92
  end
67
-
68
- task default: "#{dir.basename}:run"
69
93
  end
70
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