aikido-zen 1.0.2.beta.10-x86_64-mingw-64 → 1.0.2-x86_64-mingw-64
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 +4 -4
- data/README.md +1 -0
- data/docs/config.md +1 -1
- data/docs/troubleshooting.md +62 -0
- data/lib/aikido/zen/agent.rb +2 -2
- data/lib/aikido/zen/attack.rb +8 -6
- data/lib/aikido/zen/attack_wave/helpers.rb +457 -0
- data/lib/aikido/zen/attack_wave.rb +88 -0
- data/lib/aikido/zen/cache.rb +91 -0
- data/lib/aikido/zen/capped_collections.rb +22 -4
- data/lib/aikido/zen/collector/event.rb +29 -0
- data/lib/aikido/zen/collector/hosts.rb +16 -1
- data/lib/aikido/zen/collector/stats.rb +17 -3
- data/lib/aikido/zen/collector/users.rb +2 -2
- data/lib/aikido/zen/collector.rb +14 -0
- data/lib/aikido/zen/config.rb +29 -6
- data/lib/aikido/zen/context/rack_request.rb +3 -0
- data/lib/aikido/zen/context/rails_request.rb +3 -0
- data/lib/aikido/zen/context.rb +2 -2
- data/lib/aikido/zen/event.rb +47 -2
- data/lib/aikido/zen/helpers.rb +24 -0
- data/lib/aikido/zen/middleware/{check_allowed_addresses.rb → allowed_address_checker.rb} +1 -1
- data/lib/aikido/zen/middleware/attack_wave_protector.rb +46 -0
- data/lib/aikido/zen/middleware/{set_context.rb → context_setter.rb} +1 -1
- data/lib/aikido/zen/middleware/rack_throttler.rb +3 -1
- data/lib/aikido/zen/middleware/request_tracker.rb +8 -3
- data/lib/aikido/zen/outbound_connection.rb +11 -1
- data/lib/aikido/zen/rails_engine.rb +3 -2
- data/lib/aikido/zen/request/rails_router.rb +17 -2
- data/lib/aikido/zen/request.rb +2 -36
- data/lib/aikido/zen/route.rb +50 -0
- data/lib/aikido/zen/runtime_settings/endpoints.rb +37 -8
- data/lib/aikido/zen/runtime_settings.rb +5 -4
- data/lib/aikido/zen/scanners/path_traversal_scanner.rb +3 -2
- data/lib/aikido/zen/scanners/shell_injection_scanner.rb +3 -2
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +3 -2
- data/lib/aikido/zen/scanners/ssrf_scanner.rb +2 -1
- data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +5 -1
- data/lib/aikido/zen/sinks/action_controller.rb +3 -1
- data/lib/aikido/zen/sinks/socket.rb +7 -0
- data/lib/aikido/zen/system_info.rb +1 -5
- data/lib/aikido/zen/version.rb +1 -1
- data/lib/aikido/zen.rb +55 -6
- data/tasklib/bench.rake +1 -1
- metadata +10 -4
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aikido::Zen
|
|
4
|
+
class Cache
|
|
5
|
+
extend Forwardable
|
|
6
|
+
|
|
7
|
+
# @api private
|
|
8
|
+
# Visible for testing.
|
|
9
|
+
def_delegators :@data,
|
|
10
|
+
:size, :empty?
|
|
11
|
+
|
|
12
|
+
def initialize(capacity, default_value = nil, ttl:, clock: nil)
|
|
13
|
+
@default_value = default_value
|
|
14
|
+
@ttl = ttl
|
|
15
|
+
@clock = clock
|
|
16
|
+
|
|
17
|
+
@data = CappedMap.new(capacity, mode: :lru)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def key?(key)
|
|
21
|
+
@data.key?(key) && !@data[key].expired?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param key [Object] the key
|
|
25
|
+
# @param value [Object] the value
|
|
26
|
+
# @return [Object] the value that the key was set to
|
|
27
|
+
def []=(key, value)
|
|
28
|
+
if key?(key)
|
|
29
|
+
entry = @data[key]
|
|
30
|
+
entry.refresh
|
|
31
|
+
entry.value = value
|
|
32
|
+
else
|
|
33
|
+
@data[key] = CacheEntry.new(value, ttl: @ttl, clock: @clock)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def [](key)
|
|
38
|
+
if key?(key)
|
|
39
|
+
@data[key].value
|
|
40
|
+
else
|
|
41
|
+
@default_value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete(key)
|
|
46
|
+
if key?(key)
|
|
47
|
+
@data.delete(key).value
|
|
48
|
+
else
|
|
49
|
+
@data.delete(key)
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @api private
|
|
55
|
+
# Visible for testing.
|
|
56
|
+
def to_a
|
|
57
|
+
@data.map { |key, entry| [key, entry.value] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @api private
|
|
61
|
+
# Visible for testing.
|
|
62
|
+
def to_h
|
|
63
|
+
to_a.to_h
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class CacheEntry
|
|
68
|
+
attr_accessor :value
|
|
69
|
+
|
|
70
|
+
DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) }
|
|
71
|
+
|
|
72
|
+
# @param value [Object] the value
|
|
73
|
+
# @param ttl [Integer] the time-to-live in milliseconds
|
|
74
|
+
# @return [Aikido::Zen::CacheEntry]
|
|
75
|
+
def initialize(value, ttl:, clock: nil)
|
|
76
|
+
@value = value
|
|
77
|
+
@ttl = ttl
|
|
78
|
+
@clock = clock || DEFAULT_CLOCK
|
|
79
|
+
|
|
80
|
+
refresh
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def refresh
|
|
84
|
+
@expires = @clock.call + @ttl
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def expired?
|
|
88
|
+
@clock.call >= @expires
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -18,8 +18,8 @@ module Aikido::Zen
|
|
|
18
18
|
# @return [Integer]
|
|
19
19
|
attr_reader :capacity
|
|
20
20
|
|
|
21
|
-
def initialize(capacity)
|
|
22
|
-
@data = CappedMap.new(capacity)
|
|
21
|
+
def initialize(capacity, mode: :fifo)
|
|
22
|
+
@data = CappedMap.new(capacity, mode: mode)
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def <<(element)
|
|
@@ -47,16 +47,23 @@ module Aikido::Zen
|
|
|
47
47
|
extend Forwardable
|
|
48
48
|
|
|
49
49
|
def_delegators :@data,
|
|
50
|
-
:
|
|
50
|
+
:delete, :key?,
|
|
51
51
|
:each, :each_key, :each_value,
|
|
52
52
|
:size, :empty?, :to_hash
|
|
53
53
|
|
|
54
54
|
# @return [Integer]
|
|
55
55
|
attr_reader :capacity
|
|
56
56
|
|
|
57
|
-
def initialize(capacity)
|
|
57
|
+
def initialize(capacity, mode: :fifo)
|
|
58
58
|
raise ArgumentError, "cannot set capacity lower than 1: #{capacity}" if capacity < 1
|
|
59
|
+
|
|
60
|
+
unless [:fifo, :lru].include?(mode)
|
|
61
|
+
raise ArgumentError, "unsupported mode: #{mode}"
|
|
62
|
+
end
|
|
63
|
+
|
|
59
64
|
@capacity = capacity
|
|
65
|
+
@mode = mode
|
|
66
|
+
|
|
60
67
|
@data = {}
|
|
61
68
|
end
|
|
62
69
|
|
|
@@ -64,5 +71,16 @@ module Aikido::Zen
|
|
|
64
71
|
@data[key] = value
|
|
65
72
|
@data.delete(@data.each_key.first) if @data.size > @capacity
|
|
66
73
|
end
|
|
74
|
+
|
|
75
|
+
def [](key)
|
|
76
|
+
@data[key] = @data.delete(key) if @mode == :lru && key?(key)
|
|
77
|
+
@data[key]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fetch(key, ...)
|
|
81
|
+
return self[key] if key?(key)
|
|
82
|
+
|
|
83
|
+
@data.fetch(key, ...)
|
|
84
|
+
end
|
|
67
85
|
end
|
|
68
86
|
end
|
|
@@ -52,6 +52,35 @@ module Aikido::Zen
|
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
class TrackAttackWave < Event
|
|
56
|
+
register "track_attack_wave"
|
|
57
|
+
|
|
58
|
+
def self.from_json(data)
|
|
59
|
+
new(
|
|
60
|
+
being_blocked: data[:being_blocked]
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def initialize(being_blocked:)
|
|
65
|
+
super()
|
|
66
|
+
@being_blocked = being_blocked
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def as_json
|
|
70
|
+
super.update({
|
|
71
|
+
being_blocked: @being_blocked
|
|
72
|
+
})
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def handle(collector)
|
|
76
|
+
collector.handle_track_attack_wave(being_blocked: @being_blocked)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def inspect
|
|
80
|
+
"#<#{self.class.name} #{@being_blocked}>"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
55
84
|
class TrackScan < Event
|
|
56
85
|
register "track_scan"
|
|
57
86
|
|
|
@@ -7,9 +7,24 @@ module Aikido::Zen
|
|
|
7
7
|
#
|
|
8
8
|
# Keeps track of the hostnames to which the app has made outbound HTTP
|
|
9
9
|
# requests.
|
|
10
|
-
class Collector::Hosts < Aikido::Zen::
|
|
10
|
+
class Collector::Hosts < Aikido::Zen::CappedMap
|
|
11
11
|
def initialize(config = Aikido::Zen.config)
|
|
12
12
|
super(config.max_outbound_connections)
|
|
13
13
|
end
|
|
14
|
+
|
|
15
|
+
# @param host [Aikido::Zen::OutboundConnection]
|
|
16
|
+
# @return [void]
|
|
17
|
+
def add(host)
|
|
18
|
+
self[host] ||= host
|
|
19
|
+
self[host].hit
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def each(&blk)
|
|
23
|
+
each_value(&blk)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def as_json
|
|
27
|
+
map(&:as_json)
|
|
28
|
+
end
|
|
14
29
|
end
|
|
15
30
|
end
|
|
@@ -8,7 +8,7 @@ module Aikido::Zen
|
|
|
8
8
|
# Tracks information about how the Aikido Agent is used in the app.
|
|
9
9
|
class Collector::Stats
|
|
10
10
|
# @!visibility private
|
|
11
|
-
attr_reader :started_at, :ended_at, :requests, :aborted_requests, :sinks
|
|
11
|
+
attr_reader :started_at, :ended_at, :requests, :aborted_requests, :attack_waves, :blocked_attack_waves, :sinks
|
|
12
12
|
|
|
13
13
|
# @!visibility private
|
|
14
14
|
attr_writer :ended_at
|
|
@@ -16,10 +16,13 @@ module Aikido::Zen
|
|
|
16
16
|
def initialize(config = Aikido::Zen.config)
|
|
17
17
|
super()
|
|
18
18
|
@config = config
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
@started_at = @ended_at = nil
|
|
21
21
|
@requests = 0
|
|
22
22
|
@aborted_requests = 0
|
|
23
|
+
@attack_waves = 0
|
|
24
|
+
@blocked_attack_waves = 0
|
|
25
|
+
@sinks = Hash.new { |h, k| h[k] = Collector::SinkStats.new(k, @config) }
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
# @return [Boolean]
|
|
@@ -60,6 +63,13 @@ module Aikido::Zen
|
|
|
60
63
|
@requests += 1
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
# @param being_blocked [Boolean] whether the Agent blocked the request
|
|
67
|
+
# @return [void]
|
|
68
|
+
def add_attack_wave(being_blocked:)
|
|
69
|
+
@attack_waves += 1
|
|
70
|
+
@blocked_attack_waves += 1 if being_blocked
|
|
71
|
+
end
|
|
72
|
+
|
|
63
73
|
# @param sink_name [String] the name of the sink
|
|
64
74
|
# @param duration [Float] the length the scan in seconds
|
|
65
75
|
# @param has_errors [Boolean] whether errors occurred during the scan
|
|
@@ -85,13 +95,17 @@ module Aikido::Zen
|
|
|
85
95
|
{
|
|
86
96
|
startedAt: @started_at.to_i * 1000,
|
|
87
97
|
endedAt: (@ended_at.to_i * 1000 if @ended_at),
|
|
88
|
-
|
|
98
|
+
operations: @sinks.transform_values(&:as_json),
|
|
89
99
|
requests: {
|
|
90
100
|
total: @requests,
|
|
91
101
|
aborted: @aborted_requests,
|
|
92
102
|
attacksDetected: {
|
|
93
103
|
total: total_attacks,
|
|
94
104
|
blocked: total_blocked
|
|
105
|
+
},
|
|
106
|
+
attackWaves: {
|
|
107
|
+
total: @attack_waves,
|
|
108
|
+
blocked: @blocked_attack_waves
|
|
95
109
|
}
|
|
96
110
|
}
|
|
97
111
|
}
|
data/lib/aikido/zen/collector.rb
CHANGED
|
@@ -88,6 +88,20 @@ module Aikido::Zen
|
|
|
88
88
|
synchronize(@stats) { |stats| stats.add_request }
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# Track stats about an attack detected by our scanners.
|
|
92
|
+
#
|
|
93
|
+
# @param attack [Aikido::Zen::Events::AttackWave]
|
|
94
|
+
# @return [void]
|
|
95
|
+
def track_attack_wave(being_blocked:)
|
|
96
|
+
add_event(Events::TrackAttackWave.new(being_blocked: being_blocked))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle_track_attack_wave(being_blocked:)
|
|
100
|
+
synchronize(@stats) do |stats|
|
|
101
|
+
stats.add_attack_wave(being_blocked: being_blocked)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
91
105
|
# Track stats about a scan performed by one of our sinks.
|
|
92
106
|
#
|
|
93
107
|
# @param scan [Aikido::Zen::Scan]
|
data/lib/aikido/zen/config.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Aikido::Zen
|
|
|
11
11
|
# @return [Boolean] whether Aikido should be turned completely off (no
|
|
12
12
|
# intercepting calls to protect the app, no agent process running, no
|
|
13
13
|
# middleware installed). Defaults to false (so, enabled). Can be set
|
|
14
|
-
# via the
|
|
14
|
+
# via the AIKIDO_DISABLE environment variable.
|
|
15
15
|
attr_accessor :disabled
|
|
16
16
|
alias_method :disabled?, :disabled
|
|
17
17
|
|
|
@@ -158,15 +158,34 @@ module Aikido::Zen
|
|
|
158
158
|
attr_accessor :harden
|
|
159
159
|
alias_method :harden?, :harden
|
|
160
160
|
|
|
161
|
+
# @return [Integer] how many suspicious requests are allowed before an
|
|
162
|
+
# attack wave detected event is reported.
|
|
163
|
+
# Defaults to 15 requests.
|
|
164
|
+
attr_accessor :attack_wave_threshold
|
|
165
|
+
|
|
166
|
+
# @return [Integer] the minimum time in milliseconds between requests for
|
|
167
|
+
# requests to be part of an attack wave.
|
|
168
|
+
# Defaults to 1 minute in milliseconds.
|
|
169
|
+
attr_accessor :attack_wave_min_time_between_requests
|
|
170
|
+
|
|
171
|
+
# @return [Integer] the minimum time in milliseconds between reporting
|
|
172
|
+
# attack wave events.
|
|
173
|
+
# Defaults to 20 minutes in milliseconds.
|
|
174
|
+
attr_accessor :attack_wave_min_time_between_events
|
|
175
|
+
|
|
176
|
+
# @return [Integer] the maximum number of entries in the LRU cache.
|
|
177
|
+
# Defaults to 10,000 entries.
|
|
178
|
+
attr_accessor :attack_wave_max_cache_entries
|
|
179
|
+
|
|
161
180
|
def initialize
|
|
162
|
-
self.disabled = read_boolean_from_env(ENV.fetch("AIKIDO_DISABLED", false))
|
|
181
|
+
self.disabled = read_boolean_from_env(ENV.fetch("AIKIDO_DISABLE", false)) || read_boolean_from_env(ENV.fetch("AIKIDO_DISABLED", false))
|
|
163
182
|
self.blocking_mode = read_boolean_from_env(ENV.fetch("AIKIDO_BLOCK", false))
|
|
164
183
|
self.api_timeouts = 10
|
|
165
184
|
self.api_endpoint = ENV.fetch("AIKIDO_ENDPOINT", DEFAULT_AIKIDO_ENDPOINT)
|
|
166
185
|
self.realtime_endpoint = ENV.fetch("AIKIDO_REALTIME_ENDPOINT", DEFAULT_RUNTIME_BASE_URL)
|
|
167
186
|
self.api_token = ENV.fetch("AIKIDO_TOKEN", nil)
|
|
168
|
-
self.polling_interval = 60
|
|
169
|
-
self.initial_heartbeat_delays = [30, 60 * 2]
|
|
187
|
+
self.polling_interval = 60 # 1 min
|
|
188
|
+
self.initial_heartbeat_delays = [30, 60 * 2] # 30 sec, 2 min
|
|
170
189
|
self.json_encoder = DEFAULT_JSON_ENCODER
|
|
171
190
|
self.json_decoder = DEFAULT_JSON_DECODER
|
|
172
191
|
self.debugging = read_boolean_from_env(ENV.fetch("AIKIDO_DEBUG", false))
|
|
@@ -181,8 +200,8 @@ module Aikido::Zen
|
|
|
181
200
|
self.blocked_responder = DEFAULT_BLOCKED_RESPONDER
|
|
182
201
|
self.rate_limited_responder = DEFAULT_RATE_LIMITED_RESPONDER
|
|
183
202
|
self.rate_limiting_discriminator = DEFAULT_RATE_LIMITING_DISCRIMINATOR
|
|
184
|
-
self.server_rate_limit_deadline =
|
|
185
|
-
self.client_rate_limit_period =
|
|
203
|
+
self.server_rate_limit_deadline = 30 * 60 # 30 min
|
|
204
|
+
self.client_rate_limit_period = 60 * 60 # 1 hour
|
|
186
205
|
self.client_rate_limit_max_events = 100
|
|
187
206
|
self.collect_api_schema = read_boolean_from_env(ENV.fetch("AIKIDO_FEATURE_COLLECT_API_SCHEMA", true))
|
|
188
207
|
self.api_schema_max_samples = Integer(ENV.fetch("AIKIDO_MAX_API_DISCOVERY_SAMPLES", 10))
|
|
@@ -191,6 +210,10 @@ module Aikido::Zen
|
|
|
191
210
|
self.stored_ssrf = read_boolean_from_env(ENV.fetch("AIKIDO_FEATURE_STORED_SSRF", true))
|
|
192
211
|
self.imds_allowed_hosts = ["metadata.google.internal", "metadata.goog"]
|
|
193
212
|
self.harden = read_boolean_from_env(ENV.fetch("AIKIDO_HARDEN", true))
|
|
213
|
+
self.attack_wave_threshold = 15
|
|
214
|
+
self.attack_wave_min_time_between_requests = 60 * 1000 # 1 min (ms)
|
|
215
|
+
self.attack_wave_min_time_between_events = 20 * 60 * 1000 # 20 min (ms)
|
|
216
|
+
self.attack_wave_max_cache_entries = 10_000
|
|
194
217
|
end
|
|
195
218
|
|
|
196
219
|
# Set the base URL for API requests.
|
|
@@ -6,6 +6,9 @@ require_relative "../request/heuristic_router"
|
|
|
6
6
|
module Aikido::Zen
|
|
7
7
|
# @!visibility private
|
|
8
8
|
Context::RACK_REQUEST_BUILDER = ->(env) do
|
|
9
|
+
# Normalize PATH_INFO so routes are correctly recognized in middleware.
|
|
10
|
+
env["PATH_INFO"] = Helpers.normalize_path(env["PATH_INFO"])
|
|
11
|
+
|
|
9
12
|
delegate = Rack::Request.new(env)
|
|
10
13
|
router = Aikido::Zen::Request::HeuristicRouter.new
|
|
11
14
|
request = Aikido::Zen::Request.new(delegate, framework: "rack", router: router)
|
|
@@ -12,6 +12,9 @@ module Aikido::Zen
|
|
|
12
12
|
|
|
13
13
|
# @!visibility private
|
|
14
14
|
Context::RAILS_REQUEST_BUILDER = ->(env) do
|
|
15
|
+
# Normalize PATH_INFO so routes are correctly recognized in middleware.
|
|
16
|
+
env["PATH_INFO"] = Helpers.normalize_path(env["PATH_INFO"])
|
|
17
|
+
|
|
15
18
|
# Duplicate the Rack environment to prevent unexpected modifications from
|
|
16
19
|
# breaking Rails routing.
|
|
17
20
|
delegate = ActionDispatch::Request.new(env.dup)
|
data/lib/aikido/zen/context.rb
CHANGED
|
@@ -81,8 +81,8 @@ module Aikido::Zen
|
|
|
81
81
|
def protection_disabled?
|
|
82
82
|
return false if request.nil?
|
|
83
83
|
|
|
84
|
-
!@settings.endpoints
|
|
85
|
-
@settings.
|
|
84
|
+
!@settings.endpoints.match(request.route).all?(&:protected?) ||
|
|
85
|
+
@settings.allowed_ips.include?(request.ip)
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
# @!visibility private
|
data/lib/aikido/zen/event.rb
CHANGED
|
@@ -41,8 +41,10 @@ module Aikido::Zen
|
|
|
41
41
|
|
|
42
42
|
def as_json
|
|
43
43
|
super.update(
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
{
|
|
45
|
+
attack: @attack.as_json,
|
|
46
|
+
request: @attack.context&.request&.as_json
|
|
47
|
+
}.compact
|
|
46
48
|
)
|
|
47
49
|
end
|
|
48
50
|
end
|
|
@@ -67,5 +69,48 @@ module Aikido::Zen
|
|
|
67
69
|
)
|
|
68
70
|
end
|
|
69
71
|
end
|
|
72
|
+
|
|
73
|
+
class AttackWave < Event
|
|
74
|
+
# @param [Aikido::Zen::Context] a context
|
|
75
|
+
# @return [Aikido::Zen::Events::AttackWave] an attack wave event
|
|
76
|
+
def self.from_context(context)
|
|
77
|
+
request = Aikido::Zen::AttackWave::Request.new(
|
|
78
|
+
ip_address: context.request.client_ip,
|
|
79
|
+
user_agent: context.request.user_agent,
|
|
80
|
+
source: context.request.framework
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
attack = Aikido::Zen::AttackWave::Attack.new(
|
|
84
|
+
metadata: {}, # not used yet
|
|
85
|
+
user: context.request.actor
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
new(request: request, attack: attack)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Aikido::Zen::AttackWave::Request]
|
|
92
|
+
attr_reader :request
|
|
93
|
+
|
|
94
|
+
# @return [Aikido::Zen::AttackWave::Attack]
|
|
95
|
+
attr_reader :attack
|
|
96
|
+
|
|
97
|
+
# @param [Aikido::Zen::AttackWave::Request] the attack wave request
|
|
98
|
+
# @param [Aikido::Zen::AttackWave::Attack] the attack wave attack
|
|
99
|
+
# @param opts [Hash<Symbol, Object>] any other options to pass to
|
|
100
|
+
# the superclass initializer.
|
|
101
|
+
# @return [Aikido::Zen::Events::AttackWave] an attack wave event
|
|
102
|
+
def initialize(request:, attack:, **opts)
|
|
103
|
+
super(type: "detected_attack_wave", **opts)
|
|
104
|
+
@request = request
|
|
105
|
+
@attack = attack
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def as_json
|
|
109
|
+
super.update(
|
|
110
|
+
request: @request.as_json,
|
|
111
|
+
attack: @attack.as_json
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
70
115
|
end
|
|
71
116
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aikido
|
|
4
|
+
module Zen
|
|
5
|
+
# @api private
|
|
6
|
+
module Helpers
|
|
7
|
+
# Normalizes a path by:
|
|
8
|
+
#
|
|
9
|
+
# 1. Collapsing consecutive forward slashes into a single forward slash.
|
|
10
|
+
# 2. Removing forward trailing slash, unless the normalized path is "/".
|
|
11
|
+
#
|
|
12
|
+
# @param path [String, nil] the path to normalize.
|
|
13
|
+
# @return [String, nil] the normalized path.
|
|
14
|
+
def self.normalize_path(path)
|
|
15
|
+
return path unless path
|
|
16
|
+
|
|
17
|
+
normalized_path = path.dup
|
|
18
|
+
normalized_path.squeeze!("/")
|
|
19
|
+
normalized_path.chomp!("/") unless normalized_path == "/"
|
|
20
|
+
normalized_path
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Aikido::Zen
|
|
4
4
|
module Middleware
|
|
5
5
|
# Middleware that rejects requests from IPs blocked in the Aikido dashboard.
|
|
6
|
-
class
|
|
6
|
+
class AllowedAddressChecker
|
|
7
7
|
def initialize(app, config: Aikido::Zen.config, settings: Aikido::Zen.runtime_settings)
|
|
8
8
|
@app = app
|
|
9
9
|
@config = config
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aikido
|
|
4
|
+
module Zen
|
|
5
|
+
module Middleware
|
|
6
|
+
class AttackWaveProtector
|
|
7
|
+
def initialize(app, zen: Aikido::Zen, settings: Aikido::Zen.runtime_settings)
|
|
8
|
+
@app = app
|
|
9
|
+
@zen = zen
|
|
10
|
+
@settings = settings
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
response = @app.call(env)
|
|
15
|
+
|
|
16
|
+
context = @zen.current_context
|
|
17
|
+
protect(context)
|
|
18
|
+
|
|
19
|
+
response
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @api private
|
|
23
|
+
# Visible for testing.
|
|
24
|
+
def attack_wave?(context)
|
|
25
|
+
request = context.request
|
|
26
|
+
return false if request.nil?
|
|
27
|
+
|
|
28
|
+
# Bypass attack wave protection for allowed IPs
|
|
29
|
+
return false if @settings.allowed_ips.include?(request.ip)
|
|
30
|
+
|
|
31
|
+
@zen.attack_wave_detector.attack_wave?(context)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @api private
|
|
35
|
+
# Visible for testing.
|
|
36
|
+
def protect(context)
|
|
37
|
+
if attack_wave?(context)
|
|
38
|
+
attack_wave = Aikido::Zen::Events::AttackWave.from_context(context)
|
|
39
|
+
@zen.track_attack_wave(attack_wave)
|
|
40
|
+
@zen.agent.report(attack_wave)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -9,7 +9,7 @@ module Aikido::Zen
|
|
|
9
9
|
module Middleware
|
|
10
10
|
# Rack middleware that keeps the current context in a Thread/Fiber-local
|
|
11
11
|
# variable so that other parts of the agent/firewall can access it.
|
|
12
|
-
class
|
|
12
|
+
class ContextSetter
|
|
13
13
|
def initialize(app)
|
|
14
14
|
@app = app
|
|
15
15
|
end
|
|
@@ -33,8 +33,10 @@ module Aikido::Zen
|
|
|
33
33
|
private
|
|
34
34
|
|
|
35
35
|
def should_throttle?(request)
|
|
36
|
+
# Bypass rate limiting for allowed IPs
|
|
37
|
+
return false if @settings.allowed_ips.include?(request.ip)
|
|
38
|
+
|
|
36
39
|
return false unless @settings.endpoints[request.route].rate_limiting.enabled?
|
|
37
|
-
return false if @settings.skip_protection_for_ips.include?(request.ip)
|
|
38
40
|
|
|
39
41
|
result = @detached_agent.calculate_rate_limits(request)
|
|
40
42
|
|
|
@@ -5,8 +5,9 @@ module Aikido::Zen
|
|
|
5
5
|
# Rack middleware used to track request
|
|
6
6
|
# It implements the logic under that which is considered worthy of being tracked.
|
|
7
7
|
class RequestTracker
|
|
8
|
-
def initialize(app)
|
|
8
|
+
def initialize(app, settings: Aikido::Zen.runtime_settings)
|
|
9
9
|
@app = app
|
|
10
|
+
@settings = settings
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def call(env)
|
|
@@ -16,7 +17,8 @@ module Aikido::Zen
|
|
|
16
17
|
if request.route && track?(
|
|
17
18
|
status_code: response[0],
|
|
18
19
|
route: request.route.path,
|
|
19
|
-
http_method: request.request_method
|
|
20
|
+
http_method: request.request_method,
|
|
21
|
+
ip: request.ip
|
|
20
22
|
)
|
|
21
23
|
Aikido::Zen.track_request(request)
|
|
22
24
|
|
|
@@ -126,7 +128,10 @@ module Aikido::Zen
|
|
|
126
128
|
# @param status_code [Integer]
|
|
127
129
|
# @param route [String]
|
|
128
130
|
# @param http_method [String]
|
|
129
|
-
def track?(status_code:, route:, http_method:)
|
|
131
|
+
def track?(status_code:, route:, http_method:, ip: nil)
|
|
132
|
+
# Bypass request and route tracking for allowed IPs
|
|
133
|
+
return false if @settings.allowed_ips.include?(ip)
|
|
134
|
+
|
|
130
135
|
# In the UI we want to show only successful (2xx) or redirect (3xx) responses
|
|
131
136
|
# anything else is discarded.
|
|
132
137
|
return false unless status_code >= 200 && status_code <= 399
|
|
@@ -25,13 +25,23 @@ module Aikido::Zen
|
|
|
25
25
|
# @return [Integer] the port number to which the connection was attempted.
|
|
26
26
|
attr_reader :port
|
|
27
27
|
|
|
28
|
+
# @return [Integer] the number of times that this connection was seen by
|
|
29
|
+
# the hosts collector.
|
|
30
|
+
attr_reader :hits
|
|
31
|
+
|
|
28
32
|
def initialize(host:, port:)
|
|
29
33
|
@host = host
|
|
30
34
|
@port = port
|
|
31
35
|
end
|
|
32
36
|
|
|
37
|
+
def hit
|
|
38
|
+
# Lazy initialize @hits, so it stays nil until the connection is tracked.
|
|
39
|
+
@hits ||= 0
|
|
40
|
+
@hits += 1
|
|
41
|
+
end
|
|
42
|
+
|
|
33
43
|
def as_json
|
|
34
|
-
{hostname: host, port: port}
|
|
44
|
+
{hostname: host, port: port, hits: hits}.compact
|
|
35
45
|
end
|
|
36
46
|
|
|
37
47
|
def ==(other)
|
|
@@ -12,8 +12,9 @@ module Aikido::Zen
|
|
|
12
12
|
initializer "aikido.add_middleware" do |app|
|
|
13
13
|
app.middleware.insert_before 0, Aikido::Zen::Middleware::ForkDetector
|
|
14
14
|
|
|
15
|
-
app.middleware.use Aikido::Zen::Middleware::
|
|
16
|
-
app.middleware.use Aikido::Zen::Middleware::
|
|
15
|
+
app.middleware.use Aikido::Zen::Middleware::ContextSetter
|
|
16
|
+
app.middleware.use Aikido::Zen::Middleware::AllowedAddressChecker
|
|
17
|
+
app.middleware.use Aikido::Zen::Middleware::AttackWaveProtector
|
|
17
18
|
# Request Tracker stats do not consider failed request or 40x, so the middleware
|
|
18
19
|
# must be the last one wrapping the request.
|
|
19
20
|
app.middleware.use Aikido::Zen::Middleware::RequestTracker
|
|
@@ -62,16 +62,31 @@ module Aikido::Zen
|
|
|
62
62
|
nil
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
-
private
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def build_route(route, request, prefix: request.script_name)
|
|
66
68
|
route_wrapper = ActionDispatch::Routing::RouteWrapper.new(route)
|
|
67
69
|
|
|
68
70
|
path = if prefix.present?
|
|
69
|
-
|
|
71
|
+
prefix_route_path(prefix.to_s, route_wrapper.path)
|
|
70
72
|
else
|
|
71
73
|
route_wrapper.path
|
|
72
74
|
end
|
|
73
75
|
|
|
74
76
|
Aikido::Zen::Route.new(verb: request.request_method, path: path)
|
|
75
77
|
end
|
|
78
|
+
|
|
79
|
+
def prefix_route_path(string1, string2)
|
|
80
|
+
# The strings appear to start with "/", allowing them to be concatenated
|
|
81
|
+
# directly after removing trailing "/". However, as it is not currently
|
|
82
|
+
# known whether this is guaranteed, we insert a separator when necessary.
|
|
83
|
+
|
|
84
|
+
separator = string2.start_with?("/") ? "" : "/"
|
|
85
|
+
|
|
86
|
+
string1 = string1.chomp("/")
|
|
87
|
+
string2 = string2.chomp("/")
|
|
88
|
+
|
|
89
|
+
"#{string1}#{separator}#{string2}"
|
|
90
|
+
end
|
|
76
91
|
end
|
|
77
92
|
end
|