aikido-zen 1.4.1 → 1.5.1

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: ea0d03f79f4de77618d118402c5e77d661f595d711651c1776cbba978323c3dc
4
- data.tar.gz: 8c01f5d718ce07261e84351db938b8b5bebf3a4bf7b9fc1a4c691686adb3d670
3
+ metadata.gz: 060fe3aabca1fbc463fc77129a0b535d6c53f2c78bf5c4d9d11c2bdd46d119be
4
+ data.tar.gz: 1fa12dcaed6348cfc9ed836e04c995f2459c46b23c8ba5c529fe5fa0c01da40e
5
5
  SHA512:
6
- metadata.gz: e01c0bfada461c9884c0846c3ddebd949cb803767018fcfcb12d0c7f36e27ae4eebc9f84269b73970c984754b326645e7139dd788ed030e7d851cdfb7ae93fb3
7
- data.tar.gz: 9a5fcf35a47e825ce3b39dd556cf58a68feb49d1eee79b9e9ea96e0bf9afe42f4f6e00948d33c23c9fe31f0d7c40651afb1681406a7f63e7055d6354f89fdcc9
6
+ metadata.gz: c8d1311f2b1ab68a068b5aadf5356f6d092ac9df323030c7143d042e9a9705cf7efa06efcf7f15804c7174f78543ad0d630d53b47d42fce487de8f2eeec344eb
7
+ data.tar.gz: cd12d37d110b93b2603f87897985f1d2e70a98a9438c6bdd8beb7f5adfb0a86a4f179a925528472da8af5d859d88309c61cbdd2aa94baa029636f4cc0870c813
@@ -21,15 +21,20 @@ module Aikido::Zen
21
21
  collector: Aikido::Zen.collector,
22
22
  detached_agent: Aikido::Zen.detached_agent,
23
23
  worker: Aikido::Zen::Worker.new(config: config),
24
- api_client: Aikido::Zen::APIClient.new(config: config)
24
+ api_client: Aikido::Zen::APIClient.new(config: config),
25
+ api_stream: Aikido::Zen::APIStream.new(config: config)
25
26
  )
26
- @started_at = nil
27
-
28
27
  @config = config
29
- @worker = worker
30
- @api_client = api_client
31
28
  @collector = collector
32
29
  @detached_agent = detached_agent
30
+ @worker = worker
31
+ @api_client = api_client
32
+ @api_stream = api_stream
33
+
34
+ @started_at = nil
35
+
36
+ @runtime_config_update_mutex = Mutex.new
37
+ @runtime_firewall_lists_update_mutex = Mutex.new
33
38
  end
34
39
 
35
40
  def started?
@@ -59,7 +64,7 @@ module Aikido::Zen
59
64
  at_exit { stop! if started? }
60
65
 
61
66
  report(Events::Started.new(time: @started_at)) do |response|
62
- if Aikido::Zen.runtime_settings.update_from_runtime_config_json(response)
67
+ if update_settings_from_runtime_config!(response)
63
68
  updated_settings!
64
69
  @config.logger.info("Updated runtime settings")
65
70
  end
@@ -68,12 +73,17 @@ module Aikido::Zen
68
73
  end
69
74
 
70
75
  begin
71
- Aikido::Zen.runtime_settings.update_from_runtime_firewall_lists_json(@api_client.fetch_runtime_firewall_lists)
76
+ update_settings_from_runtime_firewall_lists!(@api_client.fetch_runtime_firewall_lists)
72
77
  @config.logger.info("Updated runtime firewall list")
73
78
  rescue => err
74
79
  @config.logger.error(err.message)
75
80
  end
76
81
 
82
+ if @config.realtime_settings_updates_enabled?
83
+ @api_stream.handle("config-updated") { |event| settings_updated(event) }
84
+ @api_stream.start!
85
+ end
86
+
77
87
  poll_for_setting_updates
78
88
 
79
89
  @config.initial_heartbeat_delays.each do |heartbeat_delay|
@@ -92,6 +102,8 @@ module Aikido::Zen
92
102
  @config.logger.info("Stopping Aikido agent")
93
103
  @started_at = nil
