aikido-zen 1.0.8 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b690b6afc8d07c5c30a8995fe06f35b4b16fcf2e74b56ed0ba0d0b1736955b7b
4
- data.tar.gz: 461b7f991225fa92f76cc6db6bbbf48eccb1ed15cb7a8f2f5ce9b9a95f576e52
3
+ metadata.gz: 00d07a2e96b782a13c2ab2ec94582f318660ce4e2a3b77ff896490c5382bea47
4
+ data.tar.gz: a9b4db0b7157206284d78f35e6530ccb5bdf2e851ea28152788a7d195406b7a3
5
5
  SHA512:
6
- metadata.gz: 0dad1650feacdcebb020c5bb94ffa9a30e540d21b942dff4322731052bc21815c354ecd22f7ecde8b9bae8af3b4b8cc83dcd6cd8714f644712bdda12f37cf918
7
- data.tar.gz: 5dcb46b46e9095d6bf9afbcfe4e4f7faeb4142c01fdbc3d742be52965e0fcb1bc423d358ac6d117c4e9f7f19c404e753d7a4570f0d1baba7dec394d7d5c5265b
6
+ metadata.gz: 3e7229c3918e282d565ef0d25841e641e9133af719769748ee3626d6c7ad70573d899c33f46e290f8a5fdd56118d569c282d62ba6dab006970426bbccc33febb
7
+ data.tar.gz: f1388b4b07679712ee999f245db8faf4c811f76c08ac7ca67e68df06ca8e5d74707cf53f35ca6e25fe195d31d41ba13ef51145804ae7f60a6e451f194672f670
@@ -46,21 +46,30 @@ module Aikido::Zen
46
46
  if Aikido::Zen.blocking_mode?
47
47
  @config.logger.info("Requests identified as attacks will be blocked")
48
48
  else
49
- @config.logger.warn("Non-blocking mode enabled! No requests will be blocked.")
49
+ @config.logger.warn("Non-blocking mode enabled! No requests will be blocked")
50
50
  end
51
51
 
52
52
  if @api_client.can_make_requests?
53
- @config.logger.info("API Token set! Reporting has been enabled.")
53
+ @config.logger.info("API Token set! Reporting has been enabled")
54
54
  else
55
- @config.logger.warn("No API Token set! Reporting has been disabled.")
55
+ @config.logger.warn("No API Token set! Reporting has been disabled")
56
56
  return
57
57
  end
58
58
 
59
59
  at_exit { stop! if started? }
60
60
 
61
61
  report(Events::Started.new(time: @started_at)) do |response|
62
- updated_settings! if Aikido::Zen.runtime_settings.update_from_json(response)
63
- @config.logger.info("Updated runtime settings.")
62
+ if Aikido::Zen.runtime_settings.update_from_runtime_config_json(response)
63
+ updated_settings!
64
+ @config.logger.info("Updated runtime settings")
65
+ end
66
+ rescue => err
67
+ @config.logger.error(err.message)
68
+ end
69
+
70
+ begin
71
+ Aikido::Zen.runtime_settings.update_from_runtime_firewall_lists_json(@api_client.fetch_runtime_firewall_lists)
72
+ @config.logger.info("Updated runtime firewall list")
64
73
  rescue => err
65
74
  @config.logger.error(err.message)
66
75
  end
@@ -148,8 +157,10 @@ module Aikido::Zen
148
157
 
149
158
  heartbeat = @collector.flush
150
159
  report(heartbeat) do |response|
151
- updated_settings! if Aikido::Zen.runtime_settings.update_from_json(response)
152
- @config.logger.info("Updated runtime settings after heartbeat")
160
+ if Aikido::Zen.runtime_settings.update_from_runtime_config_json(response)
161
+ updated_settings!
162
+ @config.logger.info("Updated runtime settings after heartbeat")
163
+ end
153
164
  end
154
165
  end
155
166
 
@@ -163,8 +174,13 @@ module Aikido::Zen
163
174
  def poll_for_setting_updates
164
175
  @worker.every(@config.polling_interval) do
165
176
  if @api_client.should_fetch_settings?
166
- updated_settings! if Aikido::Zen.runtime_settings.update_from_json(@api_client.fetch_settings)
167
- @config.logger.info("Updated runtime settings after polling")
177
+ if Aikido::Zen.runtime_settings.update_from_runtime_config_json(@api_client.fetch_runtime_config)
178
+ updated_settings!
179
+ @config.logger.info("Updated runtime settings after polling")
180
+ end
181
+
182
+ Aikido::Zen.runtime_settings.update_from_runtime_firewall_lists_json(@api_client.fetch_runtime_firewall_lists)
183
+ @config.logger.info("Updated runtime firewall list after polling")
168
184
  end
