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,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ssrf/private_ip_checker"
4
+ require_relative "ssrf/dns_lookups"
5
+
6
+ module Aikido::Zen
7
+ module Scanners
8
+ class SSRFScanner
9
+ # SSRF attacks can be triggered through external inputs, so it is essential
10
+ # to have a valid context to safeguard against these attacks.
11
+ def self.skips_on_nil_context?
12
+ true
13
+ end
14
+
15
+ # Checks if an outbound HTTP request is to a hostname supplied from user
16
+ # input that resolves to a "dangerous" address. This is called from two
17
+ # different places:
18
+ #
19
+ # * HTTP library sinks, before we make a request. In these cases we can
20
+ # detect very obvious attempts such as a request that attempts to access
21
+ # localhost or an internal IP.
22
+ # * DNS lookup sinks, after we resolve a hostname. For HTTP requests that
23
+ # are not obviously an attack, we let the DNS resolution happen, and
24
+ # then check again, now knowing if the domain name provided actually
25
+ # resolves to an internal IP or not.
26
+ #
27
+ # NOTE: Because not all DNS resolutions might be happening in the context
28
+ # of a protected HTTP request, the +request+ argument below *might* be nil
29
+ # and we can then skip this scan.
30
+ #
31
+ # @param request [Aikido::Zen::Scanners::SSRFScanner::Request, nil]
32
+ # the ongoing outbound HTTP request.
33
+ # @param context [Aikido::Zen::Context]
34
+ # @param sink [Aikido::Zen::Sink] the Sink that is running the scan.
35
+ # @param operation [Symbol, String] name of the method being scanned.
36
+ # Expects +sink.operation+ being set to get the full module/name combo.
37
+ #
38
+ # @return [Aikido::Zen::Attacks::SSRFAttack, nil] an Attack if any user
39
+ # input is detected to be attempting SSRF, or +nil+ if not.
40
+ def self.call(request:, sink:, context:, operation:, **)
41
+ return if request.nil? # See NOTE above.
42
+
43
+ context["ssrf.redirects"] ||= RedirectChains.new
44
+
45
+ context.payloads.each do |payload|
46
+ scanner = new(request.uri, payload.value, context["ssrf.redirects"])
47
+ next unless scanner.attack?
48
+
49
+ attack = Attacks::SSRFAttack.new(
50
+ sink: sink,
51
+ request: request,
52
+ input: payload,
53
+ context: context,
54
+ operation: "#{sink.operation}.#{operation}",
55
+ stack: Aikido::Zen.clean_stack_trace
56
+ )
57
+
58
+ return attack
59
+ end
60
+
61
+ nil
62
+ end
63
+
64
+ # Track the origin of a redirection so we know if an attacker is using
65
+ # redirect chains to mask their use of a (seemingly) safe domain.
66
+ #
67
+ # @param request [Aikido::Zen::Scanners::SSRFScanner::Request]
68
+ # @param response [Aikido::Zen::Scanners::SSRFScanner::Response]
69
+ # @param context [Aikido::Zen::Context]
70
+ #
71
+ # @return [void]
72
+ def self.track_redirects(request:, response:, context: Aikido::Zen.current_context)
73
+ return unless response.redirect?
74
+
75
+ context["ssrf.redirects"] ||= RedirectChains.new
76
+ context["ssrf.redirects"].add(
77
+ source: request.uri,
78
+ destination: response.redirect_to
79
+ )
80
+ end
81
+
82
+ # @api private
83
+ def initialize(request_uri, input, redirects)
84
+ @request_uri = request_uri
85
+ @input = input
86
+ @redirects = redirects
87
+ end
88
+
89
+ # @api private
90
+ def attack?
91
+ return false if @input.nil? || @input.to_s.empty?
92
+
93
+ # If the request is not aimed at an internal IP, we can ignore it. (It
94
+ # might still be an SSRF if defined strictly, but it's unlikely to be
95
+ # exfiltrating data from the app's servers, and the risk for false
96
+ # positives is too high.)
97
+ return false unless private_ip?(@request_uri.hostname)
98
+
99
+ origins_for_request
100
+ .product(uris_from_input)
101
+ .any? { |(conn_uri, candidate)| match?(conn_uri, candidate) }
102
+ end
103
+
104
+ # @!visibility private
105
+ def self.private_ip_checker
106
+ @private_ip_checker ||= SSRF::PrivateIPChecker.new
107
+ end
108
+
109
+ private
110
+
111
+ def match?(conn_uri, input_uri)
112
+ return false if conn_uri.hostname.nil? || conn_uri.hostname.empty?
113
+ return false if input_uri.hostname.nil? || input_uri.hostname.empty?
114
+
115
+ # The URI library will automatically set the port to the default port
116
+ # for the current scheme if not provided, which means we can't just
117
+ # check if the port is present, as it always will be.
118
+ is_port_relevant = input_uri.port != input_uri.default_port
119
+ return false if is_port_relevant && input_uri.port != conn_uri.port
120
+
121
+ conn_uri.hostname == input_uri.hostname &&
122
+ conn_uri.port == input_uri.port
123
+ end
124
+
125
+ def private_ip?(hostname)
126
+ self.class.private_ip_checker.private?(hostname)
127
+ end
128
+
129
+ def origins_for_request
130
+ [@request_uri, @redirects.origin(@request_uri)].compact
131
+ end
132
+
133
+ # Maps the current user input into a Set of URIs we can check against:
134
+ #
135
+ # * The input itself, if it already looks like a URI.
136
+ # * The input prefixed with http://
137
+ # * The input prefixed with https://
138
+ # * The input prefixed with the scheme of the request's URI, to consider
139
+ # things like an FTP request (to "ftp://localhost") with a plain host
140
+ # as a user-input ("localhost").
141
+ #
142
+ # @return [Array<URI>] a list of unique URIs based on the above criteria.
143
+ def uris_from_input
144
+ input = @input.to_s
145
+
146
+ # If you build a URI manually and set the hostname to an IPv6 string,
147
+ # the URI library will be helpful to wrap it in brackets so it's a
148
+ # valid hostname. We should do the same for the input.
149
+ input = format("[%s]", input) if unescaped_ipv6?(input)
150
+
151
+ [
152
+ input,
153
+ "http://#{input}",
154
+ "https://#{input}",
155
+ "#{@request_uri.scheme}://#{input}"
156
+ ].map { |candidate| as_uri(candidate) }.compact.uniq
157
+ end
158
+
159
+ def as_uri(string)
160
+ URI(string)
161
+ rescue URI::InvalidURIError
162
+ nil
163
+ end
164
+
165
+ # Check if the input is an IPv6 that is not surrounded by square brackets.
166
+ def unescaped_ipv6?(input)
167
+ (
168
+ IPAddr::RE_IPV6ADDRLIKE_FULL.match?(input) ||
169
+ IPAddr::RE_IPV6ADDRLIKE_COMPRESSED.match?(input)
170
+ ) && !(input.start_with?("[") && input.end_with?("]"))
171
+ end
172
+
173
+ # @api private
174
+ module Headers
175
+ # @param headers [Hash<String, Object>]
176
+ # @param header_normalizer [Proc{Object => String}]
177
+ def initialize(headers:, header_normalizer: :to_s.to_proc)
178
+ @headers = headers.to_h
179
+ @header_normalizer = header_normalizer
180
+ @normalized_headers = false
181
+ end
182
+
183
+ # @return [Hash<String, String>]
184
+ def headers
185
+ return @headers if @normalized_headers
186
+
187
+ @headers
188
+ .transform_keys!(&:downcase)
189
+ .transform_values!(&@header_normalizer)
190
+ .tap { @normalized_headers = true }
191
+ end
192
+ end
193
+
194
+ # @api private
195
+ class Request
196
+ include Headers
197
+
198
+ attr_reader :verb
199
+ attr_reader :uri
200
+
201
+ def initialize(verb:, uri:, **header_options)
202
+ super(**header_options)
203
+ @verb = verb.to_s.upcase
204
+ @uri = URI(uri)
205
+ end
206
+
207
+ def to_s
208
+ [@verb, @uri.to_s].join(" ").strip
209
+ end
210
+ end
211
+
212
+ # @api private
213
+ class Response
214
+ include Headers
215
+
216
+ attr_reader :status
217
+
218
+ def initialize(status:, **header_options)
219
+ super(**header_options)
220
+ @status = status.to_s
221
+ end
222
+
223
+ def redirect?
224
+ @status.start_with?("3") && headers["location"]
225
+ end
226
+
227
+ def redirect_to
228
+ URI(headers["location"]) if redirect?
229
+ rescue URI::BadURIError
230
+ nil
231
+ end
232
+ end
233
+
234
+ # @api private
235
+ class RedirectChains
236
+ def initialize
237
+ @redirects = Hash.new { |h, k| h[k] = [] }
238
+ end
239
+
240
+ def add(source:, destination:)
241
+ @redirects[destination].push(source)
242
+ self
243
+ end
244
+
245
+ # Recursively looks for the original URI that triggered the current
246
+ # chain. If given a URI that was not the result of a redirect chain, it
247
+ # returns +nil+
248
+ #
249
+ # @param uri [URI]
250
+ # @return [URI, nil]
251
+ def origin(uri, visited = Set.new)
252
+ source = @redirects[uri].first
253
+
254
+ return source if visited.include?(source)
255
+ visited << source
256
+
257
+ if !@redirects[source].empty?
258
+ origin(source, visited)
259
+ else
260
+ source
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Scanners
5
+ # Inspects the result of DNS lookups, to determine if we're being the target
6
+ # of a stored SSRF targeting IMDS addresses (169.254.169.254).
7
+ class StoredSSRFScanner
8
+ # Stored-SSRF can occur without external input, so we do not require a
9
+ # context to determine if an attack is happening.
10
+ def self.skips_on_nil_context?
11
+ false
12
+ end
13
+
14
+ def self.call(hostname:, addresses:, operation:, sink:, context:, **opts)
15
+ offending_address = new(hostname, addresses).attack?
16
+ return if offending_address.nil?
17
+
18
+ Attacks::StoredSSRFAttack.new(
19
+ hostname: hostname,
20
+ address: offending_address,
21
+ sink: sink,
22
+ context: context,
23
+ operation: "#{sink.operation}.#{operation}",
24
+ stack: Aikido::Zen.clean_stack_trace
25
+ )
26
+ end
27
+
28
+ def initialize(hostname, addresses, config: Aikido::Zen.config)
29
+ @hostname = hostname
30
+ @addresses = addresses
31
+ @config = config
32
+ end
33
+
34
+ # @return [String, nil] either the offending address, or +nil+ if no
35
+ # address is deemed dangerous.
36
+ def attack?
37
+ return unless @config.stored_ssrf? # Feature flag
38
+
39
+ return if @config.imds_allowed_hosts.include?(@hostname)
40
+
41
+ @addresses.find do |candidate|
42
+ DANGEROUS_ADDRESSES.any? { |address| address === candidate }
43
+ end
44
+ end
45
+
46
+ DANGEROUS_ADDRESSES = [
47
+ IPAddr.new("169.254.169.254"),
48
+ IPAddr.new("100.100.100.200"),
49
+ IPAddr.new("::ffff:169.254.169.254"),
50
+ IPAddr.new("::ffff:100.100.100.200"),
51
+ IPAddr.new("fd00:ec2::254")
52
+ ]
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scanners/sql_injection_scanner"
4
+ require_relative "scanners/stored_ssrf_scanner"
5
+ require_relative "scanners/ssrf_scanner"
6
+ require_relative "scanners/path_traversal_scanner"
7
+ require_relative "scanners/shell_injection_scanner"
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "scan"
4
+
5
+ module Aikido::Zen
6
+ module Sinks
7
+ # @api private
8
+ # @return [Hash<String, Sink>]
9
+ def self.registry
10
+ @registry ||= {}
11
+ end
12
+
13
+ # Primary interface to set up a sink with a list of given scanners.
14
+ #
15
+ # @param name [String] name of the library being patched. (This must
16
+ # match the name of the gem, or we won't report that gem as
17
+ # supported.)
18
+ # @param scanners [Array<#call>] a list of objects that respond to
19
+ # #call with a Hash and return an Attack or nil.
20
+ # @param opts [Hash<Symbol, Object>] any other options to pass to
21
+ # the Sink initializer.
22
+ #
23
+ # @return [void]
24
+ # @raise [ArgumentError] if a Sink with this name has already been
25
+ # registered.
26
+ def self.add(name, scanners:, **opts)
27
+ raise ArgumentError, "Sink #{name} already registered" if registry.key?(name.to_s)
28
+ registry[name.to_s] = Sink.new(name.to_s, scanners: scanners, **opts)
29
+ end
30
+ end
31
+
32
+ # Sinks serve as the proxies between a given library that we protect
33
+ # (such as a database adapter that we patch to prevent SQL injections)
34
+ # and the reporting agent.
35
+ #
36
+ # When a library is patched to track and potentially block attacks, we
37
+ # rely on a sink to run any scans required, and report any attacks to
38
+ # our agent.
39
+ #
40
+ # @see ./sinks/trilogy.rb for a reference implementation.
41
+ class Sink
42
+ # @return [String] name of the patched library (e.g. "mysql2").
43
+ attr_reader :name
44
+
45
+ # @return [Array<#call>] list of registered scanners for this sink.
46
+ attr_reader :scanners
47
+
48
+ # @return [String] descriptor of the module / method being scanned
49
+ # for attacks. This is fed into Attacks when instantiated. In
50
+ # certain cases, some scanners allow you to specialize this by
51
+ # using an +operation+ param of their own.
52
+ attr_reader :operation
53
+
54
+ DEFAULT_REPORTER = ->(scan) { Aikido::Zen.track_scan(scan) }
55
+
56
+ def initialize(name, scanners:, operation: name, reporter: DEFAULT_REPORTER)
57
+ raise ArgumentError, "scanners cannot be empty" if scanners.empty?
58
+
59
+ @name = name
60
+ @operation = operation
61
+ @scanners = scanners
62
+ @reporter = reporter
63
+ end
64
+
65
+ # Run the given arguments through all the registered scanners, until
66
+ # one of them returns an Attack or all return +nil+, and report the
67
+ # findings back to the Sink's +reporter+ to track statistics and
68
+ # potentially handle the +Attack+, if anything.
69
+ #
70
+ # This checks if runtime protection has been turned off for the current
71
+ # route first, and if so skips the scanning altogether, returning nil.
72
+ #
73
+ # @param scan_params [Hash] data to pass to all registered scanners.
74
+ # @option scan_params [Aikido::Zen::Context, nil] :context
75
+ # The current Context, including the HTTP request being inspected, or
76
+ # +nil+ if we're scanning outside of an HTTP request.
77
+ #
78
+ # @return [Aikido::Zen::Scan, nil] the result of the scan, or +nil+ if the
79
+ # scan was skipped due to protection being disabled for the current route.
80
+ # @raise [Aikido::UnderAttackError] if an attack is detected and
81
+ # blocking_mode is enabled.
82
+ def scan(context: Aikido::Zen.current_context, **scan_params)
83
+ return if context&.scanning
84
+ context&.scanning = true
85
+
86
+ return if context&.protection_disabled?
87
+
88
+ scan = Scan.new(sink: self, context: context)
89
+
90
+ scans_performed = 0
91
+ scan.perform do
92
+ result = nil
93
+
94
+ scanners.each do |scanner|
95
+ next if scanner.skips_on_nil_context? && context.nil?
96
+
97
+ result = scanner.call(sink: self, context: context, **scan_params)
98
+ scans_performed += 1
99
+
100
+ break result if result
101
+ rescue Aikido::Zen::InternalsError => error
102
+ Aikido::Zen.config.logger.warn(error.message)
103
+ scan.track_error(error, scanner)
104
+ rescue => error
105
+ scan.track_error(error, scanner)
106
+ end
107
+
108
+ result
109
+ end
110
+
111
+ @reporter.call(scan) if scans_performed > 0
112
+
113
+ scan
114
+ ensure
115
+ context&.scanning = false
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Sinks
5
+ module ActionController
6
+ # Implements the "middleware" for blocking requests (i.e.: rate-limiting or blocking
7
+ # user/bots) in Rails apps, where we need to check at the end of the `before_action`
8
+ # chain, rather than in an actual Rack middleware, to allow for calls to
9
+ # `Zen.track_user` being made from before_actions in the host app, thus allowing
10
+ # block/rate-limit by user ID rather than solely by IP.
11
+ class BlockRequestChecker
12
+ def initialize(
13
+ config: Aikido::Zen.config,
14
+ settings: Aikido::Zen.runtime_settings,
15
+ detached_agent: Aikido::Zen.detached_agent
16
+ )
17
+ @config = config
18
+ @settings = settings
19
+ @detached_agent = detached_agent
20
+ end
21
+
22
+ def block?(controller)
23
+ context = controller.request.env[Aikido::Zen::ENV_KEY]
24
+ request = context.request
25
+
26
+ if should_block_user?(request)
27
+ status, headers, body = @config.blocked_responder.call(request, :user)
28
+ controller.headers.update(headers)
29
+ controller.render plain: Array(body).join, status: status
30
+
31
+ return true
32
+ end
33
+
34
+ if should_throttle?(request)
35
+ status, headers, body = @config.rate_limited_responder.call(request)
36
+ controller.headers.update(headers)
37
+ controller.render plain: Array(body).join, status: status
38
+
39
+ return true
40
+ end
41
+
42
+ false
43
+ end
44
+
45
+ private def should_throttle?(request)
46
+ # Bypass rate limiting for allowed IPs
47
+ return false if @settings.allowed_ips.include?(request.ip)
48
+
49
+ return false unless @settings.endpoints[request.route].rate_limiting.enabled?
50
+
51
+ result = @detached_agent.calculate_rate_limits(request)
52
+ return false unless result
53
+
54
+ request.env["aikido.rate_limiting"] = result
55
+ request.env["aikido.rate_limiting"].throttled?
56
+ end
57
+
58
+ # @param request [Aikido::Zen::Request]
59
+ private def should_block_user?(request)
60
+ return false if request.actor.nil?
61
+
62
+ Aikido::Zen.runtime_settings.blocked_user_ids&.include?(request.actor.id)
63
+ end
64
+ end
65
+
66
+ def self.block_request_checker
67
+ @block_request_checker ||= Aikido::Zen::Sinks::ActionController::BlockRequestChecker.new
68
+ end
69
+
70
+ module Extensions
71
+ def run_callbacks(kind, *)
72
+ return super unless kind == :process_action
73
+
74
+ super do
75
+ checker = Aikido::Zen::Sinks::ActionController.block_request_checker
76
+
77
+ yield if block_given? && !checker.block?(self)
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ ::AbstractController::Callbacks.prepend(Aikido::Zen::Sinks::ActionController::Extensions)
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../scanners/ssrf_scanner"
4
+ require_relative "../outbound_connection_monitor"
5
+
6
+ module Aikido::Zen
7
+ module Sinks
8
+ module Async
9
+ module HTTP
10
+ SINK = Sinks.add("async-http", scanners: [
11
+ Scanners::SSRFScanner,
12
+ OutboundConnectionMonitor
13
+ ])
14
+
15
+ module Helpers
16
+ def self.scan(request, connection, operation)
17
+ SINK.scan(
18
+ request: request,
19
+ connection: connection,
20
+ operation: operation
21
+ )
22
+ end
23
+ end
24
+
25
+ def self.load_sinks!
26
+ if Aikido::Zen.satisfy "async-http", ">= 0.70.0"
27
+ require "async/http"
28
+
29
+ ::Async::HTTP::Client.class_eval do
30
+ extend Sinks::DSL
31
+
32
+ sink_around :call do |original_call, request|
33
+ uri = URI(format("%<scheme>s://%<authority>s%<path>s", {
34
+ scheme: request.scheme || scheme,
35
+ authority: request.authority || authority,
36
+ path: request.path
37
+ }))
38
+
39
+ wrapped_request = Scanners::SSRFScanner::Request.new(
40
+ verb: request.method,
41
+ uri: uri,
42
+ headers: request.headers.to_h,
43
+ header_normalizer: ->(value) { Array(value).join(", ") }
44
+ )
45
+
46
+ # Store the request information so the DNS sinks can pick it up.
47
+ context = Aikido::Zen.current_context
48
+ if context
49
+ prev_request = context["ssrf.request"]
50
+ context["ssrf.request"] = wrapped_request
51
+ end
52
+
53
+ connection = OutboundConnection.from_uri(uri)
54
+
55
+ Helpers.scan(wrapped_request, connection, "request")
56
+
57
+ response = original_call.call
58
+
59
+ Scanners::SSRFScanner.track_redirects(
60
+ request: wrapped_request,
61
+ response: Scanners::SSRFScanner::Response.new(
62
+ status: response.status,
63
+ headers: response.headers.to_h,
64
+ header_normalizer: ->(value) { Array(value).join(", ") }
65
+ )
66
+ )
67
+
68
+ response
69
+ ensure
70
+ context["ssrf.request"] = prev_request if context
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ Aikido::Zen::Sinks::Async::HTTP.load_sinks!