aikido-zen 1.0.2.beta.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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/.aikido +6 -0
  3. data/.ruby-version +1 -0
  4. data/.simplecov +26 -0
  5. data/.standard.yml +3 -0
  6. data/LICENSE +674 -0
  7. data/README.md +146 -0
  8. data/Rakefile +67 -0
  9. data/benchmarks/README.md +23 -0
  10. data/benchmarks/rails7.1_sql_injection.js +70 -0
  11. data/docs/banner.svg +202 -0
  12. data/docs/config.md +125 -0
  13. data/docs/proxy.md +10 -0
  14. data/docs/rails.md +114 -0
  15. data/lib/aikido/zen/actor.rb +116 -0
  16. data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
  17. data/lib/aikido/zen/agent.rb +179 -0
  18. data/lib/aikido/zen/api_client.rb +145 -0
  19. data/lib/aikido/zen/attack.rb +207 -0
  20. data/lib/aikido/zen/background_worker.rb +52 -0
  21. data/lib/aikido/zen/capped_collections.rb +68 -0
  22. data/lib/aikido/zen/collector/hosts.rb +15 -0
  23. data/lib/aikido/zen/collector/routes.rb +66 -0
  24. data/lib/aikido/zen/collector/sink_stats.rb +95 -0
  25. data/lib/aikido/zen/collector/stats.rb +111 -0
  26. data/lib/aikido/zen/collector/users.rb +30 -0
  27. data/lib/aikido/zen/collector.rb +144 -0
  28. data/lib/aikido/zen/config.rb +282 -0
  29. data/lib/aikido/zen/context/rack_request.rb +24 -0
  30. data/lib/aikido/zen/context/rails_request.rb +44 -0
  31. data/lib/aikido/zen/context.rb +112 -0
  32. data/lib/aikido/zen/detached_agent/agent.rb +78 -0
  33. data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
  34. data/lib/aikido/zen/detached_agent/server.rb +78 -0
  35. data/lib/aikido/zen/detached_agent.rb +2 -0
  36. data/lib/aikido/zen/errors.rb +107 -0
  37. data/lib/aikido/zen/event.rb +71 -0
  38. data/lib/aikido/zen/internals.rb +103 -0
  39. data/lib/aikido/zen/libzen-v0.1.39-aarch64-linux.so +0 -0
  40. data/lib/aikido/zen/middleware/check_allowed_addresses.rb +26 -0
  41. data/lib/aikido/zen/middleware/middleware.rb +11 -0
  42. data/lib/aikido/zen/middleware/rack_throttler.rb +48 -0
  43. data/lib/aikido/zen/middleware/request_tracker.rb +192 -0
  44. data/lib/aikido/zen/middleware/set_context.rb +26 -0
  45. data/lib/aikido/zen/outbound_connection.rb +45 -0
  46. data/lib/aikido/zen/outbound_connection_monitor.rb +23 -0
  47. data/lib/aikido/zen/package.rb +22 -0
  48. data/lib/aikido/zen/payload.rb +50 -0
  49. data/lib/aikido/zen/rails_engine.rb +56 -0
  50. data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
  51. data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
  52. data/lib/aikido/zen/rate_limiter/result.rb +31 -0
  53. data/lib/aikido/zen/rate_limiter.rb +50 -0
  54. data/lib/aikido/zen/request/heuristic_router.rb +115 -0
  55. data/lib/aikido/zen/request/rails_router.rb +77 -0
  56. data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
  57. data/lib/aikido/zen/request/schema/auth_schemas.rb +54 -0
  58. data/lib/aikido/zen/request/schema/builder.rb +121 -0
  59. data/lib/aikido/zen/request/schema/definition.rb +107 -0
  60. data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
  61. data/lib/aikido/zen/request/schema.rb +87 -0
  62. data/lib/aikido/zen/request.rb +122 -0
  63. data/lib/aikido/zen/route.rb +39 -0
  64. data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
  65. data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
  66. data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
  67. data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
  68. data/lib/aikido/zen/runtime_settings.rb +65 -0
  69. data/lib/aikido/zen/scan.rb +75 -0
  70. data/lib/aikido/zen/scanners/path_traversal/helpers.rb +65 -0
  71. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +63 -0
  72. data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
  73. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +64 -0
  74. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +93 -0
  75. data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
  76. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +97 -0
  77. data/lib/aikido/zen/scanners/ssrf_scanner.rb +265 -0
  78. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +49 -0
  79. data/lib/aikido/zen/scanners.rb +7 -0
  80. data/lib/aikido/zen/sink.rb +118 -0
  81. data/lib/aikido/zen/sinks/action_controller.rb +83 -0
  82. data/lib/aikido/zen/sinks/async_http.rb +80 -0
  83. data/lib/aikido/zen/sinks/curb.rb +113 -0
  84. data/lib/aikido/zen/sinks/em_http.rb +83 -0
  85. data/lib/aikido/zen/sinks/excon.rb +118 -0
  86. data/lib/aikido/zen/sinks/file.rb +112 -0
  87. data/lib/aikido/zen/sinks/http.rb +93 -0
  88. data/lib/aikido/zen/sinks/httpclient.rb +95 -0
  89. data/lib/aikido/zen/sinks/httpx.rb +78 -0
  90. data/lib/aikido/zen/sinks/kernel.rb +33 -0
  91. data/lib/aikido/zen/sinks/mysql2.rb +31 -0
  92. data/lib/aikido/zen/sinks/net_http.rb +101 -0
  93. data/lib/aikido/zen/sinks/patron.rb +103 -0
  94. data/lib/aikido/zen/sinks/pg.rb +72 -0
  95. data/lib/aikido/zen/sinks/resolv.rb +62 -0
  96. data/lib/aikido/zen/sinks/socket.rb +78 -0
  97. data/lib/aikido/zen/sinks/sqlite3.rb +46 -0
  98. data/lib/aikido/zen/sinks/trilogy.rb +31 -0
  99. data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
  100. data/lib/aikido/zen/sinks.rb +36 -0
  101. data/lib/aikido/zen/sinks_dsl.rb +250 -0
  102. data/lib/aikido/zen/synchronizable.rb +24 -0
  103. data/lib/aikido/zen/system_info.rb +84 -0
  104. data/lib/aikido/zen/version.rb +10 -0
  105. data/lib/aikido/zen/worker.rb +87 -0
  106. data/lib/aikido/zen.rb +246 -0
  107. data/lib/aikido-zen.rb +3 -0
  108. data/placeholder/.gitignore +4 -0
  109. data/placeholder/README.md +11 -0
  110. data/placeholder/Rakefile +75 -0
  111. data/placeholder/lib/placeholder.rb.template +3 -0
  112. data/placeholder/placeholder.gemspec.template +20 -0
  113. data/tasklib/bench.rake +94 -0
  114. data/tasklib/libzen.rake +133 -0
  115. data/tasklib/wrk.rb +88 -0
  116. metadata +205 -0
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../attack"
4
+ require_relative "../internals"
5
+
6
+ module Aikido::Zen
7
+ module Scanners
8
+ class SQLInjectionScanner
9
+ def self.skips_on_nil_context?
10
+ true
11
+ end
12
+
13
+ # Checks if the given SQL query may have dangerous user input injected,
14
+ # and returns an Attack if so, based on the current request.
15
+ #
16
+ # @param query [String]
17
+ # @param context [Aikido::Zen::Context]
18
+ # @param sink [Aikido::Zen::Sink] the Sink that is running the scan.
19
+ # @param dialect [Symbol] one of +:mysql+, +:postgesql+, or +:sqlite+.
20
+ # @param operation [Symbol, String] name of the method being scanned.
21
+ # Expects +sink.operation+ being set to get the full module/name combo.
22
+ #
23
+ # @return [Aikido::Zen::Attack, nil] an Attack if any user input is
24
+ # detected to be attempting a SQL injection, or nil if this is safe.
25
+ #
26
+ # @raise [Aikido::Zen::InternalsError] if an error occurs when loading or
27
+ # calling zenlib. See Sink#scan.
28
+ def self.call(query:, dialect:, sink:, context:, operation:)
29
+ dialect = DIALECTS.fetch(dialect) do
30
+ Aikido::Zen.config.logger.warn "Unknown SQL dialect #{dialect.inspect}"
31
+ DIALECTS[:common]
32
+ end
33
+
34
+ context.payloads.each do |payload|
35
+ next unless new(query, payload.value, dialect).attack?
36
+
37
+ return Attacks::SQLInjectionAttack.new(
38
+ sink: sink,
39
+ query: query,
40
+ input: payload,
41
+ dialect: dialect,
42
+ context: context,
43
+ operation: "#{sink.operation}.#{operation}"
44
+ )
45
+ end
46
+
47
+ nil
48
+ end
49
+
50
+ def initialize(query, input, dialect)
51
+ @query = query.downcase
52
+ @input = input.downcase
53
+ @dialect = dialect
54
+ end
55
+
56
+ def attack?
57
+ # Ignore single char inputs since they shouldn't be able to do much harm
58
+ return false if @input.length <= 1
59
+
60
+ # If the input is longer than the query, then it is not part of it
61
+ return false if @input.length > @query.length
62
+
63
+ # If the input is not included in the query at all, then we are safe
64
+ return false unless @query.include?(@input)
65
+
66
+ # If the input is solely alphanumeric, we can ignore it
67
+ return false if /\A[[:alnum:]_]+\z/i.match?(@input)
68
+
69
+ # If the input is a comma-separated list of numbers, ignore it.
70
+ return false if /\A(?:\d+(?:,\s*)?)+\z/i.match?(@input)
71
+
72
+ Internals.detect_sql_injection(@query, @input, @dialect)
73
+ end
74
+
75
+ # @api private
76
+ Dialect = Struct.new(:name, :internals_key, keyword_init: true) do
77
+ alias_method :to_s, :name
78
+ alias_method :to_int, :internals_key
79
+ end
80
+
81
+ # Maps easy-to-use Symbols to a struct that keeps both the name and the
82
+ # internal identifier used by libzen.
83
+ #
84
+ # @see https://github.com/AikidoSec/zen-internals/blob/main/src/sql_injection/helpers/select_dialect_based_on_enum.rs
85
+ DIALECTS = {
86
+ common: Dialect.new(name: "SQL", internals_key: 0),
87
+ mysql: Dialect.new(name: "MySQL", internals_key: 8),
88
+ postgresql: Dialect.new(name: "PostgreSQL", internals_key: 9),
89
+ sqlite: Dialect.new(name: "SQLite", internals_key: 12)
90
+ }
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Aikido::Zen
6
+ module Scanners
7
+ module SSRF
8
+ # Simple per-request cache of all DNS lookups performed for a given host.
9
+ # We can store this in the context after performing a lookup, and have the
10
+ # SSRF scanner make sure the hostname being inspected doesn't actually
11
+ # resolve to an internal/dangerous IP.
12
+ class DNSLookups < SimpleDelegator
13
+ def initialize
14
+ super(Hash.new { |h, k| h[k] = [] })
15
+ end
16
+
17
+ def add(hostname, addresses)
18
+ self[hostname].concat(Array(addresses))
19
+ end
20
+
21
+ def ===(hostname)
22
+ key?(hostname)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "resolv"
4
+ require "ipaddr"
5
+
6
+ module Aikido::Zen
7
+ module Scanners
8
+ module SSRF
9
+ # Little helper to check if a given hostname or address is to be
10
+ # considered "dangerous" when used for an outbound HTTP request.
11
+ #
12
+ # When given a hostname:
13
+ #
14
+ # * If any DNS lookups have been performed and stored in the current Zen
15
+ # context (under the "dns.lookups" metadata key), we will map it to the
16
+ # list of IPs that we've resolved it to.
17
+ #
18
+ # * If not, we'll still try to map it to any statically defined address in
19
+ # the system hosts file (e.g. /etc/hosts).
20
+ #
21
+ # Once we mapped the hostname to an IP address (or, if given an IP
22
+ # address), this will check that it's not a loopback address, a private IP
23
+ # address (as defined by RFCs 1918 and 4193), or in one of the
24
+ # "special-use" IP ranges defined in RFC 5735.
25
+ class PrivateIPChecker
26
+ def initialize(resolver = Resolv::Hosts.new)
27
+ @resolver = resolver
28
+ end
29
+
30
+ # @param hostname_or_address [String]
31
+ # @return [Boolean]
32
+ def private?(hostname_or_address)
33
+ resolve(hostname_or_address).any? do |ip|
34
+ PRIVATE_RANGES.any? { |range| range === ip }
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ # Source: https://github.com/AikidoSec/firewall-node/blob/main/library/vulnerabilities/ssrf/isPrivateIP.ts
41
+ PRIVATE_IPV4_RANGES = [
42
+ IPAddr.new("0.0.0.0/8"), # "This" network (RFC 1122)
43
+ IPAddr.new("10.0.0.0/8"), # Private-Use Networks (RFC 1918)
44
+ IPAddr.new("100.64.0.0/10"), # Shared Address Space (RFC 6598)
45
+ IPAddr.new("127.0.0.0/8"), # Loopback (RFC 1122)
46
+ IPAddr.new("169.254.0.0/16"), # Link Local (RFC 3927)
47
+ IPAddr.new("172.16.0.0/12"), # Private-Use Networks (RFC 1918)
48
+ IPAddr.new("192.0.0.0/24"), # IETF Protocol Assignments (RFC 5736)
49
+ IPAddr.new("192.0.2.0/24"), # TEST-NET-1 (RFC 5737)
50
+ IPAddr.new("192.31.196.0/24"), # AS112 Redirection Anycast (RFC 7535)
51
+ IPAddr.new("192.52.193.0/24"), # Automatic Multicast Tunneling (RFC 7450)
52
+ IPAddr.new("192.88.99.0/24"), # 6to4 Relay Anycast (RFC 3068)
53
+ IPAddr.new("192.168.0.0/16"), # Private-Use Networks (RFC 1918)
54
+ IPAddr.new("192.175.48.0/24"), # AS112 Redirection Anycast (RFC 7535)
55
+ IPAddr.new("198.18.0.0/15"), # Network Interconnect Device Benchmark Testing (RFC 2544)
56
+ IPAddr.new("198.51.100.0/24"), # TEST-NET-2 (RFC 5737)
57
+ IPAddr.new("203.0.113.0/24"), # TEST-NET-3 (RFC 5737)
58
+ IPAddr.new("224.0.0.0/4"), # Multicast (RFC 3171)
59
+ IPAddr.new("240.0.0.0/4"), # Reserved for Future Use (RFC 1112)
60
+ IPAddr.new("255.255.255.255/32") # Limited Broadcast (RFC 919)
61
+ ]
62
+
63
+ PRIVATE_IPV6_RANGES = [
64
+ IPAddr.new("::/128"), # Unspecified address (RFC 4291)
65
+ IPAddr.new("::1/128"), # Loopback address (RFC 4291)
66
+ IPAddr.new("fc00::/7"), # Unique local address (ULA) (RFC 4193
67
+ IPAddr.new("fe80::/10"), # Link-local address (LLA) (RFC 4291)
68
+ IPAddr.new("100::/64"), # Discard prefix (RFC 6666)
69
+ IPAddr.new("2001:db8::/32"), # Documentation prefix (RFC 3849)
70
+ IPAddr.new("3fff::/20") # Documentation prefix (RFC 9637)
71
+ ]
72
+
73
+ PRIVATE_RANGES = PRIVATE_IPV4_RANGES + PRIVATE_IPV6_RANGES + PRIVATE_IPV4_RANGES.map(&:ipv4_mapped)
74
+
75
+ def resolved_in_current_context
76
+ context = Aikido::Zen.current_context
77
+ context && context["dns.lookups"]
78
+ end
79
+
80
+ def resolve(hostname_or_address)
81
+ return [] if hostname_or_address.nil?
82
+
83
+ case hostname_or_address
84
+ when Resolv::AddressRegex
85
+ [IPAddr.new(hostname_or_address)]
86
+ when resolved_in_current_context
87
+ resolved_in_current_context[hostname_or_address]
88
+ .map { |address| IPAddr.new(address) }
89
+ else
90
+ @resolver.getaddresses(hostname_or_address.to_s)
91
+ .map { |address| IPAddr.new(address) }
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,265 @@
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
+ )
56
+
57
+ return attack
58
+ end
59
+
60
+ nil
61
+ end
62
+
63
+ # Track the origin of a redirection so we know if an attacker is using
64
+ # redirect chains to mask their use of a (seemingly) safe domain.
65
+ #
66
+ # @param request [Aikido::Zen::Scanners::SSRFScanner::Request]
67
+ # @param response [Aikido::Zen::Scanners::SSRFScanner::Response]
68
+ # @param context [Aikido::Zen::Context]
69
+ #
70
+ # @return [void]
71
+ def self.track_redirects(request:, response:, context: Aikido::Zen.current_context)
72
+ return unless response.redirect?
73
+
74
+ context["ssrf.redirects"] ||= RedirectChains.new
75
+ context["ssrf.redirects"].add(
76
+ source: request.uri,
77
+ destination: response.redirect_to
78
+ )
79
+ end
80
+
81
+ # @api private
82
+ def initialize(request_uri, input, redirects)
83
+ @request_uri = request_uri
84
+ @input = input
85
+ @redirects = redirects
86
+ end
87
+
88
+ # @api private
89
+ def attack?
90
+ return false if @input.nil? || @input.to_s.empty?
91
+
92
+ # If the request is not aimed at an internal IP, we can ignore it. (It
93
+ # might still be an SSRF if defined strictly, but it's unlikely to be
94
+ # exfiltrating data from the app's servers, and the risk for false
95
+ # positives is too high.)
96
+ return false unless private_ip?(@request_uri.hostname)
97
+
98
+ origins_for_request
99
+ .product(uris_from_input)
100
+ .any? { |(conn_uri, candidate)| match?(conn_uri, candidate) }
101
+ end
102
+
103
+ # @!visibility private
104
+ def self.private_ip_checker
105
+ @private_ip_checker ||= SSRF::PrivateIPChecker.new
106
+ end
107
+
108
+ private
109
+
110
+ def match?(conn_uri, input_uri)
111
+ return false if conn_uri.hostname.nil? || conn_uri.hostname.empty?
112
+ return false if input_uri.hostname.nil? || input_uri.hostname.empty?
113
+
114
+ # The URI library will automatically set the port to the default port
115
+ # for the current scheme if not provided, which means we can't just
116
+ # check if the port is present, as it always will be.
117
+ is_port_relevant = input_uri.port != input_uri.default_port
118
+ return false if is_port_relevant && input_uri.port != conn_uri.port
119
+
120
+ conn_uri.hostname == input_uri.hostname &&
121
+ conn_uri.port == input_uri.port
122
+ end
123
+
124
+ def private_ip?(hostname)
125
+ self.class.private_ip_checker.private?(hostname)
126
+ end
127
+
128
+ def origins_for_request
129
+ [@request_uri, @redirects.origin(@request_uri)].compact
130
+ end
131
+
132
+ # Maps the current user input into a Set of URIs we can check against:
133
+ #
134
+ # * The input itself, if it already looks like a URI.
135
+ # * The input prefixed with http://
136
+ # * The input prefixed with https://
137
+ # * The input prefixed with the scheme of the request's URI, to consider
138
+ # things like an FTP request (to "ftp://localhost") with a plain host
139
+ # as a user-input ("localhost").
140
+ #
141
+ # @return [Array<URI>] a list of unique URIs based on the above criteria.
142
+ def uris_from_input
143
+ input = @input.to_s
144
+
145
+ # If you build a URI manually and set the hostname to an IPv6 string,
146
+ # the URI library will be helpful to wrap it in brackets so it's a
147
+ # valid hostname. We should do the same for the input.
148
+ input = format("[%s]", input) if unescaped_ipv6?(input)
149
+
150
+ [
151
+ input,
152
+ "http://#{input}",
153
+ "https://#{input}",
154
+ "#{@request_uri.scheme}://#{input}"
155
+ ].map { |candidate| as_uri(candidate) }.compact.uniq
156
+ end
157
+
158
+ def as_uri(string)
159
+ URI(string)
160
+ rescue URI::InvalidURIError
161
+ nil
162
+ end
163
+
164
+ # Check if the input is an IPv6 that is not surrounded by square brackets.
165
+ def unescaped_ipv6?(input)
166
+ (
167
+ IPAddr::RE_IPV6ADDRLIKE_FULL.match?(input) ||
168
+ IPAddr::RE_IPV6ADDRLIKE_COMPRESSED.match?(input)
169
+ ) && !(input.start_with?("[") && input.end_with?("]"))
170
+ end
171
+
172
+ # @api private
173
+ module Headers
174
+ # @param headers [Hash<String, Object>]
175
+ # @param header_normalizer [Proc{Object => String}]
176
+ def initialize(headers:, header_normalizer: :to_s.to_proc)
177
+ @headers = headers.to_h
178
+ @header_normalizer = header_normalizer
179
+ @normalized_headers = false
180
+ end
181
+
182
+ # @return [Hash<String, String>]
183
+ def headers
184
+ return @headers if @normalized_headers
185
+
186
+ @headers
187
+ .transform_keys!(&:downcase)
188
+ .transform_values!(&@header_normalizer)
189
+ .tap { @normalized_headers = true }
190
+ end
191
+ end
192
+
193
+ # @api private
194
+ class Request
195
+ include Headers
196
+
197
+ attr_reader :verb
198
+ attr_reader :uri
199
+
200
+ def initialize(verb:, uri:, **header_options)
201
+ super(**header_options)
202
+ @verb = verb.to_s.upcase
203
+ @uri = URI(uri)
204
+ end
205
+
206
+ def to_s
207
+ [@verb, @uri.to_s].join(" ").strip
208
+ end
209
+ end
210
+
211
+ # @api private
212
+ class Response
213
+ include Headers
214
+
215
+ attr_reader :status
216
+
217
+ def initialize(status:, **header_options)
218
+ super(**header_options)
219
+ @status = status.to_s
220
+ end
221
+
222
+ def redirect?
223
+ @status.start_with?("3") && headers["location"]
224
+ end
225
+
226
+ def redirect_to
227
+ URI(headers["location"]) if redirect?
228
+ rescue URI::BadURIError
229
+ nil
230
+ end
231
+ end
232
+
233
+ # @api private
234
+ class RedirectChains
235
+ def initialize
236
+ @redirects = Hash.new { |h, k| h[k] = [] }
237
+ end
238
+
239
+ def add(source:, destination:)
240
+ @redirects[destination].push(source)
241
+ self
242
+ end
243
+
244
+ # Recursively looks for the original URI that triggered the current
245
+ # chain. If given a URI that was not the result of a redirect chain, it
246
+ # returns +nil+
247
+ #
248
+ # @param uri [URI]
249
+ # @return [URI, nil]
250
+ def origin(uri, visited = Set.new)
251
+ source = @redirects[uri].first
252
+
253
+ return source if visited.include?(source)
254
+ visited << source
255
+
256
+ if !@redirects[source].empty?
257
+ origin(source, visited)
258
+ else
259
+ source
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,49 @@
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
+ )
25
+ end
26
+
27
+ def initialize(hostname, addresses, config: Aikido::Zen.config)
28
+ @hostname = hostname
29
+ @addresses = addresses
30
+ @config = config
31
+ end
32
+
33
+ # @return [String, nil] either the offending address, or +nil+ if no
34
+ # address is deemed dangerous.
35
+ def attack?
36
+ return false if @config.imds_allowed_hosts.include?(@hostname)
37
+
38
+ @addresses.find do |candidate|
39
+ DANGEROUS_ADDRESSES.any? { |address| address === candidate }
40
+ end
41
+ end
42
+
43
+ DANGEROUS_ADDRESSES = [
44
+ IPAddr.new("169.254.169.254"),
45
+ IPAddr.new("fd00:ec2::254")
46
+ ]
47
+ end
48
+ end
49
+ 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 internal
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