169
185
  end
170
186
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "zlib"
5
+ require "stringio"
4
6
  require_relative "rate_limiter"
5
7
 
6
8
  module Aikido::Zen
@@ -43,19 +45,29 @@ module Aikido::Zen
43
45
  new_updated_at > last_updated_at
44
46
  end
45
47
 
46
- # Fetches the runtime settings from the server. In case of a timeout or
48
+ # Fetches the runtime config from the server. In case of a timeout or
47
49
  # other low-lever error, the request will be automatically retried up to two
48
50
  # times, after which it will raise an error.
49
51
  #
50
52
  # @return [Hash] decoded JSON response from the server with the runtime
51
53
  # settings.
52
54
  # @raise (see #request)
53
- def fetch_settings
54
- @config.logger.debug("Fetching new runtime settings")
55
+ def fetch_runtime_config
56
+ @config.logger.debug("Fetching new runtime config")
55
57
 
56
58
  request(Net::HTTP::Get.new("/api/runtime/config", default_headers))
57
59
  end
58
60
 
61
+ def fetch_runtime_firewall_lists
62
+ @config.logger.debug("Fetching new runtime firewall lists")
63
+
64
+ headers = default_headers.merge({
65
+ "Accept-Encoding" => "gzip"
66
+ })
67
+
68
+ request(Net::HTTP::Get.new("/api/runtime/firewall/lists", headers))
69
+ end
70
+
59
71
  # @overload report(event)
60
72
  # Reports an event to the server.
61
73
  #
@@ -69,7 +81,7 @@ module Aikido::Zen
69
81
  #
70
82
  # @param settings_updating_event [Aikido::Zen::Events::Started,
71
83
  # Aikido::Zen::Events::Heartbeat]
72
- # @return (see #fetch_settings)
84
+ # @return (see #fetch_runtime_config)
73
85
  # @raise (see #request)
74
86
  def report(event)
75
87
  event_type = if event.respond_to?(:type)
@@ -116,7 +128,12 @@ module Aikido::Zen
116
128
 
117
129
  case response
118
130
  when Net::HTTPSuccess
119
- @config.json_decoder.call(response.body)
131
+ begin
132
+ body = decode(response.body, response["Content-Encoding"])
133
+ @config.json_decoder.call(body)
134
+ rescue => err
135
+ raise DecodeError.new(request, err)
136
+ end
120
137
  when Net::HTTPTooManyRequests
121
138
  raise RateLimitedError.new(request, response)
122
139
  else
@@ -127,6 +144,22 @@ module Aikido::Zen
127
144
  raise NetworkError.new(request, err)
128
145
  end
129
146
 
147
+ # @param data [String, nil]
148
+ # @param encoding [String, nil]
149
+ # @return [String, nil]
150
+ private def decode(data, encoding)
151
+ return data unless data
152
+
153
+ case encoding&.downcase
154
+ when "gzip"
155
+ StringIO.open(data, "r") do |io|
156
+ Zlib::GzipReader.wrap(io) { |gz| gz.read }
157
+ end
158
+ else
159
+ data
160
+ end
161
+ end
162
+
130
163
  private def http_settings(base_url)