94
104
  @worker.shutdown
105
+
106
+ @api_stream.stop!
95
107
  end
96
108
 
97
109
  # Respond to the runtime settings changing after being fetched from the
@@ -157,11 +169,11 @@ module Aikido::Zen
157
169
 
158
170
  heartbeat = @collector.flush
159
171
  report(heartbeat) do |response|
160
- if Aikido::Zen.runtime_settings.update_from_runtime_config_json(response)
172
+ if update_settings_from_runtime_config!(response)
161
173
  updated_settings!
162
174
  @config.logger.info("Updated runtime settings after heartbeat")
163
175
 
164
- Aikido::Zen.runtime_settings.update_from_runtime_firewall_lists_json(@api_client.fetch_runtime_firewall_lists)
176
+ update_settings_from_runtime_firewall_lists!(@api_client.fetch_runtime_firewall_lists)
165
177
  @config.logger.info("Updated runtime firewall list after heartbeat")
166
178
  end
167
179
  end
@@ -177,23 +189,68 @@ module Aikido::Zen
177
189
  def poll_for_setting_updates
178
190
  @worker.every(@config.polling_interval) do
179
191
  if @api_client.should_fetch_settings?
180
- if Aikido::Zen.runtime_settings.update_from_runtime_config_json(@api_client.fetch_runtime_config)
192
+ if update_settings_from_runtime_config!(@api_client.fetch_runtime_config)
181
193
  updated_settings!
182
194
  @config.logger.info("Updated runtime settings after polling")
183
195
  end
184
196
 
185
- Aikido::Zen.runtime_settings.update_from_runtime_firewall_lists_json(@api_client.fetch_runtime_firewall_lists)
197
+ update_settings_from_runtime_firewall_lists!(@api_client.fetch_runtime_firewall_lists)
186
198
  @config.logger.info("Updated runtime firewall list after polling")
187
199
  end
188
200
  end
189
201
  end
190
202
 
191
- private def heartbeats
203
+ private
204
+
205
+ def settings_updated(event)
206
+ updated_at = Time.at(event[:data]["configUpdatedAt"].to_i)
207
+
208
+ if should_fetch_settings?(updated_at)
209
+ if update_settings_from_runtime_config!(@api_client.fetch_runtime_config)
210
+ updated_settings!
211
+ @config.logger.info("Updated runtime settings after server-side event")
212
+
213
+ update_settings_from_runtime_firewall_lists!(@api_client.fetch_runtime_firewall_lists)
214
+ @config.logger.info("Updated runtime firewall list after server-side event")
215
+ end
216
+ end
217
+ end
218
+
219
+ def should_fetch_settings?(updated_at, last_updated_at = Aikido::Zen.runtime_settings.updated_at)
220
+ return false unless @api_client.can_make_requests?
221
+ return true if last_updated_at.nil?
222
+
223
+ updated_at > last_updated_at
224
+ end
225
+
226
+ def heartbeats
192
227
  @heartbeats ||= Aikido::Zen::Agent::HeartbeatsManager.new(
193
228
  config: @config,
194
229
  worker: @worker
195
230
  )
196
231
  end
232
+
233
+ # @param data [Hash]
234
+ # @return [Boolean]
235
+ def update_settings_from_runtime_config!(data)
236
+ return unless @runtime_config_update_mutex.try_lock
237
+ begin
238
+ Aikido::Zen.runtime_settings.update_from_runtime_config_json(data)
239
+ ensure
240
+ @runtime_config_update_mutex.unlock
241
+ end
242
+ end
243
+
244
+ # @param data [Hash]
245
+ # @return [Boolean]
246
+ def update_settings_from_runtime_firewall_lists!(data)
247
+ return unless @runtime_firewall_lists_update_mutex.try_lock
248
+ begin
249
+ Aikido::Zen.runtime_settings.update_from_runtime_firewall_lists_json(data)
250
+ ensure
251
+ @runtime_firewall_lists_update_mutex.unlock
252
+ end
253
+ end
197
254
  end
198
255
  end
199
256
 
@@ -16,6 +16,12 @@ module Aikido::Zen
16
16
  @config = config
