aikido-zen 1.8.0 → 1.9.0.beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c01eda1d0ea486bf41d00d6efc05f064ad13b3ae71e112af02b1c01e3d164592
4
- data.tar.gz: 222be5ac12985a6f855335a3433d32632704d6ebb1c0f7249fe9bb85492554f2
3
+ metadata.gz: d41298c1b3dc0135c211b4beade2a723603cef96372b5526447e5a0d792d1bb9
4
+ data.tar.gz: 247dea5c7895db42823c445ce7e0f32995ba833cecbcf7430ad09f33c897c179
5
5
  SHA512:
6
- metadata.gz: 4645441dc4301bccc6c09347f864149b071f9cdcd16811309dd7a7e9650848ff90a559ebc576338e7bc8bc41fc3f4d4c2deaa096112628da02bf71619e69ad77
7
- data.tar.gz: 8e7e3717a0a38dcb0d0c38abaf2142569404917f0fff5da720fcd4d180eebb27799d7b836a5222389a33a1a963e431f2459fe2299ab1d6e0c85234b615ce997d
6
+ metadata.gz: ada3f8112966d020346b164380a2cbff9e1ba7c68cfdde2ab2a440d41e1c241c6ef73c9214597fd004e408b61c00ecc9b78294bfd73bef0effd1ca91dbdee5b4
7
+ data.tar.gz: 7f61d126b05f54339ca7633e8058e36d298dcd0a4b0abf93eebe4abb96a5c66edbf0e56cc3e6457ed92f8cf46231d6f00f60dc5b8487bdd014532db48ef52dba
data/README.md CHANGED
@@ -39,6 +39,12 @@ Zen for Ruby 2.7+ is compatible with:
39
39
 
40
40
  * ✅ [Ruby on Rails](docs/rails.md) 7.x, 8.x
41
41
 