131
164
  @http_settings ||= {
132
165
  use_ssl: base_url.scheme == "https",
@@ -52,6 +52,33 @@ module Aikido::Zen
52
52
  end
53
53
  end
54
54
 
55
+ class TrackUserAgent < Event
56
+ register "track_user_agent"
57
+
58
+ def self.from_json(data)
59
+ new(data[:user_agent_keys])
60
+ end
61
+
62
+ def initialize(user_agent_keys)
63
+ super()
64
+ @user_agent_keys = user_agent_keys
65
+ end
66
+
67
+ def as_json
68
+ super.update({
69
+ user_agent_keys: @user_agent_keys
70
+ })
71
+ end
72
+
73
+ def handle(collector)
74
+ collector.handle_track_user_agent(@user_agent_keys)
75
+ end
76
+
77
+ def inspect
78
+ "#<#{self.class.name} #{@user_agent_keys.inspect}>"
79
+ end
80
+ end
81
+
55
82
  class TrackAttackWave < Event
56
83
  register "track_attack_wave"
57
84
 
@@ -8,7 +8,7 @@ module Aikido::Zen
8
8
  # Tracks information about how the Aikido Agent is used in the app.
9
9
  class Collector::Stats
10
10
  # @!visibility private
11
- attr_reader :started_at, :ended_at, :requests, :aborted_requests, :attack_waves, :blocked_attack_waves, :sinks
11
+ attr_reader :started_at, :ended_at, :requests, :aborted_requests, :user_agents, :attack_waves, :blocked_attack_waves, :sinks
12
12
 
13
13
  # @!visibility private
14
14
  attr_writer :ended_at
@@ -20,6 +20,7 @@ module Aikido::Zen
20
20
  @started_at = @ended_at = nil
21
21
  @requests = 0
22
22
  @aborted_requests = 0
23
+ @user_agents = Hash.new { |h, k| h[k] = 0 }
23
24
  @attack_waves = 0
24
25
  @blocked_attack_waves = 0
25
26
  @sinks = Hash.new { |h, k| h[k] = Collector::SinkStats.new(k, @config) }
@@ -27,7 +28,10 @@ module Aikido::Zen
27
28
 
28
29
  # @return [Boolean]
29
30
  def empty?
30
- @requests.zero? && @sinks.empty?
31
+ @requests.zero? &&
32
+ @user_agents.empty? &&
33
+ @attack_waves.zero? &&
34
+ @sinks.empty?
31
35
  end
32
36
 
33
37
  # @return [Boolean]
@@ -63,6 +67,12 @@ module Aikido::Zen
63
67
  @requests += 1
64
68
  end
65
69
 
70
+ # @param user_agent_keys [Array<String>] the user agent keys
71
+ # @return [void]
72
+ def add_user_agent(user_agent_keys)
73
+ user_agent_keys&.each { |user_agent_key| @user_agents[user_agent_key] += 1 }
74
+ end
75
+
66
76
  # @param being_blocked [Boolean] whether the Agent blocked the request
67
77
  # @return [void]
68
78
  def add_attack_wave(being_blocked:)
@@ -107,6 +117,9 @@ module Aikido::Zen
107
117
  total: @attack_waves,
108
118
  blocked: @blocked_attack_waves
109
119
  }
120
+ },
121
+ userAgents: {
122
+ breakdown: @user_agents
110
123
  }
111
124
  }
112
125
  end
@@ -88,6 +88,20 @@ module Aikido::Zen
88
88
  synchronize(@stats) { |stats| stats.add_request }
89
89
  end
90
90
 
91
+ # Track stats about monitored and blocked user agents
92
+ #
93
+ # @param [Array<String>, nil] the user agent keys
94
+ # @return [void]
95
+ def track_user_agent(user_agent_keys)
96
+ return if user_agent_keys.nil?
97
+
98
+ add_event(Events::TrackUserAgent.new(user_agent_keys))
99
+ end
100
+
101
+ def handle_track_user_agent(user_agent_keys)
102
+ synchronize(@stats) { |stats| stats.add_user_agent(user_agent_keys) }
103
+ end
104
+
91
105
  # Track stats about an attack detected by our scanners.
92
106
  #
93
107
  # @param attack [Aikido::Zen::Events::AttackWave]
@@ -300,6 +300,8 @@ module Aikido::Zen
300
300
  message = case blocking_type
301
301
  when :ip
302
302
  format("Your IP address is not allowed to access this resource. (Your IP: %s)", request.ip)
303
+ when :user_agent
304
+ "You are not allowed to access this resource because you have been identified as a bot."
303
305
  else
304
306
  "You are blocked by Zen."
305
307
  end
@@ -54,6 +54,17 @@ module Aikido
54
54
  # Raised whenever a response to the API results in a 429 response.
55
55
  class RateLimitedError < APIError; end
56
56
 
57
+ # Raised whenever a request body cannot be decoded.
58
+ class DecodeError < StandardError
59
+ include Error
60
+
61
+ def initialize(request, cause = nil)
62
+ @request = request.dup
63
+
64
+ super("Error in #{request.method} #{request.path}: #{cause.message}")
65
+ end
66
+ end
67
+
57
68
  class UnderAttackError < StandardError
58
69
  include Error
59
70
 
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Middleware
5
+ class UserAgentChecker
6
+ def initialize(app, zen: Aikido::Zen, config: zen.config, settings: zen.runtime_settings)
7
+ @app = app
8
+ @zen = zen
9
+ @config = config
10
+ @settings = settings
11
+ end
12
+
13
+ def call(env)
14
+ request = Aikido::Zen::Middleware.request_from(env)
15
+
16
+ return @app.call(env) if bypassed?(request)
17
+
18
+ user_agent = request.user_agent
19
+
20
+ if @settings.blocked_user_agent?(user_agent)
21
+ user_agent_keys = @settings.user_agent_keys(user_agent)
22
+ @zen.track_user_agent(user_agent_keys)
23
+
24
+ return @config.blocked_responder.call(request, :user_agent)
25
+ end
26
+
27
+ if @settings.monitored_user_agent?(user_agent)
28
+ user_agent_keys = @settings.user_agent_keys(user_agent)
29
+ @zen.track_user_agent(user_agent_keys)
30
+ end
31
+
32
+ @app.call(env)
33
+ end
34
+
35
+ def bypassed?(request)
36
+ # Bypass bot blocking and monitoring for allowed IPs
37
+ @settings.allowed_ips.include?(request.ip)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -17,6 +17,7 @@ module Aikido::Zen
17
17
  Aikido::Zen::Middleware::ForkDetector,
