aikido-zen 1.0.2-aarch64-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 (125) hide show
  1. checksums.yaml +7 -0
  2. data/.aikido +6 -0
  3. data/.ruby-version +1 -0
  4. data/.simplecov +32 -0
  5. data/.standard.yml +3 -0
  6. data/LICENSE +674 -0
  7. data/README.md +148 -0
  8. data/Rakefile +67 -0
  9. data/benchmarks/README.md +22 -0
  10. data/benchmarks/rails7.1_benchmark.js +1 -0
  11. data/benchmarks/rails7.1_sql_injection.js +102 -0
  12. data/docs/banner.svg +202 -0
  13. data/docs/config.md +133 -0
  14. data/docs/proxy.md +10 -0
  15. data/docs/rails.md +112 -0
  16. data/docs/troubleshooting.md +62 -0
  17. data/lib/aikido/zen/actor.rb +146 -0
  18. data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
  19. data/lib/aikido/zen/agent.rb +181 -0
  20. data/lib/aikido/zen/api_client.rb +145 -0
  21. data/lib/aikido/zen/attack.rb +217 -0
  22. data/lib/aikido/zen/attack_wave/helpers.rb +457 -0
  23. data/lib/aikido/zen/attack_wave.rb +88 -0
  24. data/lib/aikido/zen/background_worker.rb +52 -0
  25. data/lib/aikido/zen/cache.rb +91 -0
  26. data/lib/aikido/zen/capped_collections.rb +86 -0
  27. data/lib/aikido/zen/collector/event.rb +238 -0
  28. data/lib/aikido/zen/collector/hosts.rb +30 -0
  29. data/lib/aikido/zen/collector/routes.rb +71 -0
  30. data/lib/aikido/zen/collector/sink_stats.rb +95 -0
  31. data/lib/aikido/zen/collector/stats.rb +122 -0
  32. data/lib/aikido/zen/collector/users.rb +32 -0
  33. data/lib/aikido/zen/collector.rb +223 -0
  34. data/lib/aikido/zen/config.rb +312 -0
  35. data/lib/aikido/zen/context/rack_request.rb +27 -0
  36. data/lib/aikido/zen/context/rails_request.rb +47 -0
  37. data/lib/aikido/zen/context.rb +145 -0
  38. data/lib/aikido/zen/detached_agent/agent.rb +79 -0
  39. data/lib/aikido/zen/detached_agent/front_object.rb +41 -0
  40. data/lib/aikido/zen/detached_agent/server.rb +78 -0
  41. data/lib/aikido/zen/detached_agent.rb +2 -0
  42. data/lib/aikido/zen/errors.rb +107 -0
  43. data/lib/aikido/zen/event.rb +116 -0
  44. data/lib/aikido/zen/helpers.rb +24 -0
  45. data/lib/aikido/zen/internals.rb +123 -0
  46. data/lib/aikido/zen/libzen-v0.1.48-aarch64-linux.so +0 -0
  47. data/lib/aikido/zen/middleware/allowed_address_checker.rb +26 -0
  48. data/lib/aikido/zen/middleware/attack_wave_protector.rb +46 -0
  49. data/lib/aikido/zen/middleware/context_setter.rb +26 -0
  50. data/lib/aikido/zen/middleware/fork_detector.rb +23 -0
  51. data/lib/aikido/zen/middleware/middleware.rb +11 -0
  52. data/lib/aikido/zen/middleware/rack_throttler.rb +50 -0
  53. data/lib/aikido/zen/middleware/request_tracker.rb +197 -0
  54. data/lib/aikido/zen/outbound_connection.rb +62 -0
  55. data/lib/aikido/zen/outbound_connection_monitor.rb +23 -0
  56. data/lib/aikido/zen/package.rb +22 -0
  57. data/lib/aikido/zen/payload.rb +50 -0
  58. data/lib/aikido/zen/rails_engine.rb +53 -0
  59. data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
  60. data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
  61. data/lib/aikido/zen/rate_limiter/result.rb +31 -0
  62. data/lib/aikido/zen/rate_limiter.rb +50 -0
  63. data/lib/aikido/zen/request/heuristic_router.rb +115 -0
  64. data/lib/aikido/zen/request/rails_router.rb +92 -0
  65. data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
  66. data/lib/aikido/zen/request/schema/auth_schemas.rb +54 -0
  67. data/lib/aikido/zen/request/schema/builder.rb +121 -0
  68. data/lib/aikido/zen/request/schema/definition.rb +107 -0
  69. data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
  70. data/lib/aikido/zen/request/schema.rb +87 -0
  71. data/lib/aikido/zen/request.rb +88 -0
  72. data/lib/aikido/zen/route.rb +96 -0
  73. data/lib/aikido/zen/runtime_settings/endpoints.rb +78 -0
  74. data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
  75. data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
  76. data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
  77. data/lib/aikido/zen/runtime_settings.rb +66 -0
  78. data/lib/aikido/zen/scan.rb +75 -0
  79. data/lib/aikido/zen/scanners/path_traversal/helpers.rb +68 -0
  80. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +64 -0
  81. data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
  82. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +65 -0
  83. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +94 -0
  84. data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
  85. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +97 -0
  86. data/lib/aikido/zen/scanners/ssrf_scanner.rb +266 -0
  87. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +55 -0
  88. data/lib/aikido/zen/scanners.rb +7 -0
  89. data/lib/aikido/zen/sink.rb +118 -0
  90. data/lib/aikido/zen/sinks/action_controller.rb +85 -0
  91. data/lib/aikido/zen/sinks/async_http.rb +80 -0
  92. data/lib/aikido/zen/sinks/curb.rb +113 -0
  93. data/lib/aikido/zen/sinks/em_http.rb +83 -0
  94. data/lib/aikido/zen/sinks/excon.rb +118 -0
  95. data/lib/aikido/zen/sinks/file.rb +153 -0
  96. data/lib/aikido/zen/sinks/http.rb +93 -0
  97. data/lib/aikido/zen/sinks/httpclient.rb +95 -0
  98. data/lib/aikido/zen/sinks/httpx.rb +78 -0
  99. data/lib/aikido/zen/sinks/kernel.rb +33 -0
  100. data/lib/aikido/zen/sinks/mysql2.rb +31 -0
  101. data/lib/aikido/zen/sinks/net_http.rb +101 -0
  102. data/lib/aikido/zen/sinks/patron.rb +103 -0
  103. data/lib/aikido/zen/sinks/pg.rb +72 -0
  104. data/lib/aikido/zen/sinks/resolv.rb +62 -0
  105. data/lib/aikido/zen/sinks/socket.rb +85 -0
  106. data/lib/aikido/zen/sinks/sqlite3.rb +46 -0
  107. data/lib/aikido/zen/sinks/trilogy.rb +31 -0
  108. data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
  109. data/lib/aikido/zen/sinks.rb +36 -0
  110. data/lib/aikido/zen/sinks_dsl.rb +250 -0
  111. data/lib/aikido/zen/synchronizable.rb +24 -0
  112. data/lib/aikido/zen/system_info.rb +80 -0
  113. data/lib/aikido/zen/version.rb +10 -0
  114. data/lib/aikido/zen/worker.rb +87 -0
  115. data/lib/aikido/zen.rb +303 -0
  116. data/lib/aikido-zen.rb +3 -0
  117. data/placeholder/.gitignore +4 -0
  118. data/placeholder/README.md +11 -0
  119. data/placeholder/Rakefile +75 -0
  120. data/placeholder/lib/placeholder.rb.template +3 -0
  121. data/placeholder/placeholder.gemspec.template +20 -0
  122. data/tasklib/bench.rake +94 -0
  123. data/tasklib/libzen.rake +133 -0
  124. data/tasklib/wrk.rb +88 -0
  125. metadata +214 -0
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cache"
4
+ require_relative "attack_wave/helpers"
5
+
6
+ module Aikido::Zen
7
+ module AttackWave
8
+ class Detector
9
+ def initialize(config: Aikido::Zen.config, clock: nil)
10
+ @config = config
11
+
12
+ @event_times = Cache.new(@config.attack_wave_max_cache_entries, ttl: @config.attack_wave_min_time_between_events, clock: clock)
13
+
14
+ @request_counts = Cache.new(@config.attack_wave_max_cache_entries, 0, ttl: @config.attack_wave_min_time_between_requests, clock: clock)
15
+ end
16
+
17
+ def attack_wave?(context)
18
+ client_ip = context.request.client_ip
19
+
20
+ return false unless client_ip
21
+
22
+ return false if @event_times[client_ip]
23
+
24
+ return false unless AttackWave::Helpers.web_scanner?(context)
25
+
26
+ request_count = @request_counts[client_ip] += 1
27
+
28
+ return false if request_count < @config.attack_wave_threshold
29
+
30
+ @event_times[client_ip] = Time.now.utc
31
+
32
+ true
33
+ end
34
+ end
35
+
36
+ class Request
37
+ # @return [String]
38
+ attr_reader :ip_address
39
+
40
+ # @return [String]
41
+ attr_reader :user_agent
42
+
43
+ # @return [String]
44
+ attr_reader :source
45
+
46
+ # @param ip_address [String]
47
+ # @param user_agent [String]
48
+ # @param source [String]
49
+ # @return [Aikido::Zen::AttackWave::Request]
50
+ def initialize(ip_address:, user_agent:, source:)
51
+ @ip_address = ip_address
52
+ @user_agent = user_agent
53
+ @source = source
54
+ end
55
+
56
+ def as_json
57
+ {
58
+ ipAddress: @ip_address,
59
+ userAgent: @user_agent,
60
+ source: @source
61
+ }.compact
62
+ end
63
+ end
64
+
65
+ class Attack
66
+ # @return [Hash<String, String>]
67
+ attr_reader :metadata
68
+
69
+ # @return [Aikido::Zen::Actor]
70
+ attr_reader :user
71
+
72
+ # @param metadata [Hash<String, String>]
73
+ # @param metadata [Aikido::Zen::Actor]
74
+ # @return [Aikido::Zen::AttackWave::Attack]
75
+ def initialize(metadata:, user:)
76
+ @metadata = metadata
77
+ @user = user
78
+ end
79
+
80
+ def as_json
81
+ {
82
+ metadata: @metadata.as_json,
83
+ user: @user.as_json
84
+ }.compact
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,52 @@
1
+ module Aikido::Zen
2
+ # Generic background worker class backed by queue. Meant to be used by any
3
+ # background process that needs to do heavy tasks.
4
+ class BackgroundWorker
5
+ # @param block [block] A block that receives 1 message directly from the queue
6
+ def initialize(&block)
7
+ @queue = Queue.new
8
+ @block = block
9
+ end
10
+
11
+ # starts the background thread, blocking the thread until a new messages arrives
12
+ # or the queue is stopped.
13
+ def start
14
+ @thread = Thread.new do
15
+ while running? || actions?
16
+ action = wait_for_action
17
+ @block.call(action) unless action.nil?
18
+ end
19
+ end
20
+ end
21
+
22
+ def restart
23
+ stop
24
+ @queue = Queue.new # re-open the queue
25
+ start
26
+ end
27
+
28
+ # Drain the queue to do not lose any messages
29
+ def stop
30
+ @queue.close # stop accepting messages
31
+ @thread.join # wait for the queue to be drained
32
+ end
33
+
34
+ def enqueue(scan)
35
+ @queue.push(scan)
36
+ end
37
+
38
+ private
39
+
40
+ def actions?
41
+ !@queue.empty?
42
+ end
43
+
44
+ def running?
45
+ !@queue.closed?
46
+ end
47
+
48
+ def wait_for_action
49
+ @queue.pop(false)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ class Cache
5
+ extend Forwardable
6
+
7
+ # @api private
8
+ # Visible for testing.
9
+ def_delegators :@data,
10
+ :size, :empty?
11
+
12
+ def initialize(capacity, default_value = nil, ttl:, clock: nil)
13
+ @default_value = default_value
14
+ @ttl = ttl
15
+ @clock = clock
16
+
17
+ @data = CappedMap.new(capacity, mode: :lru)
18
+ end
19
+
20
+ def key?(key)
21
+ @data.key?(key) && !@data[key].expired?
22
+ end
23
+
24
+ # @param key [Object] the key
25
+ # @param value [Object] the value
26
+ # @return [Object] the value that the key was set to
27
+ def []=(key, value)
28
+ if key?(key)
29
+ entry = @data[key]
30
+ entry.refresh
31
+ entry.value = value
32
+ else
33
+ @data[key] = CacheEntry.new(value, ttl: @ttl, clock: @clock)
34
+ end
35
+ end
36
+
37
+ def [](key)
38
+ if key?(key)
39
+ @data[key].value
40
+ else
41
+ @default_value
42
+ end
43
+ end
44
+
45
+ def delete(key)
46
+ if key?(key)
47
+ @data.delete(key).value
48
+ else
49
+ @data.delete(key)
50
+ nil
51
+ end
52
+ end
53
+
54
+ # @api private
55
+ # Visible for testing.
56
+ def to_a
57
+ @data.map { |key, entry| [key, entry.value] }
58
+ end
59
+
60
+ # @api private
61
+ # Visible for testing.
62
+ def to_h
63
+ to_a.to_h
64
+ end
65
+ end
66
+
67
+ class CacheEntry
68
+ attr_accessor :value
69
+
70
+ DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) }
71
+
72
+ # @param value [Object] the value
73
+ # @param ttl [Integer] the time-to-live in milliseconds
74
+ # @return [Aikido::Zen::CacheEntry]
75
+ def initialize(value, ttl:, clock: nil)
76
+ @value = value
77
+ @ttl = ttl
78
+ @clock = clock || DEFAULT_CLOCK
79
+
80
+ refresh
81
+ end
82
+
83
+ def refresh
84
+ @expires = @clock.call + @ttl
85
+ end
86
+
87
+ def expired?
88
+ @clock.call >= @expires
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Aikido::Zen
6
+ # @api private
7
+ #
8
+ # Provides a FIFO set with a maximum size. Adding an element after the
9
+ # capacity has been reached kicks the oldest element in the set out,
10
+ # while maintaining the uniqueness property of a set (relying on #eql?
11
+ # and #hash).
12
+ class CappedSet
13
+ include Enumerable
14
+ extend Forwardable
15
+
16
+ def_delegators :@data, :size, :empty?
17
+
18
+ # @return [Integer]
19
+ attr_reader :capacity
20
+
21
+ def initialize(capacity, mode: :fifo)
22
+ @data = CappedMap.new(capacity, mode: mode)
23
+ end
24
+
25
+ def <<(element)
26
+ @data[element] = nil
27
+ self
28
+ end
29
+ alias_method :add, :<<
30
+ alias_method :push, :<<
31
+
32
+ def each(&b)
33
+ @data.each_key(&b)
34
+ end
35
+
36
+ def as_json
37
+ map(&:as_json)
38
+ end
39
+ end
40
+
41
+ # @api private
42
+ #
43
+ # Provides a FIFO hash-like structure with a maximum size. Adding a new key
44
+ # after the capacity has been reached kicks the first element pair added out.
45
+ class CappedMap
46
+ include Enumerable
47
+ extend Forwardable
48
+
49
+ def_delegators :@data,
50
+ :delete, :key?,
51
+ :each, :each_key, :each_value,
52
+ :size, :empty?, :to_hash
53
+
54
+ # @return [Integer]
55
+ attr_reader :capacity
56
+
57
+ def initialize(capacity, mode: :fifo)
58
+ raise ArgumentError, "cannot set capacity lower than 1: #{capacity}" if capacity < 1
59
+
60
+ unless [:fifo, :lru].include?(mode)
61
+ raise ArgumentError, "unsupported mode: #{mode}"
62
+ end
63
+
64
+ @capacity = capacity
65
+ @mode = mode
66
+
67
+ @data = {}
68
+ end
69
+
70
+ def []=(key, value)
71
+ @data[key] = value
72
+ @data.delete(@data.each_key.first) if @data.size > @capacity
73
+ end
74
+
75
+ def [](key)
76
+ @data[key] = @data.delete(key) if @mode == :lru && key?(key)
77
+ @data[key]
78
+ end
79
+
80
+ def fetch(key, ...)
81
+ return self[key] if key?(key)
82
+
83
+ @data.fetch(key, ...)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ class Collector
5
+ class Event
6
+ @@registry = {}
7
+
8
+ # @api protected
9
+ def self.register(type)
10
+ const_set(:TYPE, type)
11
+ @@registry[type] = self
12
+ end
13
+
14
+ def self.from_json(data)
15
+ type = data[:type]
16
+ subclass = @@registry[type]
17
+ subclass.from_json(data)
18
+ end
19
+
20
+ attr_reader :type
21
+
22
+ def initialize
23
+ @type = self.class::TYPE
24
+ end
25
+
26
+ def as_json
27
+ {
28
+ type: @type
29
+ }
30
+ end
31
+
32
+ def handle(collector)
33
+ raise NotImplementedError, "implement in subclasses"
34
+ end
35
+ end
36
+
37
+ # @api private
38
+ module Events
39
+ class TrackRequest < Event
40
+ register "track_request"
41
+
42
+ def self.from_json(data)
43
+ new
44
+ end
45
+
46
+ def handle(collector)
47
+ collector.handle_track_request
48
+ end
49
+
50
+ def inspect
51
+ "#<#{self.class.name}>"
52
+ end
53
+ end
54
+
55
+ class TrackAttackWave < Event
56
+ register "track_attack_wave"
57
+
58
+ def self.from_json(data)
59
+ new(
60
+ being_blocked: data[:being_blocked]
61
+ )
62
+ end
63
+
64
+ def initialize(being_blocked:)
65
+ super()
66
+ @being_blocked = being_blocked
67
+ end
68
+
69
+ def as_json
70
+ super.update({
71
+ being_blocked: @being_blocked
72
+ })
73
+ end
74
+
75
+ def handle(collector)
76
+ collector.handle_track_attack_wave(being_blocked: @being_blocked)
77
+ end
78
+
79
+ def inspect
80
+ "#<#{self.class.name} #{@being_blocked}>"
81
+ end
82
+ end
83
+
84
+ class TrackScan < Event
85
+ register "track_scan"
86
+
87
+ def self.from_json(data)
88
+ new(
89
+ data[:sink_name],
90
+ data[:duration],
91
+ has_errors: data[:has_errors]
92
+ )
93
+ end
94
+
95
+ def initialize(sink_name, duration, has_errors:)
96
+ super()
97
+ @sink_name = sink_name
98
+ @duration = duration
99
+ @has_errors = has_errors
100
+ end
101
+
102
+ def as_json
103
+ super.update({
104
+ sink_name: @sink_name,
105
+ duration: @duration,
106
+ has_errors: @has_errors
107
+ })
108
+ end
109
+
110
+ def handle(collector)
111
+ collector.handle_track_scan(@sink_name, @duration, has_errors: @has_errors)
112
+ end
113
+
114
+ def inspect
115
+ "#<#{self.class.name} #{@sink_name} #{format "%0.6f", @duration} #{@has_errors}>"
116
+ end
117
+ end
118
+
119
+ class TrackAttack < Event
120
+ register "track_attack"
121
+
122
+ def self.from_json(data)
123
+ new(
124
+ data[:sink_name],
125
+ being_blocked: data[:being_blocked]
126
+ )
127
+ end
128
+
129
+ def initialize(sink_name, being_blocked:)
130
+ super()
131
+ @sink_name = sink_name
132
+ @being_blocked = being_blocked
133
+ end
134
+
135
+ def as_json
136
+ super.update({
137
+ sink_name: @sink_name,
138
+ being_blocked: @being_blocked
139
+ })
140
+ end
141
+
142
+ def handle(collector)
143
+ collector.handle_track_attack(@sink_name, being_blocked: @being_blocked)
144
+ end
145
+
146
+ def inspect
147
+ "#<#{self.class.name} #{@sink_name} #{@being_blocked}>"
148
+ end
149
+ end
150
+
151
+ class TrackUser < Event
152
+ register "track_user"
153
+
154
+ def self.from_json(data)
155
+ new(Aikido::Zen::Actor.from_json(data[:actor]))
156
+ end
157
+
158
+ def initialize(actor)
159
+ super()
160
+ @actor = actor
161
+ end
162
+
163
+ def as_json
164
+ super.update({
165
+ actor: @actor.as_json
166
+ })
167
+ end
168
+
169
+ def handle(collector)
170
+ collector.handle_track_user(@actor)
171
+ end
172
+
173
+ def inspect
174
+ "#<#{self.class.name} #{@actor.id} #{@actor.name}>"
175
+ end
176
+ end
177
+
178
+ class TrackOutbound < Event
179
+ register "track_outbound"
180
+
181
+ def self.from_json(data)
182
+ new(OutboundConnection.from_json(data[:connection]))
183
+ end
184
+
185
+ def initialize(connection)
186
+ super()
187
+ @connection = connection
188
+ end
189
+
190
+ def as_json
191
+ super.update({
192
+ connection: @connection.as_json
193
+ })
194
+ end
195
+
196
+ def handle(collector)
197
+ collector.handle_track_outbound(@connection)
198
+ end
199
+
200
+ def inspect
201
+ "#<#{self.class.name} #{@connection.host}:#{@connection.port}>"
202
+ end
203
+ end
204
+
205
+ class TrackRoute < Event
206
+ register "track_route"
207
+
208
+ def self.from_json(data)
209
+ new(
210
+ Route.from_json(data[:route]),
211
+ Request::Schema.from_json(data[:schema])
212
+ )
213
+ end
214
+
215
+ def initialize(route, schema)
216
+ super()
217
+ @route = route
218
+ @schema = schema
219
+ end
220
+
221
+ def as_json
222
+ super.update({
223
+ route: @route.as_json,
224
+ schema: @schema.as_json
225
+ })
226
+ end
227
+
228
+ def handle(collector)
229
+ collector.handle_track_route(@route, @schema)
230
+ end
231
+
232
+ def inspect
233
+ "#<#{self.class.name} #{@route.verb} #{@route.path.inspect}>"
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../capped_collections"
4
+
5
+ module Aikido::Zen
6
+ # @api private
7
+ #
8
+ # Keeps track of the hostnames to which the app has made outbound HTTP
9
+ # requests.
10
+ class Collector::Hosts < Aikido::Zen::CappedMap
11
+ def initialize(config = Aikido::Zen.config)
12
+ super(config.max_outbound_connections)
13
+ end
14
+
15
+ # @param host [Aikido::Zen::OutboundConnection]
16
+ # @return [void]
17
+ def add(host)
18
+ self[host] ||= host
19
+ self[host].hit
20
+ end
21
+
22
+ def each(&blk)
23
+ each_value(&blk)
24
+ end
25
+
26
+ def as_json
27
+ map(&:as_json)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../request/schema/empty_schema"
4
+
5
+ module Aikido::Zen
6
+ # @api private
7
+ #
8
+ # Keeps track of the visited routes.
9
+ class Collector::Routes
10
+ # @api private
11
+ # Visible for testing.
12
+ attr_reader :visits
13
+
14
+ def initialize(config = Aikido::Zen.config)
15
+ @config = config
16
+ @visits = Hash.new { |h, k| h[k] = Record.new }
17
+ end
18
+
19
+ # @param route [Aikido::Zen::Route] the route of the request
20
+ # @param schema [Aikido::Zen::Request::Schema] the schema for the request
21
+ # @return [void]
22
+ def add(route, schema)
23
+ @visits[route].increment(schema) unless route.nil?
24
+ end
25
+
26
+ def as_json
27
+ @visits.map do |route, record|
28
+ {
29
+ method: route.verb,
30
+ path: route.path,
31
+ hits: record.hits,
32
+ apispec: record.schema.as_json
33
+ }.compact
34
+ end
35
+ end
36
+
37
+ # @api private
38
+ # Visible for testing.
39
+ def [](route)
40
+ @visits[route]
41
+ end
42
+
43
+ # @api private
44
+ def empty?
45
+ @visits.empty?
46
+ end
47
+
48
+ # @api private
49
+ Record = Struct.new(:hits, :schema, :samples) do
50
+ def initialize(config = Aikido::Zen.config)
51
+ super(0, Aikido::Zen::Request::Schema::EMPTY_SCHEMA, 0)
52
+ @config = config
53
+ end
54
+
55
+ def increment(schema)
56
+ self.hits += 1
57
+
58
+ if sample_schema?
59
+ self.samples += 1
60
+ self.schema |= schema
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def sample_schema?
67
+ samples < @config.api_schema_max_samples
68
+ end
69
+ end
70
+ end
71
+ end