17
17
  @system_info = system_info
18
18
  @rate_limiter = rate_limiter
19
+
20
+ @should_fetch_settings_endpoint = @config.realtime_endpoint
21
+ end
22
+
23
+ def should_fetch_settings_endpoint=(uri)
24
+ @should_fetch_settings_endpoint = URI(uri)
19
25
  end
20
26
 
21
27
  # @return [Boolean] whether we have a configured token.
@@ -38,10 +44,10 @@ module Aikido::Zen
38
44
 
39
45
  response = request(
40
46
  Net::HTTP::Get.new("/config", default_headers),
41
- base_url: @config.realtime_endpoint
47
+ base_url: @should_fetch_settings_endpoint
42
48
  )
43
49
 
44
- new_updated_at = Time.at(response["configUpdatedAt"].to_i / 1000)
50
+ new_updated_at = Time.at(response["configUpdatedAt"].to_i)
45
51
  new_updated_at > last_updated_at
46
52
  end
47
53
 
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Aikido::Zen
8
+ class APIStream
9
+ def initialize(
10
+ config: Aikido::Zen.config,
11
+ min_backoff: 5,
12
+ max_backoff: 60,
13
+ backoff_reset: 30,
14
+ open_timeout: 5,
15
+ write_timeout: open_timeout,
16
+ read_timeout: 70
17
+ )
18
+ @config = config
19
+ @min_backoff = min_backoff
20
+ @max_backoff = max_backoff
21
+ @backoff_reset = backoff_reset
22
+ @open_timeout = open_timeout
23
+ @write_timeout = write_timeout
24
+ @read_timeout = read_timeout
25
+
26
+ @running = Concurrent::AtomicBoolean.new
27
+ @thread = nil
28
+ @http = nil
29
+
30
+ endpoint = @config.realtime_endpoint
31
+
32
+ @host = endpoint.host
33
+ @port = endpoint.port
34
+ @use_ssl = endpoint.scheme == "https"
35
+ @token = @config.api_token
36
+
37
+ @handlers = Concurrent::Array.new
38
+ end
39
+
40
+ def running?
41
+ @running.true?
42
+ end
43
+ alias_method :started?, :running?
44
+
45
+ def start!
46
+ return false unless @running.make_true
47
+
48
+ @thread = Thread.new do
49
+ backoff = @min_backoff
50
+
51
+ while running?
52
+ time_before = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
53
+
54
+ begin
55
+ work
56
+ rescue IOError => err
57
+ @config.logger.debug("Error in API stream: #{err.class}: #{err.message}") if running?
58
+ rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::OpenSSLError => err
59
+ @config.logger.debug("Error in API stream: #{err.class}: #{err.message}")
60
+ rescue => err
61
+ @config.logger.error("Error in API stream: #{err.class}: #{err.message}")
62
+ end
63
+
64
+ break unless running?
65
+
66
+ time_after = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second)
67
+
68
+ backoff = if time_after - time_before > @backoff_reset
69
+ @min_backoff
70
+ else
71
+ [backoff * 2, @max_backoff].min
72
+ end
73
+
74
+ jitter = rand * backoff / 2
75
+
76
+ @config.logger.debug("API stream reconnecting in %d seconds" % (backoff + jitter).ceil)
77
+
78
+ sleep(backoff + jitter)
79
+ end
80
+ end
81
+
82
+ true
83
+ end
84
+
85
+ def stop!
86
+ return false unless @running.make_false
87
+
88
+ begin
89
+ @http&.finish
90
+ rescue IOError
91
+ # ignore error
92
+ end
93
+
94
+ begin
95
+ @thread&.wakeup
96
+ rescue ThreadError
97
+ # ignore error
98
+ end
99
+
100
+ @thread.join(@read_timeout)
101
+
102
+ true
103
+ end
104
+
105
+ def handle(type, &block)
106
+ raise ArgumentError, "block required" unless block
107
+
108
+ @handlers << proc do |event|
109
+ block.call(event) if type === event[:type]
110
+ end
111
+ end
112
+
113
+ private def work
114
+ http = Net::HTTP.new(@host, @port)
115
+ http.use_ssl = @use_ssl
116
+ http.open_timeout = @open_timeout
117
+ http.write_timeout = @write_timeout
118
+ http.read_timeout = @read_timeout
119
+ http.max_retries = 0
120
+
121
+ request = Net::HTTP::Get.new("/api/runtime/stream")
122
+ request["Authorization"] = @token
123
+ request["Accept"] = "text/event-stream"
124
+ request["Cache-Control"] = "no-cache"
125
+
126
+ @config.logger.debug("API stream connecting")
127
+ http.start
128
+ @config.logger.debug("API stream connected")
129
+
130
+ @http = http
131
+
132
+ begin
133
+ http.request(request) do |response|
134
+ case response.code.to_i
135
+ when 200
136
+ # empty
137
+ when 401, 403
138
+ @running.make_false
139
+ return nil
140
+ else
141
+ return nil
142
+ end
143
+
144
+ buffer = +""
145
+
146
+ response.read_body do |chunk|
147
+ return nil unless running?
148
+
149
+ @config.logger.debug("API stream received chunk of #{chunk.bytesize} bytes")
150
+
151
+ buffer << chunk
152
+
153
+ while (index = buffer.index("\n\n"))
154
+ event_str = buffer.slice!(0..index + 1)
155
+ buffer = buffer.lstrip
156
+
157
+ event = {}
158
+
159
+ begin
160
+ event_str.each_line do |line|
161
+ case line
162
+ when /^event:\s*(.+)/
163
+ event[:type] = $1.strip
164
+ when /^data:\s*(.+)/
165
+ event[:data] = JSON.parse($1.strip)
166
+ end
167
+ end
168
+ rescue => err
169
+ @config.logger.error("Error in API stream: #{err.class}: #{err.message}")
170
+ next
171
+ end
172
+
173
+ @handlers.each do |handler|
174
+ handler.call(event)
175
+ rescue => err
176
+ @config.logger.error("Error in API stream: #{err.class}: #{err.message}")
177
+ end
178
+ end
179
+ end
180
+ end
181
+ ensure
182
+ @config.logger.debug("API stream disconnecting")
183
+
184
+ @http = nil
185
+
186
+ http.finish if http.started?
187
+
188
+ @config.logger.debug("API stream disconnected")
189
+ end
190
+ end
191
+ end
192
+ end
@@ -1,27 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Aikido::Zen
4
6
  module AttackWave
