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.
- checksums.yaml +7 -0
- data/.aikido +6 -0
- data/.ruby-version +1 -0
- data/.simplecov +32 -0
- data/.standard.yml +3 -0
- data/LICENSE +674 -0
- data/README.md +148 -0
- data/Rakefile +67 -0
- data/benchmarks/README.md +22 -0
- data/benchmarks/rails7.1_benchmark.js +1 -0
- data/benchmarks/rails7.1_sql_injection.js +102 -0
- data/docs/banner.svg +202 -0
- data/docs/config.md +133 -0
- data/docs/proxy.md +10 -0
- data/docs/rails.md +112 -0
- data/docs/troubleshooting.md +62 -0
- data/lib/aikido/zen/actor.rb +146 -0
- data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
- data/lib/aikido/zen/agent.rb +181 -0
- data/lib/aikido/zen/api_client.rb +145 -0
- data/lib/aikido/zen/attack.rb +217 -0
- data/lib/aikido/zen/attack_wave/helpers.rb +457 -0
- data/lib/aikido/zen/attack_wave.rb +88 -0
- data/lib/aikido/zen/background_worker.rb +52 -0
- data/lib/aikido/zen/cache.rb +91 -0
- data/lib/aikido/zen/capped_collections.rb +86 -0
- data/lib/aikido/zen/collector/event.rb +238 -0
- data/lib/aikido/zen/collector/hosts.rb +30 -0
- data/lib/aikido/zen/collector/routes.rb +71 -0
- data/lib/aikido/zen/collector/sink_stats.rb +95 -0
- data/lib/aikido/zen/collector/stats.rb +122 -0
- data/lib/aikido/zen/collector/users.rb +32 -0
- data/lib/aikido/zen/collector.rb +223 -0
- data/lib/aikido/zen/config.rb +312 -0
- data/lib/aikido/zen/context/rack_request.rb +27 -0
- data/lib/aikido/zen/context/rails_request.rb +47 -0
- data/lib/aikido/zen/context.rb +145 -0
- data/lib/aikido/zen/detached_agent/agent.rb +79 -0
- data/lib/aikido/zen/detached_agent/front_object.rb +41 -0
- data/lib/aikido/zen/detached_agent/server.rb +78 -0
- data/lib/aikido/zen/detached_agent.rb +2 -0
- data/lib/aikido/zen/errors.rb +107 -0
- data/lib/aikido/zen/event.rb +116 -0
- data/lib/aikido/zen/helpers.rb +24 -0
- data/lib/aikido/zen/internals.rb +123 -0
- data/lib/aikido/zen/libzen-v0.1.48-aarch64-linux.so +0 -0
- data/lib/aikido/zen/middleware/allowed_address_checker.rb +26 -0
- data/lib/aikido/zen/middleware/attack_wave_protector.rb +46 -0
- data/lib/aikido/zen/middleware/context_setter.rb +26 -0
- data/lib/aikido/zen/middleware/fork_detector.rb +23 -0
- data/lib/aikido/zen/middleware/middleware.rb +11 -0
- data/lib/aikido/zen/middleware/rack_throttler.rb +50 -0
- data/lib/aikido/zen/middleware/request_tracker.rb +197 -0
- data/lib/aikido/zen/outbound_connection.rb +62 -0
- data/lib/aikido/zen/outbound_connection_monitor.rb +23 -0
- data/lib/aikido/zen/package.rb +22 -0
- data/lib/aikido/zen/payload.rb +50 -0
- data/lib/aikido/zen/rails_engine.rb +53 -0
- data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
- data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
- data/lib/aikido/zen/rate_limiter/result.rb +31 -0
- data/lib/aikido/zen/rate_limiter.rb +50 -0
- data/lib/aikido/zen/request/heuristic_router.rb +115 -0
- data/lib/aikido/zen/request/rails_router.rb +92 -0
- data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
- data/lib/aikido/zen/request/schema/auth_schemas.rb +54 -0
- data/lib/aikido/zen/request/schema/builder.rb +121 -0
- data/lib/aikido/zen/request/schema/definition.rb +107 -0
- data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
- data/lib/aikido/zen/request/schema.rb +87 -0
- data/lib/aikido/zen/request.rb +88 -0
- data/lib/aikido/zen/route.rb +96 -0
- data/lib/aikido/zen/runtime_settings/endpoints.rb +78 -0
- data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
- data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
- data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
- data/lib/aikido/zen/runtime_settings.rb +66 -0
- data/lib/aikido/zen/scan.rb +75 -0
- data/lib/aikido/zen/scanners/path_traversal/helpers.rb +68 -0
- data/lib/aikido/zen/scanners/path_traversal_scanner.rb +64 -0
- data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
- data/lib/aikido/zen/scanners/shell_injection_scanner.rb +65 -0
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +94 -0
- data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
- data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +97 -0
- data/lib/aikido/zen/scanners/ssrf_scanner.rb +266 -0
- data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +55 -0
- data/lib/aikido/zen/scanners.rb +7 -0
- data/lib/aikido/zen/sink.rb +118 -0
- data/lib/aikido/zen/sinks/action_controller.rb +85 -0
- data/lib/aikido/zen/sinks/async_http.rb +80 -0
- data/lib/aikido/zen/sinks/curb.rb +113 -0
- data/lib/aikido/zen/sinks/em_http.rb +83 -0
- data/lib/aikido/zen/sinks/excon.rb +118 -0
- data/lib/aikido/zen/sinks/file.rb +153 -0
- data/lib/aikido/zen/sinks/http.rb +93 -0
- data/lib/aikido/zen/sinks/httpclient.rb +95 -0
- data/lib/aikido/zen/sinks/httpx.rb +78 -0
- data/lib/aikido/zen/sinks/kernel.rb +33 -0
- data/lib/aikido/zen/sinks/mysql2.rb +31 -0
- data/lib/aikido/zen/sinks/net_http.rb +101 -0
- data/lib/aikido/zen/sinks/patron.rb +103 -0
- data/lib/aikido/zen/sinks/pg.rb +72 -0
- data/lib/aikido/zen/sinks/resolv.rb +62 -0
- data/lib/aikido/zen/sinks/socket.rb +85 -0
- data/lib/aikido/zen/sinks/sqlite3.rb +46 -0
- data/lib/aikido/zen/sinks/trilogy.rb +31 -0
- data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
- data/lib/aikido/zen/sinks.rb +36 -0
- data/lib/aikido/zen/sinks_dsl.rb +250 -0
- data/lib/aikido/zen/synchronizable.rb +24 -0
- data/lib/aikido/zen/system_info.rb +80 -0
- data/lib/aikido/zen/version.rb +10 -0
- data/lib/aikido/zen/worker.rb +87 -0
- data/lib/aikido/zen.rb +303 -0
- data/lib/aikido-zen.rb +3 -0
- data/placeholder/.gitignore +4 -0
- data/placeholder/README.md +11 -0
- data/placeholder/Rakefile +75 -0
- data/placeholder/lib/placeholder.rb.template +3 -0
- data/placeholder/placeholder.gemspec.template +20 -0
- data/tasklib/bench.rake +94 -0
- data/tasklib/libzen.rake +133 -0
- data/tasklib/wrk.rb +88 -0
- 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!
|