18
18
  Aikido::Zen::Middleware::ContextSetter,
19
19
  Aikido::Zen::Middleware::AllowedAddressChecker,
20
+ Aikido::Zen::Middleware::UserAgentChecker,
20
21
  Aikido::Zen::Middleware::AttackProtector,
21
22
  Aikido::Zen::Middleware::AttackWaveProtector,
22
23
  # Request Tracker stats do not consider failed requests, so the middleware
@@ -6,12 +6,12 @@ module Aikido::Zen
6
6
  #
7
7
  # Because the RuntimeSettings object can be modified in runtime, it implements
8
8
  # the {Observable} API, allowing you to subscribe to updates. These are
9
- # triggered whenever #update_from_json makes a change (i.e. if the settings
10
- # don't change, no update is triggered).
9
+ # triggered whenever #update_from_runtime_settings_json makes a change
10
+ # (i.e. if the settings don't change, no update is triggered).
11
11
  #
12
12
  # You can subscribe to changes with +#add_observer(object, func_name)+, which
13
- # will call the function passing the settings as an argument.
14
- RuntimeSettings = Struct.new(:updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :allowed_ips, :received_any_stats, :blocking_mode) do
13
+ # will call the function passing the settings as an argument
14
+ RuntimeSettings = Struct.new(:updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :allowed_ips, :received_any_stats, :blocking_mode, :blocked_user_agent_regexp, :monitored_user_agent_regexp, :user_agent_details) do
15
15
  def initialize(*)
16
16
  super
17
17
  self.endpoints ||= RuntimeSettings::Endpoints.new
@@ -25,10 +25,6 @@ module Aikido::Zen
25
25
  # @return [Integer] duration in seconds between heartbeat requests to the
26
26
  # Aikido server.
27
27
 
28
- # @!attribute [rw] received_any_stats
29
- # @return [Boolean] whether the Aikido server has received any data from
30
- # this application.
31
-
32
28
  # @!attribute [rw] endpoints
33
29
  # @return [Aikido::Zen::RuntimeSettings::Endpoints]
34
30
 
@@ -38,15 +34,31 @@ module Aikido::Zen
38
34
  # @!attribute [rw] allowed_ips
39
35
  # @return [Aikido::Zen::RuntimeSettings::IPSet]
40
36
 
37
+ # @!attribute [rw] received_any_stats
38
+ # @return [Boolean] whether the Aikido server has received any data from
39
+ # this application.
40
+
41
+ # @!attribute [rw] blocking_mode
42
+ # @return [Boolean]
43
+
44
+ # @!attribute [rw] blocked_user_agent_regexp
45
+ # @return [Regexp]
46
+
47
+ # @!attribute [rw] monitored_user_agent_regexp
48
+ # @return [Regexp]
49
+
50
+ # @!attribute [rw] user_agent_details
51
+ # @return [Regexp]
52
+
41
53
  # Parse and interpret the JSON response from the core API with updated
42
- # settings, and apply the changes. This will also notify any subscriber
43
- # to updates
54
+ # runtime settings, and apply the changes.
55
+ #
56
+ # This will also notify any subscriber to updates.
44
57
  #
45
58
  # @param data [Hash] the decoded JSON payload from the /api/runtime/config
46
59
  # API endpoint.
47
- #
48
60
  # @return [bool]
49
- def update_from_json(data)
61
+ def update_from_runtime_config_json(data)
50
62
  last_updated_at = updated_at
51
63
 
52
64
  self.updated_at = Time.at(data["configUpdatedAt"].to_i / 1000)
@@ -59,6 +71,75 @@ module Aikido::Zen
59
71
 
60
72
  updated_at != last_updated_at
61
73
  end