5
7
  module Helpers
6
- def self.web_scanner?(context)
7
- return true if suspicious_request?(context)
8
+ def self.web_scanner?(context, status_code)
9
+ return true if suspicious_request?(context, status_code)
8
10
 
9
11
  return true if include_suspicious_payload?(context)
10
12
 
11
13
  false
12
14
  end
13
15
 
14
- def self.suspicious_request?(context)
16
+ def self.suspicious_request?(context, status_code)
15
17
  request = context.request
16
18
 
17
- suspicious_method?(request.request_method) || suspicious_path?(request.path_info)
19
+ suspicious_method?(request.request_method) || suspicious_path?(request.path_info, status_code)
18
20
  end
19
21
 
20
22
  def self.suspicious_method?(method)
21
23
  SUSPICIOUS_METHODS.include?(method.downcase)
22
24
  end
23
25
 
24
- def self.suspicious_path?(path)
26
+ def self.suspicious_path?(path, status_code)
25
27
  path_parts = path.downcase.split("/")
26
28
 
27
29
  file_name = path_parts.pop if path_parts.length > 0
@@ -34,6 +36,8 @@ module Aikido::Zen
34
36
  file_extension = file_name_parts.pop if file_name_parts.length > 1
35
37
 
36
38
  return true if SUSPICIOUS_FILE_EXTENSIONS.include?(file_extension)
39
+
40
+ return true if FOREIGN_EXTENSIONS.include?(file_extension) && status_code == 404
37
41
  end
38
42
 
39
43
  path_parts.any? do |directory_name|
@@ -434,6 +438,11 @@ module Aikido::Zen
434
438
  "sqlite3db"
