aikido-zen 0.1.0.alpha4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +674 -0
- data/README.md +40 -0
- data/Rakefile +63 -0
- data/lib/aikido/zen/actor.rb +116 -0
- data/lib/aikido/zen/agent.rb +187 -0
- data/lib/aikido/zen/api_client.rb +132 -0
- data/lib/aikido/zen/attack.rb +138 -0
- data/lib/aikido/zen/capped_collections.rb +68 -0
- data/lib/aikido/zen/config.rb +229 -0
- data/lib/aikido/zen/context/rack_request.rb +24 -0
- data/lib/aikido/zen/context/rails_request.rb +42 -0
- data/lib/aikido/zen/context.rb +101 -0
- data/lib/aikido/zen/errors.rb +88 -0
- data/lib/aikido/zen/event.rb +66 -0
- data/lib/aikido/zen/internals.rb +64 -0
- data/lib/aikido/zen/middleware/check_allowed_addresses.rb +38 -0
- data/lib/aikido/zen/middleware/set_context.rb +26 -0
- data/lib/aikido/zen/middleware/throttler.rb +50 -0
- data/lib/aikido/zen/outbound_connection.rb +45 -0
- data/lib/aikido/zen/outbound_connection_monitor.rb +19 -0
- data/lib/aikido/zen/package.rb +22 -0
- data/lib/aikido/zen/payload.rb +48 -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 +55 -0
- data/lib/aikido/zen/request/heuristic_router.rb +109 -0
- data/lib/aikido/zen/request/rails_router.rb +84 -0
- data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
- data/lib/aikido/zen/request/schema/auth_schemas.rb +40 -0
- data/lib/aikido/zen/request/schema/builder.rb +125 -0
- data/lib/aikido/zen/request/schema/definition.rb +112 -0
- data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
- data/lib/aikido/zen/request/schema.rb +72 -0
- data/lib/aikido/zen/request.rb +97 -0
- data/lib/aikido/zen/route.rb +39 -0
- data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -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 +70 -0
- data/lib/aikido/zen/scan.rb +75 -0
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +95 -0
- data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
- data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +85 -0
- data/lib/aikido/zen/scanners/ssrf_scanner.rb +251 -0
- data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +43 -0
- data/lib/aikido/zen/scanners.rb +5 -0
- data/lib/aikido/zen/sink.rb +108 -0
- data/lib/aikido/zen/sinks/async_http.rb +63 -0
- data/lib/aikido/zen/sinks/curb.rb +89 -0
- data/lib/aikido/zen/sinks/em_http.rb +71 -0
- data/lib/aikido/zen/sinks/excon.rb +103 -0
- data/lib/aikido/zen/sinks/http.rb +76 -0
- data/lib/aikido/zen/sinks/httpclient.rb +68 -0
- data/lib/aikido/zen/sinks/httpx.rb +61 -0
- data/lib/aikido/zen/sinks/mysql2.rb +21 -0
- data/lib/aikido/zen/sinks/net_http.rb +85 -0
- data/lib/aikido/zen/sinks/patron.rb +88 -0
- data/lib/aikido/zen/sinks/pg.rb +50 -0
- data/lib/aikido/zen/sinks/resolv.rb +41 -0
- data/lib/aikido/zen/sinks/socket.rb +51 -0
- data/lib/aikido/zen/sinks/sqlite3.rb +30 -0
- data/lib/aikido/zen/sinks/trilogy.rb +21 -0
- data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
- data/lib/aikido/zen/sinks.rb +21 -0
- data/lib/aikido/zen/stats/routes.rb +53 -0
- data/lib/aikido/zen/stats/sink_stats.rb +95 -0
- data/lib/aikido/zen/stats/users.rb +26 -0
- data/lib/aikido/zen/stats.rb +171 -0
- data/lib/aikido/zen/synchronizable.rb +24 -0
- data/lib/aikido/zen/system_info.rb +84 -0
- data/lib/aikido/zen/version.rb +10 -0
- data/lib/aikido/zen.rb +138 -0
- data/lib/aikido-zen.rb +3 -0
- data/lib/aikido.rb +3 -0
- data/tasklib/libzen.rake +128 -0
- metadata +171 -0
@@ -0,0 +1,68 @@
|
|
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)
|
22
|
+
@data = CappedMap.new(capacity)
|
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
|
+
:[], :fetch, :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)
|
58
|
+
raise ArgumentError, "cannot set capacity lower than 1: #{capacity}" if capacity < 1
|
59
|
+
@capacity = capacity
|
60
|
+
@data = {}
|
61
|
+
end
|
62
|
+
|
63
|
+
def []=(key, value)
|
64
|
+
@data[key] = value
|
65
|
+
@data.delete(@data.each_key.first) if @data.size > @capacity
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,229 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "uri"
|
4
|
+
require "json"
|
5
|
+
require "logger"
|
6
|
+
|
7
|
+
require_relative "context"
|
8
|
+
|
9
|
+
module Aikido::Zen
|
10
|
+
class Config
|
11
|
+
# @return [Boolean] whether Aikido should only report infractions or block
|
12
|
+
# the request by raising an Exception. Defaults to whether AIKIDO_BLOCKING
|
13
|
+
# is set to a non-empty value in your environment, or +false+ otherwise.
|
14
|
+
attr_accessor :blocking_mode
|
15
|
+
alias_method :blocking_mode?, :blocking_mode
|
16
|
+
|
17
|
+
# @return [URI] The HTTP host for the Aikido API. Defaults to
|
18
|
+
# +https://guard.aikido.dev+.
|
19
|
+
attr_reader :api_base_url
|
20
|
+
|
21
|
+
# @return [URI] The HTTP host for the Aikido Runtime API. Defaults to
|
22
|
+
# +https://runtime.aikido.dev+.
|
23
|
+
attr_reader :runtime_api_base_url
|
24
|
+
|
25
|
+
# @return [Hash] HTTP timeouts for communicating with the API.
|
26
|
+
attr_reader :api_timeouts
|
27
|
+
|
28
|
+
# @return [String] the token obtained when configuring the Firewall in the
|
29
|
+
# Aikido interface.
|
30
|
+
attr_accessor :api_token
|
31
|
+
|
32
|
+
# @return [Integer] the interval in seconds to poll the runtime API for
|
33
|
+
# settings changes. Defaults to evey 60 seconds.
|
34
|
+
attr_accessor :polling_interval
|
35
|
+
|
36
|
+
# @return [Integer] the amount in seconds to wait before sending an initial
|
37
|
+
# heartbeat event when the server reports no stats have been sent yet.
|
38
|
+
attr_accessor :initial_heartbeat_delay
|
39
|
+
|
40
|
+
# @return [#call] Callable that can be passed an Object and returns a String
|
41
|
+
# of JSON. Defaults to the standard library's JSON.dump method.
|
42
|
+
attr_accessor :json_encoder
|
43
|
+
|
44
|
+
# @return [#call] Callable that can be passed a JSON string and parses it
|
45
|
+
# into an Object. Defaults to the standard library's JSON.parse method.
|
46
|
+
attr_accessor :json_decoder
|
47
|
+
|
48
|
+
# @returns [Logger]
|
49
|
+
attr_accessor :logger
|
50
|
+
|
51
|
+
# @return [Integer] maximum number of timing measurements to keep in memory
|
52
|
+
# before compressing them.
|
53
|
+
attr_accessor :max_performance_samples
|
54
|
+
|
55
|
+
# @return [Integer] maximum number of compressed performance samples to keep
|
56
|
+
# in memory. If we take more than this before reporting them to Aikido, we
|
57
|
+
# will discard the oldest samples.
|
58
|
+
attr_accessor :max_compressed_stats
|
59
|
+
|
60
|
+
# @return [Integer] maximum number of connections to outbound hosts to keep
|
61
|
+
# in memory in order to report them in the next heartbeat event. If new
|
62
|
+
# connections are added to the set before reporting them to Aikido, we
|
63
|
+
# will discard the oldest data point.
|
64
|
+
attr_accessor :max_outbound_connections
|
65
|
+
|
66
|
+
# @return [Integer] maximum number of users tracked via Zen.track_user to
|
67
|
+
# share with the Aikido servers on the next heartbeat event. If more
|
68
|
+
# unique users (by their ID) are tracked than this number, we will discard
|
69
|
+
# the oldest seen users.
|
70
|
+
attr_accessor :max_users_tracked
|
71
|
+
|
72
|
+
# @return [Proc{Aikido::Zen::Request => Array(Integer, Hash, #each)}]
|
73
|
+
# Rack handler used to respond to requests from IPs blocked in the Aikido
|
74
|
+
# dashboard.
|
75
|
+
attr_accessor :blocked_ip_responder
|
76
|
+
|
77
|
+
# @return [Proc{Aikido::Zen::Request => Array(Integer, Hash, #each)}]
|
78
|
+
# Rack handler used to respond to requests that have been rate limited.
|
79
|
+
attr_accessor :rate_limited_responder
|
80
|
+
|
81
|
+
# @return [Proc{Aikido::Zen::Request => String}] a proc that reads
|
82
|
+
# information off the current request and returns a String to
|
83
|
+
# differentiate different clients. By default this uses the request IP.
|
84
|
+
attr_accessor :rate_limiting_discriminator
|
85
|
+
|
86
|
+
# @return [Boolean] whether Zen should infer the schema from request bodies
|
87
|
+
# sent to the app. Defaults to +false+ or the value of the environment
|
88
|
+
# variable AIKIDO_FEATURE_COLLECT_API_SCHEMA.
|
89
|
+
attr_accessor :api_schema_collection_enabled
|
90
|
+
alias_method :api_schema_collection_enabled?, :api_schema_collection_enabled
|
91
|
+
|
92
|
+
# @api private
|
93
|
+
# @return [Integer] max number of levels deep we want to read a nested
|
94
|
+
# strcture for performance reasons.
|
95
|
+
attr_accessor :api_schema_collection_max_depth
|
96
|
+
|
97
|
+
# @api private
|
98
|
+
# @return [Integer] max number of properties that we want to inspect per
|
99
|
+
# level of the structure for performance reasons.
|
100
|
+
attr_accessor :api_schema_collection_max_properties
|
101
|
+
|
102
|
+
# @api private
|
103
|
+
# @return [Proc<Hash => Aikido::Zen::Context>] callable that takes a
|
104
|
+
# Rack-compatible env Hash and returns a Context object with an HTTP
|
105
|
+
# request. This is meant to be overridden by each framework adapter.
|
106
|
+
attr_accessor :request_builder
|
107
|
+
|
108
|
+
# @api private
|
109
|
+
# @return [Integer] number of seconds to perform client-side rate limiting
|
110
|
+
# of events sent to the server.
|
111
|
+
attr_accessor :client_rate_limit_period
|
112
|
+
|
113
|
+
# @api private
|
114
|
+
# @return [Integer] max number of events sent during a sliding
|
115
|
+
# {client_rate_limit_period} window.
|
116
|
+
attr_accessor :client_rate_limit_max_events
|
117
|
+
|
118
|
+
# @api private
|
119
|
+
# @return [Integer] number of seconds to wait before sending an event after
|
120
|
+
# the server returns a 429 response.
|
121
|
+
attr_accessor :server_rate_limit_deadline
|
122
|
+
|
123
|
+
# @return [Array<String>] when checking for stored SSRF attacks, we want to
|
124
|
+
# allow known hosts that should be able to resolve to the IMDS service.
|
125
|
+
attr_accessor :imds_allowed_hosts
|
126
|
+
|
127
|
+
def initialize
|
128
|
+
self.blocking_mode = !!ENV.fetch("AIKIDO_BLOCKING", false)
|
129
|
+
self.api_timeouts = 10
|
130
|
+
self.api_base_url = ENV.fetch("AIKIDO_BASE_URL", DEFAULT_API_BASE_URL)
|
131
|
+
self.runtime_api_base_url = ENV.fetch("AIKIDO_RUNTIME_URL", DEFAULT_RUNTIME_BASE_URL)
|
132
|
+
self.api_token = ENV.fetch("AIKIDO_TOKEN", nil)
|
133
|
+
self.polling_interval = 60
|
134
|
+
self.initial_heartbeat_delay = 60
|
135
|
+
self.json_encoder = DEFAULT_JSON_ENCODER
|
136
|
+
self.json_decoder = DEFAULT_JSON_DECODER
|
137
|
+
self.logger = Logger.new($stdout, progname: "aikido")
|
138
|
+
self.max_performance_samples = 5000
|
139
|
+
self.max_compressed_stats = 100
|
140
|
+
self.max_outbound_connections = 200
|
141
|
+
self.max_users_tracked = 1000
|
142
|
+
self.request_builder = Aikido::Zen::Context::RACK_REQUEST_BUILDER
|
143
|
+
self.blocked_ip_responder = DEFAULT_BLOCKED_IP_RESPONDER
|
144
|
+
self.rate_limited_responder = DEFAULT_RATE_LIMITED_RESPONDER
|
145
|
+
self.rate_limiting_discriminator = DEFAULT_RATE_LIMITING_DISCRIMINATOR
|
146
|
+
self.server_rate_limit_deadline = 1800 # 30 min
|
147
|
+
self.client_rate_limit_period = 3600 # 1 hour
|
148
|
+
self.client_rate_limit_max_events = 100
|
149
|
+
|
150
|
+
self.api_schema_collection_enabled = read_boolean_from_env(ENV.fetch("AIKIDO_FEATURE_COLLECT_API_SCHEMA", false))
|
151
|
+
self.api_schema_collection_max_depth = 20
|
152
|
+
self.api_schema_collection_max_properties = 20
|
153
|
+
|
154
|
+
self.imds_allowed_hosts = ["metadata.google.internal", "metadata.goog"]
|
155
|
+
end
|
156
|
+
|
157
|
+
# Set the base URL for API requests.
|
158
|
+
#
|
159
|
+
# @param url [String, URI]
|
160
|
+
def api_base_url=(url)
|
161
|
+
@api_base_url = URI(url)
|
162
|
+
end
|
163
|
+
|
164
|
+
# Set the base URL for runtime API requests.
|
165
|
+
#
|
166
|
+
# @param url [String, URI]
|
167
|
+
def runtime_api_base_url=(url)
|
168
|
+
@runtime_api_base_url = URI(url)
|
169
|
+
end
|
170
|
+
|
171
|
+
# @overload def api_timeouts=(timeouts)
|
172
|
+
# Configure granular connection timeouts for the Aikido Zen API. You
|
173
|
+
# can set any of these per call.
|
174
|
+
# @param timeouts [Hash]
|
175
|
+
# @option timeouts [Integer] :open_timeout Duration in seconds.
|
176
|
+
# @option timeouts [Integer] :read_timeout Duration in seconds.
|
177
|
+
# @option timeouts [Integer] :write_timeout Duration in seconds.
|
178
|
+
#
|
179
|
+
# @overload def api_timeouts=(duration)
|
180
|
+
# Configure the connection timeouts for the Aikido Zen API.
|
181
|
+
# @param duration [Integer] Duration in seconds to set for all three
|
182
|
+
# timeouts (open, read, and write).
|
183
|
+
def api_timeouts=(value)
|
184
|
+
value = {open_timeout: value, read_timeout: value, write_timeout: value} if value.respond_to?(:to_int)
|
185
|
+
|
186
|
+
@api_timeouts ||= {}
|
187
|
+
@api_timeouts.update(value)
|
188
|
+
end
|
189
|
+
|
190
|
+
private
|
191
|
+
|
192
|
+
def read_boolean_from_env(value)
|
193
|
+
return value unless value.respond_to?(:to_str)
|
194
|
+
|
195
|
+
case value.to_str.strip
|
196
|
+
when "false", "", "0", "f"
|
197
|
+
false
|
198
|
+
else
|
199
|
+
true
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
# @!visibility private
|
204
|
+
DEFAULT_API_BASE_URL = "https://guard.aikido.dev"
|
205
|
+
|
206
|
+
# @!visibility private
|
207
|
+
DEFAULT_RUNTIME_BASE_URL = "https://runtime.aikido.dev"
|
208
|
+
|
209
|
+
# @!visibility private
|
210
|
+
DEFAULT_JSON_ENCODER = JSON.method(:dump)
|
211
|
+
|
212
|
+
# @!visibility private
|
213
|
+
DEFAULT_JSON_DECODER = JSON.method(:parse)
|
214
|
+
|
215
|
+
# @!visibility private
|
216
|
+
DEFAULT_BLOCKED_IP_RESPONDER = ->(request) do
|
217
|
+
message = "Your IP address is not allowed to access this resource. (Your IP: %s)"
|
218
|
+
[403, {"Content-Type" => "text/plain"}, [format(message, request.ip)]]
|
219
|
+
end
|
220
|
+
|
221
|
+
# @!visibility private
|
222
|
+
DEFAULT_RATE_LIMITED_RESPONDER = ->(request) do
|
223
|
+
[429, {"Content-Type" => "text/plain"}, ["Too many requests."]]
|
224
|
+
end
|
225
|
+
|
226
|
+
# @!visibility private
|
227
|
+
DEFAULT_RATE_LIMITING_DISCRIMINATOR = ->(request) { request.ip }
|
228
|
+
end
|
229
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../request"
|
4
|
+
require_relative "../request/heuristic_router"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
# @!visibility private
|
8
|
+
Context::RACK_REQUEST_BUILDER = ->(env) do
|
9
|
+
delegate = Rack::Request.new(env)
|
10
|
+
router = Aikido::Zen::Request::HeuristicRouter.new
|
11
|
+
request = Aikido::Zen::Request.new(delegate, framework: "rack", router: router)
|
12
|
+
|
13
|
+
Context.new(request) do |req|
|
14
|
+
{
|
15
|
+
query: req.GET,
|
16
|
+
body: req.POST,
|
17
|
+
route: {},
|
18
|
+
header: req.normalized_headers,
|
19
|
+
cookie: req.cookies,
|
20
|
+
subdomain: []
|
21
|
+
}
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../request"
|
4
|
+
require_relative "../request/rails_router"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Rails
|
8
|
+
def self.router
|
9
|
+
@router ||= Request::RailsRouter.new(::Rails.application.routes)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
# @!visibility private
|
14
|
+
Context::RAILS_REQUEST_BUILDER = ->(env) do
|
15
|
+
delegate = ActionDispatch::Request.new(env)
|
16
|
+
request = Aikido::Zen::Request.new(
|
17
|
+
delegate, framework: "rails", router: Rails.router
|
18
|
+
)
|
19
|
+
|
20
|
+
decrypt_cookies = ->(req) do
|
21
|
+
return req.cookies unless req.respond_to?(:cookie_jar)
|
22
|
+
|
23
|
+
req.cookie_jar.map { |key, value|
|
24
|
+
plain_text = req.cookie_jar.encrypted[key].presence ||
|
25
|
+
req.cookie_jar.signed[key].presence ||
|
26
|
+
value
|
27
|
+
[key, plain_text]
|
28
|
+
}.to_h
|
29
|
+
end
|
30
|
+
|
31
|
+
Context.new(request) do |req|
|
32
|
+
{
|
33
|
+
query: req.query_parameters,
|
34
|
+
body: req.request_parameters,
|
35
|
+
route: req.path_parameters,
|
36
|
+
header: req.normalized_headers,
|
37
|
+
cookie: decrypt_cookies.call(req),
|
38
|
+
subdomain: req.subdomains
|
39
|
+
}
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
require_relative "request"
|
6
|
+
require_relative "payload"
|
7
|
+
|
8
|
+
module Aikido::Zen
|
9
|
+
class Context
|
10
|
+
def self.from_rack_env(env, config = Aikido::Zen.config)
|
11
|
+
config.request_builder.call(env)
|
12
|
+
end
|
13
|
+
|
14
|
+
attr_reader :request
|
15
|
+
|
16
|
+
# @param [Rack::Request] a Request object that implements the
|
17
|
+
# Rack::Request API, to which we will delegate behavior.
|
18
|
+
# @param settings [Aikido::Zen::RuntimeSettings]
|
19
|
+
#
|
20
|
+
# @yieldparam request [Rack::Request] the given request object.
|
21
|
+
# @yieldreturn [Hash<Symbol, #flat_map>] map of payload source types
|
22
|
+
# to the actual data from the request to populate them.
|
23
|
+
def initialize(request, settings: Aikido::Zen.runtime_settings, &sources)
|
24
|
+
@request = request
|
25
|
+
@settings = settings
|
26
|
+
@payload_sources = sources
|
27
|
+
@metadata = {}
|
28
|
+
end
|
29
|
+
|
30
|
+
# Fetch some metadata stored in the Context.
|
31
|
+
#
|
32
|
+
# @param key [String]
|
33
|
+
# @return [Object, nil]
|
34
|
+
def [](key)
|
35
|
+
@metadata[key]
|
36
|
+
end
|
37
|
+
|
38
|
+
# Store some metadata in the Context so other Scanners can use it.
|
39
|
+
#
|
40
|
+
# @param key [String]
|
41
|
+
# @param value [Object]
|
42
|
+
# @return [void]
|
43
|
+
def []=(key, value)
|
44
|
+
@metadata[key] = value
|
45
|
+
end
|
46
|
+
|
47
|
+
# Overrides the current request, and invalidates any memoized data obtained
|
48
|
+
# from it. This is useful for scenarios where setting the request in the
|
49
|
+
# middleware isn't enough, such as Rails, where the router modifies it after
|
50
|
+
# the middleware has seen it.
|
51
|
+
#
|
52
|
+
# @param new_request [Rack::Request]
|
53
|
+
# @return [void]
|
54
|
+
def update_request(new_request)
|
55
|
+
@payloads = nil
|
56
|
+
request.__setobj__(new_request)
|
57
|
+
end
|
58
|
+
|
59
|
+
# @return [Array<Aikido::Zen::Payload>] list of user inputs from all the
|
60
|
+
# different sources we recognize.
|
61
|
+
def payloads
|
62
|
+
@payloads ||= payload_sources.flat_map do |source, data|
|
63
|
+
extract_payloads_from(data, source)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# @return [Boolean] whether attack protection for the currently requested
|
68
|
+
# endpoint was disabled on the Aikido dashboard, or if the source IP for
|
69
|
+
# this request is in the "Bypass List".
|
70
|
+
def protection_disabled?
|
71
|
+
return false if request.nil?
|
72
|
+
|
73
|
+
!@settings.endpoints[request.route].protected? ||
|
74
|
+
@settings.skip_protection_for_ips.include?(request.ip)
|
75
|
+
end
|
76
|
+
|
77
|
+
# @!visibility private
|
78
|
+
def payload_sources
|
79
|
+
@payload_sources.call(request)
|
80
|
+
end
|
81
|
+
|
82
|
+
private
|
83
|
+
|
84
|
+
def extract_payloads_from(data, source_type, prefix = nil)
|
85
|
+
if data.respond_to?(:to_hash)
|
86
|
+
data.to_hash.flat_map { |name, val|
|
87
|
+
extract_payloads_from(val, source_type, [prefix, name].compact.join("."))
|
88
|
+
}
|
89
|
+
elsif data.respond_to?(:to_ary)
|
90
|
+
data.to_ary.flat_map.with_index { |val, idx|
|
91
|
+
extract_payloads_from(val, source_type, [prefix, idx].compact.join("."))
|
92
|
+
}
|
93
|
+
else
|
94
|
+
Payload.new(data, source_type, prefix.to_s)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
require_relative "context/rack_request"
|
101
|
+
require_relative "context/rails_request"
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
module Aikido
|
6
|
+
# Support rescuing Aikido::Error without forcing a single base class to all
|
7
|
+
# errors (so things that should be e.g. a TypeError, can have the correct
|
8
|
+
# superclass).
|
9
|
+
module Error; end
|
10
|
+
|
11
|
+
# Generic error for problems with the Agent.
|
12
|
+
class ZenError < RuntimeError
|
13
|
+
include Error
|
14
|
+
end
|
15
|
+
|
16
|
+
module Zen
|
17
|
+
# Wrapper for all low-level network errors communicating with the API. You
|
18
|
+
# can access the original error by calling #cause.
|
19
|
+
class NetworkError < StandardError
|
20
|
+
include Error
|
21
|
+
|
22
|
+
def initialize(request, cause = nil)
|
23
|
+
@request = request.dup
|
24
|
+
|
25
|
+
super("Error in #{request.method} #{request.path}: #{cause.message}")
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Raised whenever a request to the API results in a 4XX or 5XX response.
|
30
|
+
class APIError < StandardError
|
31
|
+
include Error
|
32
|
+
|
33
|
+
attr_reader :request
|
34
|
+
attr_reader :response
|
35
|
+
|
36
|
+
def initialize(request, response)
|
37
|
+
@request = anonimize_token(request.dup)
|
38
|
+
@response = response
|
39
|
+
|
40
|
+
super("Error in #{request.method} #{request.path}: #{response.code} #{response.message} (#{response.body})")
|
41
|
+
end
|
42
|
+
|
43
|
+
private def anonimize_token(request)
|
44
|
+
# Anonimize the token to `********************xxxx`,
|
45
|
+
# mimicking what we show in the dashbaord.
|
46
|
+
request["Authorization"] = request["Authorization"].to_s
|
47
|
+
.gsub(/\A.*(.{4})\z/, ("*" * 20) + "\\1")
|
48
|
+
request
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Raised whenever a response to the API results in a 429 response.
|
53
|
+
class RateLimitedError < APIError; end
|
54
|
+
|
55
|
+
class UnderAttackError < StandardError
|
56
|
+
include Error
|
57
|
+
|
58
|
+
attr_reader :attack
|
59
|
+
|
60
|
+
def initialize(attack)
|
61
|
+
super(attack.log_message)
|
62
|
+
@attack = attack
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
class SQLInjectionError < UnderAttackError
|
67
|
+
extend Forwardable
|
68
|
+
def_delegators :@attack, :query, :input, :dialect
|
69
|
+
end
|
70
|
+
|
71
|
+
class SSRFDetectedError < UnderAttackError
|
72
|
+
extend Forwardable
|
73
|
+
def_delegators :@attack, :request, :input
|
74
|
+
end
|
75
|
+
|
76
|
+
# Raised when there's any problem communicating (or loading) libzen.
|
77
|
+
class InternalsError < ZenError
|
78
|
+
# @param attempt [String] description of what we were trying to do.
|
79
|
+
# @param problem [String] what couldn't be done.
|
80
|
+
# @param libname [String] the name of the file (including the arch).
|
81
|
+
def initialize(attempt, problem, libname)
|
82
|
+
super(format(<<~MSG.chomp, attempt, problem, libname))
|
83
|
+
Zen could not scan %s due to a problem %s the library `%s'
|
84
|
+
MSG
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
# Base class for all events. You should be using one of the subclasses defined
|
5
|
+
# in the Events module.
|
6
|
+
class Event
|
7
|
+
attr_reader :type
|
8
|
+
attr_reader :time
|
9
|
+
attr_reader :system_info
|
10
|
+
|
11
|
+
def initialize(type:, system_info: Aikido::Zen.system_info, time: Time.now.utc)
|
12
|
+
@type = type
|
13
|
+
@time = time
|
14
|
+
@system_info = system_info
|
15
|
+
end
|
16
|
+
|
17
|
+
def as_json
|
18
|
+
{
|
19
|
+
type: type,
|
20
|
+
time: time.to_i * 1000,
|
21
|
+
agent: system_info.as_json
|
22
|
+
}
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
module Events
|
27
|
+
# Event sent when starting up the agent.
|
28
|
+
class Started < Event
|
29
|
+
def initialize(**opts)
|
30
|
+
super(type: "started", **opts)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
class Attack < Event
|
35
|
+
attr_reader :attack
|
36
|
+
|
37
|
+
def initialize(attack:, **opts)
|
38
|
+
@attack = attack
|
39
|
+
super(type: "detected_attack", **opts)
|
40
|
+
end
|
41
|
+
|
42
|
+
def as_json
|
43
|
+
super.update(
|
44
|
+
attack: @attack.as_json,
|
45
|
+
request: @attack.context.request.as_json
|
46
|
+
)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
class Heartbeat < Event
|
51
|
+
def initialize(stats:, **opts)
|
52
|
+
super(type: "heartbeat", **opts)
|
53
|
+
@stats = stats
|
54
|
+
end
|
55
|
+
|
56
|
+
def as_json
|
57
|
+
super.update(
|
58
|
+
stats: @stats.as_json,
|
59
|
+
routes: @stats.routes.as_json,
|
60
|
+
hostnames: @stats.outbound_connections.as_json,
|
61
|
+
users: @stats.users.as_json
|
62
|
+
)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "ffi"
|
4
|
+
require_relative "errors"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Internals
|
8
|
+
extend FFI::Library
|
9
|
+
|
10
|
+
class << self
|
11
|
+
# @return [String] the name of the extension we're loading, which we can
|
12
|
+
# use in error messages to identify the architecture.
|
13
|
+
attr_accessor :libzen_name
|
14
|
+
end
|
15
|
+
|
16
|
+
self.libzen_name = [
|
17
|
+
"libzen-v#{LIBZEN_VERSION}",
|
18
|
+
FFI::Platform::ARCH,
|
19
|
+
FFI::Platform::LIBSUFFIX
|
20
|
+
].join(".")
|
21
|
+
|
22
|
+
begin
|
23
|
+
ffi_lib File.expand_path(libzen_name, __dir__)
|
24
|
+
|
25
|
+
# @!method self.detect_sql_injection_native(query, input, dialect)
|
26
|
+
# @param (see .detect_sql_injection)
|
27
|
+
# @returns [Integer] 0 if no injection detected, 1 if an injection was
|
28
|
+
# detected, or 2 if there was an internal error.
|
29
|
+
# @raise [Aikido::Zen::InternalsError] if there's a problem loading or
|
30
|
+
# calling libzen.
|
31
|
+
attach_function :detect_sql_injection_native, :detect_sql_injection,
|
32
|
+
[:string, :string, :int], :int
|
33
|
+
rescue LoadError, FFI::NotFoundError => err
|
34
|
+
# Emit an $stderr warning at startup.
|
35
|
+
warn "Zen could not load its binary extension #{libzen_name}: #{err}"
|
36
|
+
|
37
|
+
def self.detect_sql_injection(query, *)
|
38
|
+
attempt = format("%p for SQL injection", query)
|
39
|
+
raise InternalsError.new(attempt, "loading", Internals.libzen_name)
|
40
|
+
end
|
41
|
+
else
|
42
|
+
# Analyzes the SQL query to detect if the provided user input is being
|
43
|
+
# passed as-is without escaping.
|
44
|
+
#
|
45
|
+
# @param query [String]
|
46
|
+
# @param input [String]
|
47
|
+
# @param dialect [Integer, #to_int] the SQL Dialect identifier in libzen.
|
48
|
+
# See {Aikido::Zen::Scanners::SQLInjectionScanner::DIALECTS}.
|
49
|
+
#
|
50
|
+
# @returns [Boolean]
|
51
|
+
# @raise [Aikido::Zen::InternalsError] if there's a problem loading or
|
52
|
+
# calling libzen.
|
53
|
+
def self.detect_sql_injection(query, input, dialect)
|
54
|
+
case detect_sql_injection_native(query, input, dialect)
|
55
|
+
when 0 then false
|
56
|
+
when 1 then true
|
57
|
+
when 2
|
58
|
+
attempt = format("%s query %p with input %p", dialect, query, input)
|
59
|
+
raise InternalsError.new(attempt, "calling detect_sql_injection in", libzen_name)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|