aikido-zen 1.0.2.beta.9-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 +9 -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 +35 -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 +35 -3
- 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/file.rb +34 -32
- 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,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cache"
|
|
4
|
+
require_relative "attack_wave/helpers"
|
|
5
|
+
|
|
6
|
+
module Aikido::Zen
|
|
7
|
+
module AttackWave
|
|
8
|
+
class Detector
|
|
9
|
+
def initialize(config: Aikido::Zen.config, clock: nil)
|
|
10
|
+
@config = config
|
|
11
|
+
|
|
12
|
+
@event_times = Cache.new(@config.attack_wave_max_cache_entries, ttl: @config.attack_wave_min_time_between_events, clock: clock)
|
|
13
|
+
|
|
14
|
+
@request_counts = Cache.new(@config.attack_wave_max_cache_entries, 0, ttl: @config.attack_wave_min_time_between_requests, clock: clock)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def attack_wave?(context)
|
|
18
|
+
client_ip = context.request.client_ip
|
|
19
|
+
|
|
20
|
+
return false unless client_ip
|
|
21
|
+
|
|
22
|
+
return false if @event_times[client_ip]
|
|
23
|
+
|
|
24
|
+
return false unless AttackWave::Helpers.web_scanner?(context)
|
|
25
|
+
|
|
26
|
+
request_count = @request_counts[client_ip] += 1
|
|
27
|
+
|
|
28
|
+
return false if request_count < @config.attack_wave_threshold
|
|
29
|
+
|
|
30
|
+
@event_times[client_ip] = Time.now.utc
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class Request
|
|
37
|
+
# @return [String]
|
|
38
|
+
attr_reader :ip_address
|
|
39
|
+
|
|
40
|
+
# @return [String]
|
|
41
|
+
attr_reader :user_agent
|
|
42
|
+
|
|
43
|
+
# @return [String]
|
|
44
|
+
attr_reader :source
|
|
45
|
+
|
|
46
|
+
# @param ip_address [String]
|
|
47
|
+
# @param user_agent [String]
|
|
48
|
+
# @param source [String]
|
|
49
|
+
# @return [Aikido::Zen::AttackWave::Request]
|
|
50
|
+
def initialize(ip_address:, user_agent:, source:)
|
|
51
|
+
@ip_address = ip_address
|
|
52
|
+
@user_agent = user_agent
|
|
53
|
+
@source = source
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def as_json
|
|
57
|
+
{
|
|
58
|
+
ipAddress: @ip_address,
|
|
59
|
+
userAgent: @user_agent,
|
|
60
|
+
source: @source
|
|
61
|
+
}.compact
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class Attack
|
|
66
|
+
# @return [Hash<String, String>]
|
|
67
|
+
attr_reader :metadata
|
|
68
|
+
|
|
69
|
+
# @return [Aikido::Zen::Actor]
|
|
70
|
+
attr_reader :user
|
|
71
|
+
|
|
72
|
+
# @param metadata [Hash<String, String>]
|
|
73
|
+
# @param metadata [Aikido::Zen::Actor]
|
|
74
|
+
# @return [Aikido::Zen::AttackWave::Attack]
|
|
75
|
+
def initialize(metadata:, user:)
|
|
76
|
+
@metadata = metadata
|
|
77
|
+
@user = user
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def as_json
|
|
81
|
+
{
|
|
82
|
+
metadata: @metadata.as_json,
|
|
83
|
+
user: @user.as_json
|
|
84
|
+
}.compact
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -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
|
|
|
@@ -153,15 +153,39 @@ module Aikido::Zen
|
|
|
153
153
|
# allow known hosts that should be able to resolve to the IMDS service.
|
|
154
154
|
attr_accessor :imds_allowed_hosts
|
|
155
155
|
|
|
156
|
+
# @return [Boolean] whether Aikido Zen should harden methods where possible.
|
|
157
|
+
# Defaults to true. Can be set through AIKIDO_HARDEN environment variable.
|
|
158
|
+
attr_accessor :harden
|
|
159
|
+
alias_method :harden?, :harden
|
|
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
|
+
|
|
156
180
|
def initialize
|
|
157
|
-
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))
|
|
158
182
|
self.blocking_mode = read_boolean_from_env(ENV.fetch("AIKIDO_BLOCK", false))
|
|
159
183
|
self.api_timeouts = 10
|
|
160
184
|
self.api_endpoint = ENV.fetch("AIKIDO_ENDPOINT", DEFAULT_AIKIDO_ENDPOINT)
|
|
161
185
|
self.realtime_endpoint = ENV.fetch("AIKIDO_REALTIME_ENDPOINT", DEFAULT_RUNTIME_BASE_URL)
|
|
162
186
|
self.api_token = ENV.fetch("AIKIDO_TOKEN", nil)
|
|
163
|
-
self.polling_interval = 60
|
|
164
|
-
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
|
|
165
189
|
self.json_encoder = DEFAULT_JSON_ENCODER
|
|
166
190
|
self.json_decoder = DEFAULT_JSON_DECODER
|
|
167
191
|
self.debugging = read_boolean_from_env(ENV.fetch("AIKIDO_DEBUG", false))
|
|
@@ -176,8 +200,8 @@ module Aikido::Zen
|
|
|
176
200
|
self.blocked_responder = DEFAULT_BLOCKED_RESPONDER
|
|
177
201
|
self.rate_limited_responder = DEFAULT_RATE_LIMITED_RESPONDER
|
|
178
202
|
self.rate_limiting_discriminator = DEFAULT_RATE_LIMITING_DISCRIMINATOR
|
|
179
|
-
self.server_rate_limit_deadline =
|
|
180
|
-
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
|
|
181
205
|
self.client_rate_limit_max_events = 100
|
|
182
206
|
self.collect_api_schema = read_boolean_from_env(ENV.fetch("AIKIDO_FEATURE_COLLECT_API_SCHEMA", true))
|
|
183
207
|
self.api_schema_max_samples = Integer(ENV.fetch("AIKIDO_MAX_API_DISCOVERY_SAMPLES", 10))
|
|
@@ -185,6 +209,11 @@ module Aikido::Zen
|
|
|
185
209
|
self.api_schema_collection_max_properties = 20
|
|
186
210
|
self.stored_ssrf = read_boolean_from_env(ENV.fetch("AIKIDO_FEATURE_STORED_SSRF", true))
|
|
187
211
|
self.imds_allowed_hosts = ["metadata.google.internal", "metadata.goog"]
|
|
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
|
|
188
217
|
end
|
|
189
218
|
|
|
190
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
|
|
@@ -99,13 +99,45 @@ module Aikido::Zen
|
|
|
99
99
|
extract_payloads_from(value, source_type, [prefix, key].compact.join("."))
|
|
100
100
|
end
|
|
101
101
|
elsif data.respond_to?(:to_ary)
|
|
102
|
-
data.to_ary
|
|
102
|
+
array = data.to_ary
|
|
103
|
+
return array if array.empty?
|
|
104
|
+
|
|
105
|
+
payloads = array.flat_map.with_index do |value, index|
|
|
103
106
|
extract_payloads_from(value, source_type, [prefix, index].compact.join("."))
|
|
104
107
|
end
|
|
108
|
+
|
|
109
|
+
unless Aikido::Zen.config.harden?
|
|
110
|
+
# Special case for File.join given a possibly nested array of strings,
|
|
111
|
+
# as might occur when a query parameter is an array.
|
|
112
|
+
begin
|
|
113
|
+
string = File.join__internal_for_aikido_zen(*array)
|
|
114
|
+
if unsafe_path?(string)
|
|
115
|
+
payloads << Payload.new(string, source_type, [prefix, "__File.join__"].compact.join("."))
|
|
116
|
+
end
|
|
117
|
+
rescue
|
|
118
|
+
# Could not create special payload for File.join.
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
payloads
|
|
105
123
|
else
|
|
106
124
|
[Payload.new(data, source_type, prefix.to_s)]
|
|
107
125
|
end
|
|
108
126
|
end
|
|
127
|
+
|
|
128
|
+
def unsafe_path?(filepath)
|
|
129
|
+
normalized_filepath = Pathname.new(filepath).cleanpath.to_s.downcase
|
|
130
|
+
|
|
131
|
+
Scanners::PathTraversal::DANGEROUS_PATH_PARTS.each do |dangerous_path_part|
|
|
132
|
+
return true if normalized_filepath.include?(dangerous_path_part)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
Scanners::PathTraversal::DANGEROUS_PATH_STARTS.each do |dangerous_path_start|
|
|
136
|
+
return true if normalized_filepath.start_with?(dangerous_path_start)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
false
|
|
140
|
+
end
|
|
109
141
|
end
|
|
110
142
|
end
|
|
111
143
|
|
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
|