435
439
  ].map(&:downcase).freeze
436
440
 
441
+ # Extensions that a Ruby app would not natively serve. Requests to these
442
+ # paths are only treated as scan hits when the response is 404 — a 200
443
+ # may indicate the app is proxying to a PHP/Java backend.
444
+ FOREIGN_EXTENSIONS = Set.new(%w[php php3 php4 php5 phtml java jsp jspx]).freeze
445
+
437
446
  SUSPICIOUS_SQL_KEYWORDS = [
438
447
  "SELECT (CASE WHEN",
439
448
  "SELECT COUNT(",
@@ -21,14 +21,14 @@ module Aikido::Zen
21
21
  end
22
22
  end
23
23
 
24
- def attack_wave?(context)
24
+ def attack_wave?(context, status_code = nil)
25
25
  client_ip = context.request.client_ip
26
26
 
27
27
  return false unless client_ip
28
28
 
29
29
  return false if @event_times[client_ip]
30
30
 
31
- return false unless AttackWave::Helpers.web_scanner?(context)
31
+ return false unless AttackWave::Helpers.web_scanner?(context, status_code)
32
32
 
33
33
  request_count = @request_counts[client_ip] += 1
34
34
 
@@ -216,6 +216,11 @@ module Aikido::Zen
216
216
  # Defaults to 1000 entries.
217
217
  attr_accessor :idor_max_cache_entries
218
218
 
219
+ # @return [Boolean] whether the realtime settings updates feature is enabled.
220
+ # Defaults to false.
221
+ attr_accessor :realtime_settings_updates_enabled
222
+ alias_method :realtime_settings_updates_enabled?, :realtime_settings_updates_enabled
223
+
219
224
  def initialize
220
225
  self.insert_middleware_after = ::ActionDispatch::RemoteIp
221
226
  self.disabled = read_boolean_from_env(ENV.fetch("AIKIDO_DISABLE", false)) || read_boolean_from_env(ENV.fetch("AIKIDO_DISABLED", false))
@@ -261,6 +266,7 @@ module Aikido::Zen
261
266
  self.idor_tenant_column_name = nil
262
267
  self.idor_excluded_table_names = []
263
268
  self.idor_max_cache_entries = 1000
269
+ self.realtime_settings_updates_enabled = false
264
270
  end
265
271
 
266
272
  # Set the base URL for API requests.
@@ -98,7 +98,9 @@ module Aikido::Zen
98
98
 
99
99
  # @!visibility private
100
100
  def extract_payloads_from(data, source_type, prefix = nil)
101
- if data.respond_to?(:to_hash)
101
+ if data.is_a?(String)
102
+ [Payload.new(data, source_type, prefix.to_s)]
103
+ elsif data.respond_to?(:to_hash)
102
104
  data.to_hash.flat_map do |key, value|
103
105
  extract_payloads_from(value, source_type, [prefix, key].compact.join("."))
104
106
  end
@@ -125,7 +127,7 @@ module Aikido::Zen
125
127
 
126
128
  payloads
127
129
  else
128
- [Payload.new(data, source_type, prefix.to_s)]
130
+ []
129
131
  end
130
132
  end
131
133
 
@@ -12,28 +12,29 @@ module Aikido
12
12
 
13
13
  def call(env)
14
14
  response = @app.call(env)
15
+ status_code = response[0].to_i
15
16
 
16
17
  context = @zen.current_context
17
- protect(context)
18
+ protect(context, status_code)
18
19
 
19
20
  response
20
21
  end
21
22
 
22
23
  # @api private
23
24
  # Visible for testing.
24
- def attack_wave?(context)
25
+ def attack_wave?(context, status_code = nil)
25
26
  request = context.request
26
27
  return false if request.nil?
27
28
 
28
29
  return false if @settings.bypassed_ips.include?(request.client_ip)
29
30
 
30
- @zen.attack_wave_detector.attack_wave?(context)
31
+ @zen.attack_wave_detector.attack_wave?(context, status_code)
31
32
  end
32
33
 
33
34
  # @api private
34
35
  # Visible for testing.
35
- def protect(context)
36
- if attack_wave?(context)
36
+ def protect(context, status_code = nil)
37
+ if attack_wave?(context, status_code)
37
38
  client_ip = context.request.client_ip
38
39
 
39
40
  request = Aikido::Zen::AttackWave::Request.new(
@@ -80,11 +80,11 @@ module Aikido::Zen
80
80
  #
81
81
  # @param data [Hash] the decoded JSON payload from the /api/runtime/config
82
82
  # API endpoint.
83
- # @return [bool]
83
+ # @return [Boolean]
84
84
  def update_from_runtime_config_json(data)
85
85
  last_updated_at = updated_at
86
86
 
87
- self.updated_at = Time.at(data["configUpdatedAt"].to_i / 1000)
87
+ self.updated_at = Time.at(data["configUpdatedAt"].to_i)
88
88
  self.heartbeat_interval = data["heartbeatIntervalInMS"].to_i / 1000
89
89
  self.endpoints = RuntimeSettings::Endpoints.from_json(data["endpoints"])
90
90
  self.blocked_user_ids = data["blockedUserIds"]
@@ -105,7 +105,7 @@ module Aikido::Zen
105
105
  #
106
106
  # @param data [Hash] the decoded JSON payload from the /api/runtime/firewall/lists
107
107
  # API endpoint.
108
- # @return [void]
108
+ # @return [Boolean]
109
109
  def update_from_runtime_firewall_lists_json(data)
110
110
  self.blocked_user_agent_regexp = pattern(data["blockedUserAgents"])
111
111
 
@@ -142,6 +142,8 @@ module Aikido::Zen
142
142
  data["monitoredIPAddresses"]&.each do |ip_list|
143
143
  monitored_ip_lists << RuntimeSettings::IPList.from_json(ip_list)
144
144
  end
145
+
146
+ true
145
147
  end
146
148
 
147
149
  # Construct a regular expression from the non-nil and non-empty string,
@@ -45,6 +45,9 @@ module Aikido::Zen
45
45
  end
46
46
 
47
47
  def attack?
48
+ # We block home-relative path
49
+ return true if @input.start_with?("~") && @filepath.start_with?(@input)
50
+
48
51
  # Single character are ignored because they don't pose a big threat
49
52
  return false if @input.length <= 1
50
53
 
@@ -54,7 +54,7 @@ module Aikido::Zen
54
54
 
55
55
  def initialize(query, input, dialect)
56
56
  @query = query.downcase
57
- @input = input.downcase
57
+ @input = input.downcase.strip
58
58
  @dialect = dialect
59
59
  end
60
60
 
@@ -36,7 +36,7 @@ module Aikido::Zen
36
36
  def attack?
37
37
  return unless @config.stored_ssrf? # Feature flag
38
38
 
39
- return if @config.imds_allowed_hosts.include?(@hostname)
39
+ return if @config.imds_allowed_hosts.include?(@hostname.chomp("."))
40
40
 
41
41
  @addresses.find do |address|
42
42
  DANGEROUS_ADDRESSES.any? do |dangerous_address|
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "1.4.1"
5
+ VERSION = "1.5.1"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
8
  LIBZEN_VERSION = "0.1.61"
data/lib/aikido/zen.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "zen/system_info"
10
10
  require_relative "zen/worker"
11
11
  require_relative "zen/agent"
12
12
  require_relative "zen/api_client"
13
+ require_relative "zen/api_stream"
13
14
  require_relative "zen/context"
14
15
  require_relative "zen/current_context"
15
16
  require_relative "zen/detached_agent"
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.4.1
4
+ version: 1.5.1
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-05-29 00:00:00.000000000 Z
11
+ date: 2026-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -98,6 +98,7 @@ files:
98
98
  - lib/aikido/zen/agent.rb
99
99
  - lib/aikido/zen/agent/heartbeats_manager.rb
100
100
  - lib/aikido/zen/api_client.rb
101
+ - lib/aikido/zen/api_stream.rb
101
102
  - lib/aikido/zen/attack.rb
102
103
  - lib/aikido/zen/attack_wave.rb
103
104
  - lib/aikido/zen/attack_wave/helpers.rb