42
+ ### Application servers
43
+
44
+ * ✅ [Puma](https://puma.io/)
45
+
46
+ Our test suite only covers Puma. If you use another Rack server, [contact us](docs/troubleshooting.md#contact-support) and we'll help check whether Zen is compatible.
47
+
42
48
  ### Database drivers
43
49
 
44
50
  * ✅ [`sqlite3`](https://github.com/sparklemotion/sqlite3-ruby) 1.x, 2.x
@@ -117,7 +123,8 @@ See [Reporting to Aikido](#reporting-to-your-aikido-security-dashboard) to learn
117
123
 
118
124
  ## Additional configuration
119
125
 
120
- [Configure Zen using environment variables for authentication, mode settings, debugging, and more.](https://help.aikido.dev/doc/configuration-via-env-vars/docrSItUkeR9)
126
+ * [Configure Zen using environment variables for authentication, mode settings, debugging, and more](https://help.aikido.dev/doc/configuration-via-env-vars/docrSItUkeR9)
127
+ * [Track custom events](docs/custom-event-tracking.md): trigger events that Playbooks can act on
121
128
 
122
129
  ## License
123
130
 
@@ -24,7 +24,7 @@ end
24
24
  if __FILE__ == $0
25
25
  ip_ranges = random_ip_ranges.take(1000)
26
26
 
27
- ip_list = Aikido::Zen::RuntimeSettings::IPList.from_json({
27
+ ip_list = Aikido::Zen::Firewall::IPList.from_json({
28
28
  "key" => "key",
29
29
  "source" => "source",
30
30
  "description" => "description",
@@ -0,0 +1,33 @@
1
+ # Track custom events
2
+
3
+ Use `Aikido::Zen.track_custom_event` to report events that only your application knows about, such as failed logins. [Playbooks](https://help.aikido.dev/zen-firewall/zen-features/playbooks) can act when an event occurs repeatedly, for example by blocking an IP after three failed logins in five minutes.
4
+
5
+ ```ruby
6
+ # app/controllers/application_controller.rb
7
+ class ApplicationController < ActionController::Base
8
+ private
9
+
10
+ def authenticate_user!
11
+ # Your authentication logic here
12
+ # ...
13
+
14
+ unless current_user
15
+ Aikido::Zen.track_custom_event("user.login_failed")
16
+ return
17
+ end
18
+
19
+ Aikido::Zen.set_user(
20
+ id: current_user.id,
21
+ name: current_user.name
22
+ )
23
+
24
+ Aikido::Zen.track_custom_event("user.login_succeeded")
25
+ end
26
+ end
27
+ ```
28
+
29
+ After adding `Aikido::Zen.track_custom_event`, trigger the event at least once. It will then appear on the Playbooks page in the Aikido dashboard. From there, you can create a playbook and choose what should happen when the event occurs. Calling `Aikido::Zen.track_custom_event` by itself does not create a playbook or block anything.
30
+
31
+ Call `Aikido::Zen.track_custom_event` while handling an HTTP request. Zen associates the event with the request's IP address. Playbook counts are per IP, not across your whole app. If you call [`Aikido::Zen.set_user`](./rails.md#rate-limiting-and-user-blocking) before tracking the event, Zen also includes the current user. `Aikido::Zen.set_user` is optional. Events without a user are still tracked.
32
+
33
+ Event names can use any format. We recommend lowercase, dot-separated names such as `user.login_failed`.
@@ -68,27 +68,22 @@ module Aikido::Zen
68
68
  # :nocov:
69
69
 
70
70
  report(Events::Started.new(time: @started_at)) do |response|
71
- if update_settings_from_runtime_config!(response, reason: "after start")
72
- updated_settings!
73
-
74
- if @config.realtime_settings_updates_enabled? || @settings.realtime_settings_updates_enabled?
75
- @api_stream.handle("config-updated") do |event|
76
- @config.logger.debug("Received server-sent event: config-updated")
77
- settings_updated(event)
78
- end
71
+ update_settings_from_runtime_config!(response, reason: "after start")
72
+ updated_settings!
79
73
 
80
- @api_stream.start!
74
+ if @config.realtime_settings_updates_enabled? || @settings.realtime_settings_updates_enabled?
75
+ @api_stream.handle("config-updated") do |event|
76
+ @config.logger.debug("Received server-sent event: config-updated")
77
+ settings_updated(event)
81
78
  end
82
- # :nocov:
83
- else
84
- # empty
79
+
80
+ @api_stream.start!
85
81
  end
86
- # :nocov:
87
82
  rescue => err
88
83
  @config.logger.error(err.message)
89
84
  end
90
85
 
91
- begin
86
+ @worker.perform do
92
87
  update_settings_from_runtime_firewall_lists!(@api_client.fetch_runtime_firewall_lists, reason: "after start")
93
88
  rescue => err
94
89
  @config.logger.error(err.message)
@@ -165,6 +160,15 @@ module Aikido::Zen
165
160
  end
166
161
  end
167
162
 
163
+ # Reports a custom event tracked via Aikido::Zen.track_custom_event, if
164
+ # reporting is enabled.
165
+ #
166
+ # @param event [Aikido::Zen::Events::Custom]
167
+ # @return [void]
168
+ def report_custom_event(event)
169
+ report(event) if @api_client.can_make_requests?
170
+ end
171
+
168
172
  # @api private
169
173
  #
170
174
  # Atomically flushes all the stats stored by the agent, and sends a
@@ -272,7 +276,7 @@ module Aikido::Zen
272
276
  begin
273
277
  return false unless Aikido::Zen.api_cache.update_runtime_config(data)
274
278
 
275
- if Aikido::Zen.runtime_settings.update_from_runtime_config_json(data)
279
+ if Aikido::Zen.runtime_settings.update_from_json(data)
276
280
  @config.logger.info("Updated runtime settings #{reason}")
277
281
  true
278
282
  else
@@ -294,7 +298,7 @@ module Aikido::Zen
294
298
  begin
295
299
  return false unless Aikido::Zen.api_cache.update_runtime_firewall_lists(data)
296
300
 
297
- if Aikido::Zen.runtime_settings.update_from_runtime_firewall_lists_json(data)
301
+ if Aikido::Zen.firewall.update_from_json(data)
298
302
  @config.logger.info("Updated runtime firewall list #{reason}")
299
303
  true
300
304
  else
@@ -133,6 +133,8 @@ module Aikido::Zen
133
133
  response = http.request(request)
134
134
 
135
135
  case response
136
+ when Net::HTTPNoContent
137
+ # empty
136
138
  when Net::HTTPSuccess
137
139
  begin
138
140
  body = decode(response.body, response["Content-Encoding"])
@@ -447,7 +447,6 @@ module Aikido::Zen
447
447
  "smb.conf",
448
448
  "iis.log",
449
449
  "pom.xml",
450
- "openapi.json",
451
450
  "vim_settings.xml",
452
451
  "winscp.ini",
453
452
  "ws_ftp.ini"
@@ -56,6 +56,10 @@ module Aikido::Zen
56
56
  # each initial heartbeat event.
57
57
  attr_accessor :initial_heartbeat_delays
58
58
 
59
+ # @return [Symbol] the agent mode for forked worker processes. Can be set
60
+ # through the AIKIDO_AGENT_MODE environment variable.
61
+ attr_reader :agent_mode
62
+
59
63
  # @return [Integer] the interval in seconds at which forked worker processes
60
64
  # poll the parent process for updated runtime settings. Defaults to 10 seconds.
61
65
  attr_accessor :worker_process_polling_interval
@@ -245,6 +249,7 @@ module Aikido::Zen
245
249
  self.api_timeouts = 10
246
250
  self.polling_interval = 60 # 1 min
247
251
  self.initial_heartbeat_delays = [30, 60 * 2] # 30 sec, 2 min
252
+ self.agent_mode = ENV.fetch("AIKIDO_AGENT_MODE", "shared")
248
253
  self.worker_process_polling_interval = 10
249
254
  self.worker_process_polling_jitter = 10
250
255
  self.worker_process_heartbeat_interval = 10
@@ -299,6 +304,17 @@ module Aikido::Zen
299
304
  @realtime_endpoint = URI(url)
300
305
  end
301
306
 
307
+ # @param mode [Symbol, String] a supported agent mode.
308
+ # @raise [ArgumentError]
309
+ def agent_mode=(mode)
310
+ mode = mode.to_sym
311
+ unless AGENT_MODES.include?(mode)
312
+ raise ArgumentError, "agent_mode must be one of #{AGENT_MODES.join(", ")}, got #{mode}"
313
+ end
314
+
315
+ @agent_mode = mode
316
+ end
317
+
302
318
  # Set the logger and configure its severity level according to agent's debug mode
303
319
  # @param logger [::Logger]
304
320
  def logger=(logger)
@@ -370,6 +386,9 @@ module Aikido::Zen
370
386
  end
371
387
  end
372
388
 
389
+ # @!visibility private
390
+ AGENT_MODES = [:shared, :per_worker].freeze
391
+
373
392
  # @!visibility private
374
393
  DEFAULT_AIKIDO_ENDPOINT = "https://guard.aikido.dev"
375
394
 
@@ -95,5 +95,31 @@ module Aikido::Zen
95
95
  )
96
96
  end
97
97
  end
98
+
99
+ # Event sent by Aikido::Zen.track_custom_event to record a custom,
100
+ # user-named event happening during an HTTP request.
101
+ class Custom < Event
102
+ # @param name [String] the name of the tracked event.
103
+ # @param request [Aikido::Zen::Request]
104
+ # @param user [Aikido::Zen::Actor, nil]
105
+ # @param opts [Hash<Symbol, Object>] any other options to pass to
106
+ # the superclass initializer.
107
+ def initialize(name:, request:, user: nil, **opts)
108
+ super(type: "custom", **opts)
109
+ @name = name
110
+ @request = request
111
+ @user = user
112
+ end
113
+
114
+ def as_json
115
+ super.update(
116
+ {
117
+ name: @name,
118
+ request: @request.as_json,
119
+ user: @user && {id: @user.id, name: @user.name}.compact
120
+ }.compact
121
+ )
122
+ end
123
+ end
98
124
  end
99
125
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Aikido::Zen
4
- class RuntimeSettings::IPList
4
+ class Firewall::IPList
5
5
  attr_reader :key
6
6
  attr_reader :source
7
7
  attr_reader :description
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ class Firewall
5
+ # @return [Regexp, nil]
6
+ attr_accessor :blocked_user_agent_regexp
7
+
8
+ # @return [Regexp, nil]
9
+ attr_accessor :monitored_user_agent_regexp
10
+
11
+ # @return [Array<Hash>, nil]
12
+ attr_accessor :user_agent_details
13
+
14
+ # @return [Array<Aikido::Zen::Firewall::IPList>]
15
+ attr_accessor :blocked_ip_lists
16
+
17
+ # @return [Array<Aikido::Zen::Firewall::IPList>]
18
+ attr_accessor :allowed_ip_lists
19
+
20
+ # @return [Array<Aikido::Zen::Firewall::IPList>]
21
+ attr_accessor :monitored_ip_lists
22
+
23
+ def initialize
24
+ self.blocked_ip_lists = []
25
+ self.allowed_ip_lists = []
26
+ self.monitored_ip_lists = []
27
+ end
28
+
29
+ # @param data [Hash] the decoded JSON payload from /api/runtime/firewall/lists
30
+ # @return [Boolean]
31
+ def update_from_json(data)
32
+ self.blocked_user_agent_regexp = pattern(data["blockedUserAgents"])
33
+
34
+ self.monitored_user_agent_regexp = pattern(data["monitoredUserAgents"])
35
+
36
+ self.user_agent_details = []
37
+
38
+ data["userAgentDetails"]&.each do |record|
39
+ key = record["key"]
40
+ pattern = pattern(record["pattern"])
41
+
42
+ next if key.nil? || pattern.nil?
43
+
44
+ user_agent_details << {
45
+ key: key,
46
+ pattern: pattern
47
+ }
48
+ end
49
+
50
+ self.blocked_ip_lists = []
51
+
52
+ data["blockedIPAddresses"]&.each do |ip_list|
53
+ blocked_ip_lists << Firewall::IPList.from_json(ip_list)
54
+ end
55
+
56
+ self.allowed_ip_lists = []
57
+
58
+ data["allowedIPAddresses"]&.each do |ip_list|
59
+ allowed_ip_lists << Firewall::IPList.from_json(ip_list)
60
+ end
61
+
62
+ self.monitored_ip_lists = []
63
+
64
+ data["monitoredIPAddresses"]&.each do |ip_list|
65
+ monitored_ip_lists << Firewall::IPList.from_json(ip_list)
66
+ end
67
+
68
+ true
69
+ end
70
+
71
+ # Construct a regular expression from the non-nil and non-empty string,
72
+ # otherwise return nil.
73
+ #
74
+ # The resulting regular expression is case insensitive.
75
+ #
76
+ # @param string [String, nil]
77
+ # @return [Regexp, nil]
78
+ private def pattern(string)
79
+ return nil if string.nil? || string.empty?
80
+
81
+ begin
82
+ /#{string}/i
83
+ rescue RegexpError
84
+ nil
85
+ end
86
+ end
87
+
88
+ # @param user_agent [String] the user agent
89
+ # @return [Boolean] whether the user agent should be blocked
90
+ def blocked_user_agent?(user_agent)
91
+ return false if blocked_user_agent_regexp.nil?
92
+
93
+ blocked_user_agent_regexp.match?(user_agent)
94
+ end
95
+
96
+ # @param user_agent [String] the user agent
97
+ # @return [Boolean] whether the user agent should be monitored
98
+ def monitored_user_agent?(user_agent)
99
+ return false if monitored_user_agent_regexp.nil?
100
+
101
+ monitored_user_agent_regexp.match?(user_agent)
102
+ end
103
+
104
+ # @param user_agent [String] the user agent
105
+ # @return [Array<String>] the matching user agent keys
106
+ def user_agent_keys(user_agent)
107
+ return [] if user_agent_details.nil?
108
+
109
+ user_agent_details.filter_map { |record| record[:key] if record[:pattern].match?(user_agent) }
110
+ end
111
+
112
+ def allowed_ip?(ip)
113
+ allowed_ip_lists.empty? || allowed_ip_lists.any? { |ip_list| ip_list.include?(ip) }
114
+ end
115
+
116
+ def blocked_ip?(ip)
117
+ blocked_ip_lists.any? { |ip_list| ip_list.include?(ip) }
118
+ end
119
+
120
+ def monitored_ip?(ip)
121
+ monitored_ip_lists.any? { |ip_list| ip_list.include?(ip) }
122
+ end
123
+
124
+ def monitored_ip_list_keys(ip)
125
+ return [] if ip.nil?
126
+
127
+ monitored_ip_lists.filter_map { |ip_list| ip_list.key if ip_list.include?(ip) }
128
+ end
129
+ end
130
+ end
131
+
132
+ require_relative "firewall/ip_list"
@@ -11,14 +11,20 @@ module Aikido
11
11
  @app = app
12
12
 
13
13
  @pid = Concurrent::AtomicFixnum.new(Process.pid)
14
+ @fork_mutex = Mutex.new
14
15
  end
15
16
 
16
17
  def call(env)
17
- new_pid = Process.pid
18
- old_pid = @pid.value
18
+ pid = Process.pid
19
19
 
20
- if new_pid != old_pid && @pid.compare_and_set(old_pid, new_pid)
21
- Aikido::Zen.fork!
20
+ if pid != @pid.value
21
+ @fork_mutex.synchronize do
22
+ if pid != @pid.value
23
+ Aikido::Zen.fork!
24
+
25
+ @pid.value = pid
26
+ end
27
+ end
22
28
  end
23
29
 
24
30
  @app.call(env)
@@ -3,11 +3,11 @@
3
3
  module Aikido::Zen
4
4
  module Middleware
5
5
  class IPListChecker
6
- def initialize(app, zen: Aikido::Zen, config: zen.config, settings: zen.runtime_settings)
6
+ def initialize(app, zen: Aikido::Zen, config: zen.config, firewall: zen.firewall)
7
7
  @app = app
8
8
  @zen = zen
9
9
  @config = config
10
- @settings = settings
10
+ @firewall = firewall
11
11
  end
12
12
 
13
13
  def call(env)
@@ -17,14 +17,14 @@ module Aikido::Zen
17
17
 
18
18
  return @app.call(env) if @zen.request_bypassed?
19
19
 
20
- if !@settings.allowed_ip?(client_ip)
20
+ if !@firewall.allowed_ip?(client_ip)
21
21
  return @config.blocked_responder.call(request, :ip_allowed_list)
22
22
  end
23
23
 
24
- monitored_ip_list_keys = @settings.monitored_ip_list_keys(client_ip)
24
+ monitored_ip_list_keys = @firewall.monitored_ip_list_keys(client_ip)
25
25
  @zen.track_ip_list(monitored_ip_list_keys)
26
26
 
27
- blocked_ip_lists = @settings.blocked_ip_lists.filter { |ip_list| ip_list.include?(client_ip) }
27
+ blocked_ip_lists = @firewall.blocked_ip_lists.filter { |ip_list| ip_list.include?(client_ip) }
28
28
 
29
29
  if !blocked_ip_lists.empty?
30
30
  @zen.track_ip_list(blocked_ip_lists.map(&:key))
@@ -3,11 +3,11 @@
3
3
  module Aikido::Zen
4
4
  module Middleware
5
5
  class UserAgentChecker
6
- def initialize(app, zen: Aikido::Zen, config: zen.config, settings: zen.runtime_settings)
6
+ def initialize(app, zen: Aikido::Zen, config: zen.config, firewall: zen.firewall)
7
7
  @app = app
8
8
  @zen = zen
9
9
  @config = config
10
- @settings = settings
10
+ @firewall = firewall
11
11
  end
12
12
 
13
13
  def call(env)
@@ -17,15 +17,15 @@ module Aikido::Zen
17
17
 
18
18
  user_agent = request.user_agent
19
19
 
20
- if @settings.blocked_user_agent?(user_agent)
21
- user_agent_keys = @settings.user_agent_keys(user_agent)
20
+ if @firewall.blocked_user_agent?(user_agent)
21
+ user_agent_keys = @firewall.user_agent_keys(user_agent)
22
22
  @zen.track_user_agent(user_agent_keys)
23
23
 
24
24
  return @config.blocked_responder.call(request, :user_agent)
25
25
  end
26
26
 
27
- if @settings.monitored_user_agent?(user_agent)
28
- user_agent_keys = @settings.user_agent_keys(user_agent)
27
+ if @firewall.monitored_user_agent?(user_agent)
28
+ user_agent_keys = @firewall.user_agent_keys(user_agent)
29
29
  @zen.track_user_agent(user_agent_keys)
30
30
  end
31
31
 
@@ -3,16 +3,6 @@
3
3
  require "set"
4
4
 
5
5
  module Aikido::Zen
6
- # Stores the firewall configuration sourced from the Aikido dashboard. This
7
- # object is updated by the Agent regularly.
8
- #
9
- # Because the RuntimeSettings object can be modified in runtime, it implements
10
- # the {Observable} API, allowing you to subscribe to updates. These are
11
- # triggered whenever #update_from_runtime_settings_json makes a change
12
- # (i.e. if the settings don't change, no update is triggered).
13
- #
14
- # You can subscribe to changes with +#add_observer(object, func_name)+, which
15
- # will call the function passing the settings as an argument
16
6
  RuntimeSettings = Struct.new(
17
7
  :updated_at,
18
8
  :heartbeat_interval,
@@ -21,12 +11,6 @@ module Aikido::Zen
21
11
  :bypassed_ips,
22
12
  :received_any_stats,
23
13
  :blocking_mode,
24
- :blocked_user_agent_regexp,
25
- :monitored_user_agent_regexp,
26
- :user_agent_details,
27
- :blocked_ip_lists,
28
- :allowed_ip_lists,
29
- :monitored_ip_lists,
30
14
  :block_new_outbound,
31
15
  :domains,
32
16
  :excluded_user_ids_from_rate_limiting,
@@ -36,9 +20,6 @@ module Aikido::Zen
36
20
  super
37
21
  self.endpoints ||= RuntimeSettings::Endpoints.new
38
22
  self.bypassed_ips ||= RuntimeSettings::IPSet.new
39
- self.blocked_ip_lists ||= []
40
- self.allowed_ip_lists ||= []
41
- self.monitored_ip_lists ||= []
42
23
  self.domains ||= RuntimeSettings::Domains.new
43
24
  self.enabled_features ||= Set.new
44
25
  end
@@ -66,24 +47,6 @@ module Aikido::Zen
66
47
  # @!attribute [rw] blocking_mode
67
48
  # @return [Boolean]
68
49
 
69
- # @!attribute [rw] blocked_ip_lists
70
- # @return [Array<Aikido::Zen::RuntimeSettings::IPList>]
71
-
72
- # @!attribute [rw] allowed_ip_lists
73
- # @return [Array<Aikido::Zen::RuntimeSettings::IPList>]
74
-
75
- # @!attribute [rw] monitored_ip_lists
76
- # @return [Array<Aikido::Zen::RuntimeSettings::IPList>]
77
-
78
- # @!attribute [rw] blocked_user_agent_regexp
79
- # @return [Regexp]
80
-
81
- # @!attribute [rw] monitored_user_agent_regexp
82
- # @return [Regexp]
83
-
84
- # @!attribute [rw] user_agent_details
85
- # @return [Regexp]
86
-
87
50
  # @!attribute [rw] block_new_outbound
88
51
  # @return [Boolean]
89
52
 
@@ -97,15 +60,9 @@ module Aikido::Zen
97
60
  # @!attribute [rw] enabled_features
98
61
  # @return [Set<String>]
99
62
 
100
- # Parse and interpret the JSON response from the core API with updated
101
- # runtime settings, and apply the changes.
102
- #
103
- # This will also notify any subscriber to updates.
104
- #
105
63
  # @param data [Hash] the decoded JSON payload from the /api/runtime/config
106
- # API endpoint.
107
64
  # @return [Boolean]
108
- def update_from_runtime_config_json(data)
65
+ def update_from_json(data)
109
66
  last_updated_at = updated_at
110
67
 
111
68
  self.updated_at = Time.at(data["configUpdatedAt"].to_i)
@@ -126,69 +83,6 @@ module Aikido::Zen
126
83
  updated_at != last_updated_at
127
84
  end
128
85
 
129
- # Parse and interpret the JSON response from the core API with updated
130
- # runtime firewall lists, and apply the changes.
131
- #
132
- # @param data [Hash] the decoded JSON payload from the /api/runtime/firewall/lists
133
- # API endpoint.
134
- # @return [Boolean]
135
- def update_from_runtime_firewall_lists_json(data)
136
- self.blocked_user_agent_regexp = pattern(data["blockedUserAgents"])
137
-
138
- self.monitored_user_agent_regexp = pattern(data["monitoredUserAgents"])
139
-
140
- self.user_agent_details = []
141
-
142
- data["userAgentDetails"]&.each do |record|
143
- key = record["key"]
144
- pattern = pattern(record["pattern"])
145
-
146
- next if key.nil? || pattern.nil?
147
-
148
- user_agent_details << {
149
- key: key,
150
- pattern: pattern
151
- }
152
- end
153
-
154
- self.blocked_ip_lists = []
155
-
156
- data["blockedIPAddresses"]&.each do |ip_list|
157
- blocked_ip_lists << RuntimeSettings::IPList.from_json(ip_list)
158
- end
159
-
160
- self.allowed_ip_lists = []
161
-
162
- data["allowedIPAddresses"]&.each do |ip_list|
163
- allowed_ip_lists << RuntimeSettings::IPList.from_json(ip_list)
164
- end
165
-
166
- self.monitored_ip_lists = []
167
-
168
- data["monitoredIPAddresses"]&.each do |ip_list|
169
- monitored_ip_lists << RuntimeSettings::IPList.from_json(ip_list)
170
- end
171
-
172
- true
173
- end
174
-
175
- # Construct a regular expression from the non-nil and non-empty string,
176
- # otherwise return nil.
177
- #
178
- # The resulting regular expression is case insensitive.
179
- #
180
- # @param string [String, nil]
181
- # @return [Regexp, nil]
182
- private def pattern(string)
183
- return nil if string.nil? || string.empty?
184
-
185
- begin
186
- /#{string}/i
187
- rescue RegexpError
188
- nil
189
- end
190
- end
191
-
192
86
  # @param ip [String]
193
87
  # @return [Boolean] Whether the IP is included in the bypassed IPs set.
194
88
  def bypassed_ip?(ip)
@@ -202,48 +96,6 @@ module Aikido::Zen
202
96
  excluded_user_ids_from_rate_limiting&.include?(user_id.to_s) || false
203
97
  end
204
98
 
205
- # @param user_agent [String] the user agent
206
- # @return [Boolean] whether the user agent should be blocked
207
- def blocked_user_agent?(user_agent)
208
- return false if blocked_user_agent_regexp.nil?
209
-
210
- blocked_user_agent_regexp.match?(user_agent)
211
- end
212
-
213
- # @param user_agent [String] the user agent
214
- # @return [Boolean] whether the user agent should be monitored
215
- def monitored_user_agent?(user_agent)
216
- return false if monitored_user_agent_regexp.nil?
217
-
218
- monitored_user_agent_regexp.match?(user_agent)
219
- end
220
-
221
- # @param user_agent [String] the user agent
222
- # @return [Array<String>] the matching user agent keys
223
- def user_agent_keys(user_agent)
224
- return [] if user_agent_details.nil?
225
-
226
- user_agent_details.filter_map { |record| record[:key] if record[:pattern].match?(user_agent) }
227
- end
228
-
229
- def allowed_ip?(ip)
230
- allowed_ip_lists.empty? || allowed_ip_lists.any? { |ip_list| ip_list.include?(ip) }
231
- end
232
-
233
- def blocked_ip?(ip)
234
- blocked_ip_lists.any? { |ip_list| ip_list.include?(ip) }
235
- end
236
-
237
- def monitored_ip?(ip)
238
- monitored_ip_lists.any? { |ip_list| ip_list.include?(ip) }
239
- end
240
-
241
- def monitored_ip_list_keys(ip)
242
- return [] if ip.nil?
243
-
244
- monitored_ip_lists.filter_map { |ip_list| ip_list.key if ip_list.include?(ip) }
245
- end
246
-
247
99
  def block_outbound?(connection)
248
100
  domain = domains[connection.host]
249
101
 
@@ -261,6 +113,5 @@ module Aikido::Zen
261
113
  end
262
114
 
263
115
  require_relative "runtime_settings/ip_set"
264
- require_relative "runtime_settings/ip_list"
265
116
  require_relative "runtime_settings/endpoints"
266
117
  require_relative "runtime_settings/domains"
@@ -27,7 +27,11 @@ module Aikido::Zen
27
27
  "/var/",
28
28
  # Common container/cloud directories
29
29
  "/app/",
30
- "/code/"
30
+ "/code/",
31
+ "/data/",
32
+ "/rails/",
33
+ "/workspace/",
34
+ "/workspaces/"
31
35
  ]
32
36
 
33
37
  MACOS_PATH_STARTS = [
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "resolv"
4
4
  require "ipaddr"
5
+ require "socket"
5
6
 
6
7
  module Aikido::Zen
7
8
  module Scanners
@@ -72,17 +73,51 @@ module Aikido::Zen
72
73
 
73
74
  PRIVATE_RANGES = PRIVATE_IPV4_RANGES + PRIVATE_IPV6_RANGES + PRIVATE_IPV4_RANGES.map(&:ipv4_mapped)
74
75
 
76
+ # DNS lookups already performed for this request, keyed by hostname.
77
+ #
78
+ # @return [Aikido::Zen::Scanners::SSRF::DNSLookups, nil] nil outside
79
+ # of a request
75
80
  def resolved_in_current_context
76
81
  context = Aikido::Zen.current_context
77
82
  context && context["dns.lookups"]
78
83
  end
79
84
 
85
+ # Matches strings that contain only characters that appear in some
86
+ # form of IP address.
87
+ #
88
+ # Hostnames are not expected to match, allowing `parse_address` to
89
+ # skip the `Socket.getaddrinfo` call.
90
+ ADDRESS_REGEXP = /\A[0-9a-fx:.]+\z/i
91
+
92
+ # Parses `address` as an IP address, in any form that is accepted
93
+ # by `getaddrinfo`:
94
+ #
95
+ # * Standard IPv4/IPv6 notation (e.g. "127.0.0.1", "::1")
96
+ # * Plain integer IPv4 notation, in decimal, octal, or hexadecimal
97
+ # (e.g. "2130706433", "017700000001", "0x7f000001")
98
+ # * Shorthand dotted IPv4 notation (e.g. "127.1")
99
+ #
100
+ # Delegates to `Socket.getaddrinfo` with the `AI_NUMERICHOST` flag,
101
+ # which never performs a DNS lookup.
102
+ #
103
+ # @param address [String]
104
+ # @return [Array<IPAddr>, nil] nil if `address` is not an address
105
+ def parse_address(address)
106
+ return nil unless address.is_a?(String) && ADDRESS_REGEXP.match?(address)
107
+
108
+ Socket.getaddrinfo(address, nil, :UNSPEC, :STREAM, nil, Socket::AI_NUMERICHOST)
109
+ .map { |info| IPAddr.new(info[3]) }
110
+ rescue SocketError
111
+ nil
112
+ end
113
+
80
114
  def resolve(hostname_or_address)
81
115
  return [] if hostname_or_address.nil?
82
116
 
117
+ addresses = parse_address(hostname_or_address)
118
+ return addresses if addresses
119
+
83
120
  case hostname_or_address
84
- when Resolv::AddressRegex
85
- [IPAddr.new(hostname_or_address)]
86
121
  when resolved_in_current_context
87
122
  resolved_in_current_context[hostname_or_address]
88
123
  .map { |address| IPAddr.new(address) }
@@ -6,9 +6,9 @@ require_relative "../scanners/ssrf_scanner"
6
6
 
7
7
  module Aikido::Zen
8
8
  module Sinks
9
- # We intercept IPSocket.open to hook our DNS checks around it, since
10
- # there's no way to access the internal DNS resolution that happens in C
11
- # when using the socket primitives.
9
+ # We intercept TCPSocket#initialize to hook our DNS checks around it,
10
+ # since there's no way to access the internal DNS resolution that
11
+ # happens in C when using the socket primitives.
12
12
  module Socket
13
13
  SINK = Sinks.add("socket", "outgoing_http_op", scanners: [
14
14
  Scanners::StoredSSRFScanner,
@@ -17,7 +17,7 @@ module Aikido::Zen
17
17
 
18
18
  module Helpers
19
19
  def self.scan(hostname, socket, operation)
20
- # We're patching IPSocket.open(..) method.
20
+ # We're patching TCPSocket#initialize.
21
21
  # The IPSocket class hierarchy is:
22
22
  # IPSocket
23
23
  # / \
@@ -59,20 +59,22 @@ module Aikido::Zen
59
59
  end
60
60
 
61
61
  def self.load_sinks!
62
- ::IPSocket.singleton_class.class_eval do
62
+ ::TCPSocket.class_eval do
63
63
  extend Sinks::DSL
64
64
 
65
- sink_after :open do |socket, remote_host|
65
+ # This sink method covers both the `open` and the `new` class methods,
66
+ # since these methods call `initialize`.
67
+ sink_after :initialize do |_result, remote_host, *|
66
68
  # Code coverage is disabled here because the tests are contrived and
67
69
  # intentionally do not call open.
68
70
  # :nocov:
69
- Helpers.scan(remote_host, socket, "open")
71
+ Helpers.scan(remote_host, self, "open")
70
72
  # :nocov:
71
73
  rescue Aikido::Zen::UnderAttackError, Aikido::Zen::Sinks::DSL::PresafeError
72
74
  # If the scan raises an exception that will escape the safe block,
73
75
  # the open socket must be closed because it will not be returned,
74
76
  # so the user cannot close it.
75
- socket.close
77
+ close
76
78
 
77
79
  raise
78
80
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "1.8.0"
5
+ VERSION = "1.9.0.beta.2"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
8
  LIBZEN_VERSION = "0.1.61"
@@ -107,14 +107,14 @@ module Aikido::Zen::WorkerProcess
107
107
 
108
108
  if settings["config"]
109
109
  @config.logger.debug("Forked worker process #{Process.pid}: starting config update")
110
- Aikido::Zen.runtime_settings.update_from_runtime_config_json(settings["config"])
110
+ Aikido::Zen.runtime_settings.update_from_json(settings["config"])
111
111
  @known_config_generation = settings["config_generation"]
112
112
  @config.logger.debug("Forked worker process #{Process.pid}: finished config update")
113
113
  end
114
114
 
115
115
  if settings["firewall_lists"]
116
116
  @config.logger.debug("Forked worker process #{Process.pid}: starting firewall_lists update")
117
- Aikido::Zen.runtime_settings.update_from_runtime_firewall_lists_json(settings["firewall_lists"])
117
+ Aikido::Zen.firewall.update_from_json(settings["firewall_lists"])
118
118
  @known_firewall_lists_generation = settings["firewall_lists_generation"]
119
119
  @config.logger.debug("Forked worker process #{Process.pid}: finished firewall_lists update")
120
120
  end
data/lib/aikido/zen.rb CHANGED
@@ -27,6 +27,7 @@ require_relative "zen/middleware/attack_wave_protector"
27
27
  require_relative "zen/middleware/request_tracker"
28
28
  require_relative "zen/outbound_connection"
29
29
  require_relative "zen/runtime_settings"
30
+ require_relative "zen/firewall"
30
31
  require_relative "zen/rate_limiter"
31
32
  require_relative "zen/attack_wave"
32
33
  require_relative "zen/sql"
@@ -88,6 +89,14 @@ module Aikido
88
89
  @runtime_settings = settings
89
90
  end
90
91
 
92
+ def self.firewall
93
+ @firewall ||= Firewall.new
94
+ end
95
+
96
+ def self.firewall=(firewall)
97
+ @firewall = firewall
98
+ end
99
+
91
100
  def self.api_cache
92
101
  @api_cache ||= APICache.new
93
102
  end
@@ -269,6 +278,35 @@ module Aikido
269
278
  alias_method :set_user, :track_user
270
279
  end
271
280
 
281
+ # Track a custom event happening in your application, like a failed login or
282
+ # a password reset request. Zen automatically attaches the IP address, user
283
+ # agent, and current user (if you called .track_user) from the request.
284
+ #
285
+ # Only works inside an HTTP request; if called from a background job or
286
+ # script, nothing gets sent.
287
+ #
288
+ # @param name [String]
289
+ # @return [void]
290
+ def self.track_custom_event(name)
291
+ unless name.is_a?(String) && !name.empty?
292
+ config.logger.warn("track_custom_event expects a non-empty String as the event name")
293
+ return
294
+ end
295
+
296
+ return unless agent
297
+
298
+ context = current_context
299
+ return unless context
300
+
301
+ event = Events::Custom.new(
302
+ name: name,
303
+ request: context.request,
304
+ user: context.request.actor
305
+ )
306
+
307
+ agent.report_custom_event(event)
308
+ end
309
+
272
310
  # @return [Aikido::Zen::AttackWave::Detector] the attack wave detector.
273
311
  def self.attack_wave_detector
274
312
  @attack_wave_detector ||= AttackWave::Detector.new
@@ -411,10 +449,19 @@ module Aikido
411
449
  # @!visibility private
412
450
  # Stop any background threads.
413
451
  def self.stop!
414
- @agent&.stop!
415
- @worker_process_server&.stop
452
+ agent = @agent
453
+ @agent = nil
454
+ agent&.stop!
455
+
456
+ server = @worker_process_server
457
+ @worker_process_server = nil
458
+ server&.stop
416
459
 
417
- @worker_process_client&.stop
460
+ client = @worker_process_client
461
+ @worker_process_client = nil
462
+ client&.stop
463
+
464
+ @running.make_false
418
465
  end
419
466
 
420
467
  def self.agent
@@ -425,13 +472,13 @@ module Aikido
425
472
  @worker_process_server
426
473
  end
427
474
 
428
- @has_started = Concurrent::AtomicBoolean.new(false)
475
+ @running = Concurrent::AtomicBoolean.new(false)
429
476
 
430
477
  class << self
431
478
  def start!
432
479
  return unless start?
433
480
 
434
- return unless @has_started.make_true
481
+ return unless @running.make_true
435
482
 
436
483
  @worker_process_server = WorkerProcess::Agent::Server.new
437
484
  @worker_process_server.start
@@ -446,9 +493,9 @@ module Aikido
446
493
  end
447
494
 
448
495
  def fork!
449
- server = @worker_process_server
450
- return unless server
496
+ return unless @running.true?
451
497
 
498
+ server = @worker_process_server
452
499
  @worker_process_server = nil
453
500
  server.close
454
501
 
@@ -456,11 +503,30 @@ module Aikido
456
503
  @worker_process_client = nil
457
504
  client&.close
458
505
 
506
+ case config.agent_mode
507
+ when :per_worker
508
+ start_per_worker_mode
509
+ when :shared
510
+ start_shared_mode(server)
511
+ end
512
+ rescue => err
513
+ config.logger.error("Forked worker process #{Process.pid}: failed to start: #{err.message}")
514
+ end
515
+
516
+ private
517
+
518
+ def start_per_worker_mode
519
+ agent = @agent
520
+ @agent = nil
521
+ agent&.stop!
522
+
523
+ @agent = Agent.start
524
+ end
525
+
526
+ def start_shared_mode(server)
459
527
  client = WorkerProcess::Agent::Client.new(server.host, server.port)
460
528
  client.start
461
529
  @worker_process_client = client
462
- rescue => err
463
- config.logger.error("Forked worker process #{Process.pid}: failed to start worker process client: #{err.message}")
464
530
  end
465
531
  end
466
532
 
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.8.0
4
+ version: 1.9.0.beta.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aikido Security
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-21 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -89,6 +89,7 @@ files:
89
89
  - benchmarks/rpc/benchmark.rb
90
90
  - docs/banner.svg
91
91
  - docs/config.md
92
+ - docs/custom-event-tracking.md
92
93
  - docs/idor-protection.md
93
94
  - docs/invalid-sql-queries.md
94
95
  - docs/proxy.md
@@ -123,6 +124,8 @@ files:
123
124
  - lib/aikido/zen/current_context.rb
124
125
  - lib/aikido/zen/errors.rb
125
126
  - lib/aikido/zen/event.rb
127
+ - lib/aikido/zen/firewall.rb
128
+ - lib/aikido/zen/firewall/ip_list.rb
126
129
  - lib/aikido/zen/helpers.rb
127
130
  - lib/aikido/zen/idor.rb
128
131
  - lib/aikido/zen/idor/analysis_result.rb
@@ -163,7 +166,6 @@ files:
163
166
  - lib/aikido/zen/runtime_settings/domain_settings.rb
164
167
  - lib/aikido/zen/runtime_settings/domains.rb
165
168
  - lib/aikido/zen/runtime_settings/endpoints.rb
166
- - lib/aikido/zen/runtime_settings/ip_list.rb
167
169
  - lib/aikido/zen/runtime_settings/ip_set.rb
168
170
  - lib/aikido/zen/runtime_settings/protection_settings.rb
169
171
  - lib/aikido/zen/runtime_settings/rate_limit_settings.rb