aikido-zen 1.0.2.beta.9-x86_64-linux-musl → 1.0.2-x86_64-linux-musl

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/docs/config.md +9 -1
  4. data/docs/troubleshooting.md +62 -0
  5. data/lib/aikido/zen/agent.rb +2 -2
  6. data/lib/aikido/zen/attack.rb +8 -6
  7. data/lib/aikido/zen/attack_wave/helpers.rb +457 -0
  8. data/lib/aikido/zen/attack_wave.rb +88 -0
  9. data/lib/aikido/zen/cache.rb +91 -0
  10. data/lib/aikido/zen/capped_collections.rb +22 -4
  11. data/lib/aikido/zen/collector/event.rb +29 -0
  12. data/lib/aikido/zen/collector/hosts.rb +16 -1
  13. data/lib/aikido/zen/collector/stats.rb +17 -3
  14. data/lib/aikido/zen/collector/users.rb +2 -2
  15. data/lib/aikido/zen/collector.rb +14 -0
  16. data/lib/aikido/zen/config.rb +35 -6
  17. data/lib/aikido/zen/context/rack_request.rb +3 -0
  18. data/lib/aikido/zen/context/rails_request.rb +3 -0
  19. data/lib/aikido/zen/context.rb +35 -3
  20. data/lib/aikido/zen/event.rb +47 -2
  21. data/lib/aikido/zen/helpers.rb +24 -0
  22. data/lib/aikido/zen/middleware/{check_allowed_addresses.rb → allowed_address_checker.rb} +1 -1
  23. data/lib/aikido/zen/middleware/attack_wave_protector.rb +46 -0
  24. data/lib/aikido/zen/middleware/{set_context.rb → context_setter.rb} +1 -1
  25. data/lib/aikido/zen/middleware/rack_throttler.rb +3 -1
  26. data/lib/aikido/zen/middleware/request_tracker.rb +8 -3
  27. data/lib/aikido/zen/outbound_connection.rb +11 -1
  28. data/lib/aikido/zen/rails_engine.rb +3 -2
  29. data/lib/aikido/zen/request/rails_router.rb +17 -2
  30. data/lib/aikido/zen/request.rb +2 -36
  31. data/lib/aikido/zen/route.rb +50 -0
  32. data/lib/aikido/zen/runtime_settings/endpoints.rb +37 -8
  33. data/lib/aikido/zen/runtime_settings.rb +5 -4
  34. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +3 -2
  35. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +3 -2
  36. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +3 -2
  37. data/lib/aikido/zen/scanners/ssrf_scanner.rb +2 -1
  38. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +5 -1
  39. data/lib/aikido/zen/sinks/action_controller.rb +3 -1
  40. data/lib/aikido/zen/sinks/file.rb +34 -32
  41. data/lib/aikido/zen/sinks/socket.rb +7 -0
  42. data/lib/aikido/zen/system_info.rb +1 -5
  43. data/lib/aikido/zen/version.rb +1 -1
  44. data/lib/aikido/zen.rb +55 -6
  45. data/tasklib/bench.rake +1 -1
  46. 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
- :[], :fetch, :delete, :key?,
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::CappedSet
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
- @sinks = Hash.new { |h, k| h[k] = Collector::SinkStats.new(k, @config) }
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
- sinks: @sinks.transform_values(&:as_json),
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
  }
@@ -21,8 +21,8 @@ module Aikido::Zen
21
21
  end
22
22
  end
23
23
 
24
- def each(&b)
25
- each_value(&b)
24
+ def each(&blk)
25
+ each_value(&blk)
26
26
  end
27
27
 
28
28
  def as_json
@@ -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]
@@ -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 AIKIDO_DISABLED environment variable.
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 = 1800 # 30 min
180
- self.client_rate_limit_period = 3600 # 1 hour
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)
@@ -81,8 +81,8 @@ module Aikido::Zen
81
81
  def protection_disabled?
82
82
  return false if request.nil?
83
83
 
84
- !@settings.endpoints[request.route].protected? ||
85
- @settings.skip_protection_for_ips.include?(request.ip)
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.flat_map.with_index do |value, index|
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
 
@@ -41,8 +41,10 @@ module Aikido::Zen
41
41
 
42
42
  def as_json
43
43
  super.update(
44
- attack: @attack.as_json,
45
- request: @attack.context.request.as_json
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 CheckAllowedAddresses
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 SetContext
12
+ class ContextSetter
13
13
  def initialize(app)
14
14
  @app = app
15
15
  end