74
+
75
+ # Parse and interpret the JSON response from the core API with updated
76
+ # runtime firewall lists, and apply the changes.
77
+ #
78
+ # @param data [Hash] the decoded JSON payload from the /api/runtime/firewall/lists
79
+ # API endpoint.
80
+ # @return [void]
81
+ def update_from_runtime_firewall_lists_json(data)
82
+ self.blocked_user_agent_regexp = pattern(data["blockedUserAgents"])
83
+
84
+ self.monitored_user_agent_regexp = pattern(data["monitoredUserAgents"])
85
+
86
+ self.user_agent_details = []
87
+
88
+ data["userAgentDetails"]&.each do |record|
89
+ key = record["key"]
90
+ pattern = pattern(record["pattern"])
91
+
92
+ next if key.nil? || pattern.nil?
93
+
94
+ user_agent_details << {
95
+ key: key,
96
+ pattern: pattern
97
+ }
98
+ end
99
+ end
100
+
101
+ # Construct a regular expression from the non-nil and non-empty string,
102
+ # otherwise return nil.
103
+ #
104
+ # The resulting regular expression is case insensitive.
105
+ #
106
+ # @param string [String, nil]
107
+ # @return [Regexp, nil]
108
+ private def pattern(string)
109
+ return nil if string.nil? || string.empty?
110
+
111
+ begin
112
+ /#{string}/i
113
+ rescue RegexpError
114
+ nil
115
+ end
116
+ end
117
+
118
+ # @param user_agent [String] the user agent
119
+ # @return [Boolean] whether the user agent should be blocked
120
+ def blocked_user_agent?(user_agent)
121
+ return false if blocked_user_agent_regexp.nil?
122
+
123
+ blocked_user_agent_regexp.match?(user_agent)
124
+ end
125
+
126
+ # @param user_agent [String] the user agent
127
+ # @return [Boolean] whether the user agent should be monitored
128
+ def monitored_user_agent?(user_agent)
129
+ return false if monitored_user_agent_regexp.nil?
130
+
131
+ monitored_user_agent_regexp.match?(user_agent)
132
+ end
133
+
134
+ # @param user_agent [String] the user agent
135
+ # @return [Array<String>] the matching user agent keys
136
+ def user_agent_keys(user_agent)
137
+ return [] if user_agent_details.nil?
138
+
139
+ user_agent_details
140
+ .filter { |record| record[:pattern].match?(user_agent) }
141
+ .map { |record| record[:key] }
142
+ end
62
143
  end
63
144
  end
64
145
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "1.0.8"
5
+ VERSION = "1.1.0"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
8
  LIBZEN_VERSION = "0.1.48"
data/lib/aikido/zen.rb CHANGED
@@ -16,6 +16,7 @@ require_relative "zen/middleware/middleware"
16
16
  require_relative "zen/middleware/fork_detector"
17
17
  require_relative "zen/middleware/context_setter"
18
18
  require_relative "zen/middleware/allowed_address_checker"
19
+ require_relative "zen/middleware/user_agent_checker"
19
20
  require_relative "zen/middleware/attack_protector"
20
21
  require_relative "zen/middleware/attack_wave_protector"
21
22
  require_relative "zen/middleware/request_tracker"
@@ -37,7 +38,7 @@ module Aikido
37
38
  # @return [void]
38
39
  def self.protect!
39
40
  if config.disabled?
40
- config.logger.warn("Zen has been disabled and will not run.")
41
+ config.logger.warn("Zen has been disabled and will not run")
41
42
  return
42
43
  end
43
44
 
@@ -128,6 +129,15 @@ module Aikido
128
129
  collector.track_request
129
130
  end
130
131
 
132
+ # Track monitored and blocked user agents.
133
+ #
134
+ # @param user_agent_keys [Array<String>, nil] the user agent keys
135
+ # from matching runtime firewall list user agent details.
136
+ # @return [void]
137
+ def self.track_user_agent(user_agent_keys)
138
+ collector.track_user_agent(user_agent_keys)
139
+ end
140
+
131
141
  # Track statistics about an attack wave the app is handling.
132
142
  #
133
143
  # @param attack_wave [Aikido::Zen::Events::AttackWave]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aikido-zen
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.8
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aikido Security
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-01-09 00:00:00.000000000 Z
11
+ date: 2026-01-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -114,6 +114,7 @@ files:
114
114
  - lib/aikido/zen/middleware/middleware.rb
115
115
  - lib/aikido/zen/middleware/rack_throttler.rb
116
116
  - lib/aikido/zen/middleware/request_tracker.rb
117
+ - lib/aikido/zen/middleware/user_agent_checker.rb
117
118
  - lib/aikido/zen/outbound_connection.rb
118
119
  - lib/aikido/zen/outbound_connection_monitor.rb
119
120
  - lib/aikido/zen/package.rb