aikido-zen 0.2.0-x86_64-darwin → 1.0.1.beta.2-x86_64-darwin

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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.aikido +6 -0
  3. data/.simplecov +6 -0
  4. data/README.md +67 -83
  5. data/benchmarks/README.md +8 -12
  6. data/docs/rails.md +1 -1
  7. data/lib/aikido/zen/agent.rb +10 -8
  8. data/lib/aikido/zen/api_client.rb +14 -4
  9. data/lib/aikido/zen/background_worker.rb +52 -0
  10. data/lib/aikido/zen/collector.rb +12 -1
  11. data/lib/aikido/zen/config.rb +20 -0
  12. data/lib/aikido/zen/context.rb +4 -0
  13. data/lib/aikido/zen/detached_agent/agent.rb +78 -0
  14. data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
  15. data/lib/aikido/zen/detached_agent/server.rb +41 -0
  16. data/lib/aikido/zen/detached_agent.rb +2 -0
  17. data/lib/aikido/zen/errors.rb +8 -0
  18. data/lib/aikido/zen/internals.rb +41 -7
  19. data/lib/aikido/zen/libzen-v0.1.39-x86_64-darwin.dylib +0 -0
  20. data/lib/aikido/zen/middleware/rack_throttler.rb +9 -3
  21. data/lib/aikido/zen/middleware/request_tracker.rb +6 -4
  22. data/lib/aikido/zen/outbound_connection_monitor.rb +4 -0
  23. data/lib/aikido/zen/rails_engine.rb +8 -8
  24. data/lib/aikido/zen/rate_limiter/breaker.rb +3 -3
  25. data/lib/aikido/zen/rate_limiter.rb +6 -11
  26. data/lib/aikido/zen/request/heuristic_router.rb +6 -0
  27. data/lib/aikido/zen/request/rails_router.rb +6 -18
  28. data/lib/aikido/zen/request/schema/auth_schemas.rb +14 -0
  29. data/lib/aikido/zen/request/schema.rb +18 -0
  30. data/lib/aikido/zen/runtime_settings.rb +2 -2
  31. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +4 -2
  32. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +4 -2
  33. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +4 -2
  34. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +33 -21
  35. data/lib/aikido/zen/scanners/ssrf_scanner.rb +6 -1
  36. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +6 -0
  37. data/lib/aikido/zen/sink.rb +11 -1
  38. data/lib/aikido/zen/sinks/action_controller.rb +9 -4
  39. data/lib/aikido/zen/sinks/async_http.rb +35 -16
  40. data/lib/aikido/zen/sinks/curb.rb +52 -26
  41. data/lib/aikido/zen/sinks/em_http.rb +39 -25
  42. data/lib/aikido/zen/sinks/excon.rb +63 -45
  43. data/lib/aikido/zen/sinks/file.rb +67 -71
  44. data/lib/aikido/zen/sinks/http.rb +38 -19
  45. data/lib/aikido/zen/sinks/httpclient.rb +51 -22
  46. data/lib/aikido/zen/sinks/httpx.rb +37 -18
  47. data/lib/aikido/zen/sinks/kernel.rb +18 -57
  48. data/lib/aikido/zen/sinks/mysql2.rb +19 -7
  49. data/lib/aikido/zen/sinks/net_http.rb +37 -19
  50. data/lib/aikido/zen/sinks/patron.rb +41 -24
  51. data/lib/aikido/zen/sinks/pg.rb +50 -27
  52. data/lib/aikido/zen/sinks/resolv.rb +37 -16
  53. data/lib/aikido/zen/sinks/socket.rb +46 -17
  54. data/lib/aikido/zen/sinks/sqlite3.rb +31 -12
  55. data/lib/aikido/zen/sinks/trilogy.rb +19 -7
  56. data/lib/aikido/zen/sinks.rb +29 -20
  57. data/lib/aikido/zen/sinks_dsl.rb +226 -0
  58. data/lib/aikido/zen/version.rb +2 -2
  59. data/lib/aikido/zen/worker.rb +5 -0
  60. data/lib/aikido/zen.rb +59 -9
  61. data/placeholder/.gitignore +4 -0
  62. data/placeholder/README.md +11 -0
  63. data/placeholder/Rakefile +75 -0
  64. data/placeholder/lib/placeholder.rb.template +3 -0
  65. data/placeholder/placeholder.gemspec.template +20 -0
  66. data/tasklib/bench.rake +29 -6
  67. data/tasklib/libzen.rake +70 -66
  68. data/tasklib/wrk.rb +88 -0
  69. metadata +23 -13
  70. data/CHANGELOG.md +0 -25
  71. data/lib/aikido/zen/libzen-v0.1.37.x86_64.dylib +0 -0
  72. data/lib/aikido.rb +0 -3
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # dRB Front object that will work as a bridge communication between child & parent
4
+ # processes.
5
+ # Every method is called from the child but it runs in the parent process.
6
+ module Aikido::Zen::DetachedAgent
7
+ class FrontObject
8
+ def initialize(
9
+ config: Aikido::Zen.config,
10
+ collector: Aikido::Zen.collector,
11
+ runtime_settings: Aikido::Zen.runtime_settings,
12
+ rate_limiter: Aikido::Zen::RateLimiter.new
13
+ )
14
+ @config = config
15
+ @collector = collector
16
+ @rate_limiter = rate_limiter
17
+ @runtime_settings = runtime_settings
18
+ end
19
+
20
+ RequestKind = Struct.new(:route, :schema, :ip, :actor)
21
+
22
+ def send_heartbeat_to_parent_process(heartbeat)
23
+ @collector.push_heartbeat(heartbeat)
24
+ end
25
+
26
+ # Method called by child processes to get an up-to-date version of the
27
+ # runtime_settings
28
+ def updated_settings
29
+ @runtime_settings
30
+ end
31
+
32
+ def calculate_rate_limits(route, ip, actor_hash)
33
+ actor = Aikido::Zen::Actor(actor_hash) if actor_hash
34
+ @rate_limiter.calculate_rate_limits(RequestKind.new(route, nil, ip, actor))
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen::DetachedAgent
4
+ class Server
5
+ def initialize(config: Aikido::Zen.config)
6
+ @detached_agent_front = FrontObject.new
7
+ @drb_server = DRb.start_service(config.detached_agent_socket_path, @detached_agent_front)
8
+
9
+ # We don't want to see drb logs unless in debug mode
10
+ @drb_server.verbose = config.logger.debug?
11
+ end
12
+
13
+ def alive?
14
+ @drb_server.alive?
15
+ end
16
+
17
+ def stop!
18
+ @drb_server.stop_service
19
+ DRb.stop_service
20
+ end
21
+
22
+ class << self
23
+ def start!
24
+ Aikido::Zen.config.logger.debug("Starting DRb Server...")
25
+ max_attempts = 10
26
+ @server = new
27
+
28
+ attempts = 0
29
+ until @server.alive?
30
+ Aikido::Zen.config.logger.info("DRb Server still not alive. #{max_attempts - attempts} attempts remaining")
31
+ sleep 0.1
32
+ attempts += 1
33
+ raise Aikido::Zen::DetachedAgentError.new("Impossible to start the dRB server (socket=#{Aikido::Zen.config.detached_agent_socket_path})") \
34
+ if attempts == max_attempts
35
+ end
36
+
37
+ @server
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,2 @@
1
+ require_relative "detached_agent/agent"
2
+ require_relative "detached_agent/server"
@@ -95,5 +95,13 @@ module Aikido
95
95
  MSG
96
96
  end
97
97
  end
98
+
99
+ class DetachedAgentError < ZenError
100
+ extend Forwardable
101
+
102
+ def initialize(msg)
103
+ super
104
+ end
105
+ end
98
106
  end
99
107
  end
@@ -13,14 +13,48 @@ module Aikido::Zen
13
13
  attr_accessor :libzen_name
14
14
  end
15
15
 
16
- self.libzen_name = [
17
- "libzen-v#{LIBZEN_VERSION}",
18
- FFI::Platform::ARCH,
19
- FFI::Platform::LIBSUFFIX
20
- ].join(".")
16
+ def self.libzen_names
17
+ lib_name = "libzen-v#{LIBZEN_VERSION}"
18
+ lib_ext = FFI::Platform::LIBSUFFIX
19
+
20
+ # Gem::Platform#version should be understood as an arbitrary Ruby defined
21
+ # OS specific string. A platform with a version string is considered more
22
+ # specific than a platform without a version string.
23
+ # https://docs.ruby-lang.org/en/3.3/Gem/Platform.html
24
+
25
+ platform = Gem::Platform.local.dup
26
+
27
+ # Library names in preferred order.
28
+ #
29
+ # If two library names are added, the specific platform library names is
30
+ # first and the generic platform library name is second.
31
+ names = []
32
+
33
+ names << "#{lib_name}-#{platform}.#{lib_ext}"
34
+
35
+ unless platform.version.nil?
36
+ platform.version = nil
37
+ names << "#{lib_name}-#{platform}.#{lib_ext}"
38
+ end
39
+
40
+ names
41
+ end
42
+
43
+ # Load the most specific library
44
+ def self.load_libzen
45
+ libzen_names.each do |libzen_name|
46
+ libzen_path = File.expand_path(libzen_name, __dir__)
47
+ begin
48
+ return ffi_lib(libzen_path)
49
+ rescue LoadError
50
+ # empty
51
+ end
52
+ end
53
+ raise LoadError, "Could not load libzen"
54
+ end
21
55
 
22
56
  begin
23
- ffi_lib File.expand_path(libzen_name, __dir__)
57
+ load_libzen
24
58
 
25
59
  # @!method self.detect_sql_injection_native(query, input, dialect)
26
60
  # @param (see .detect_sql_injection)
@@ -30,7 +64,7 @@ module Aikido::Zen
30
64
  # calling libzen.
31
65
  attach_function :detect_sql_injection_native, :detect_sql_injection,
32
66
  [:string, :string, :int], :int
33
- rescue LoadError, FFI::NotFoundError => err
67
+ rescue LoadError, FFI::NotFoundError => err # rubocop:disable Lint/ShadowedException
34
68
  # :nocov:
35
69
 
36
70
  # Emit an $stderr warning at startup.
@@ -12,12 +12,12 @@ module Aikido::Zen
12
12
  app,
13
13
  config: Aikido::Zen.config,
14
14
  settings: Aikido::Zen.runtime_settings,
15
- rate_limiter: Aikido::Zen::RateLimiter.new
15
+ detached_agent: Aikido::Zen.detached_agent
16
16
  )
17
17
  @app = app
18
18
  @config = config
19
19
  @settings = settings
20
- @rate_limiter = rate_limiter
20
+ @detached_agent = detached_agent
21
21
  end
22
22
 
23
23
  def call(env)
@@ -33,9 +33,15 @@ module Aikido::Zen
33
33
  private
34
34
 
35
35
  def should_throttle?(request)
36
+ return false unless @settings.endpoints[request.route].rate_limiting.enabled?
36
37
  return false if @settings.skip_protection_for_ips.include?(request.ip)
37
38
 
38
- @rate_limiter.throttle?(request)
39
+ result = @detached_agent.calculate_rate_limits(request)
40
+
41
+ return false unless result
42
+
43
+ request.env["aikido.rate_limiting"] = result
44
+ request.env["aikido.rate_limiting"].throttled?
39
45
  end
40
46
  end
41
47
  end
@@ -13,14 +13,16 @@ module Aikido::Zen
13
13
  request = Aikido::Zen::Middleware.request_from(env)
14
14
  response = @app.call(env)
15
15
 
16
- Aikido::Zen.track_request request
17
-
18
- if Aikido::Zen.config.collect_api_schema? && request.route && track?(
16
+ if request.route && track?(
19
17
  status_code: response[0],
20
18
  route: request.route.path,
21
19
  http_method: request.request_method
22
20
  )
23
- Aikido::Zen.track_discovered_route(request)
21
+ Aikido::Zen.track_request request
22
+
23
+ if Aikido::Zen.config.collect_api_schema?
24
+ Aikido::Zen.track_discovered_route(request)
25
+ end
24
26
  end
25
27
 
26
28
  response
@@ -5,6 +5,10 @@ module Aikido::Zen
5
5
  # any Sink that wraps an HTTP library, and lets us keep track of any hosts to
6
6
  # which the app communicates over HTTP.
7
7
  module OutboundConnectionMonitor
8
+ def self.skips_on_nil_context?
9
+ false
10
+ end
11
+
8
12
  # This simply reports the connection to the Agent, and always returns +nil+
9
13
  # as it's not scanning for any particular attack.
10
14
  #
@@ -10,7 +10,7 @@ module Aikido::Zen
10
10
  end
11
11
 
12
12
  initializer "aikido.add_middleware" do |app|
13
- next if config.zen.disabled?
13
+ next unless config.zen.protect?
14
14
 
15
15
  app.middleware.use Aikido::Zen::Middleware::SetContext
16
16
  app.middleware.use Aikido::Zen::Middleware::CheckAllowedAddresses
@@ -33,9 +33,9 @@ module Aikido::Zen
33
33
  initializer "aikido.configuration" do |app|
34
34
  # Allow the logger to be configured before checking if disabled? so we can
35
35
  # let the user know that the agent is disabled.
36
- app.config.zen.logger = ::Rails.logger.tagged("aikido")
37
-
38
- next if config.zen.disabled?
36
+ logger = ::Rails.logger
37
+ logger = ActiveSupport::TaggedLogging.new(logger) unless logger.respond_to?(:tagged)
38
+ app.config.zen.logger = logger.tagged("aikido")
39
39
 
40
40
  app.config.zen.request_builder = Aikido::Zen::Context::RAILS_REQUEST_BUILDER
41
41
 
@@ -51,16 +51,16 @@ module Aikido::Zen
51
51
  end
52
52
 
53
53
  config.after_initialize do
54
- if config.zen.disabled?
55
- config.zen.logger.warn("Zen has been disabled and will not run.")
56
- next
57
- end
54
+ next unless config.zen.protect?
58
55
 
59
56
  # Make sure this is run at the end of the initialization process, so
60
57
  # that any gems required after aikido-zen are detected and patched
61
58
  # accordingly.
62
59
  Aikido::Zen.load_sinks!
63
60
 
61
+ # It's important we start after loading sinks, so we can report the installed packages
62
+ Aikido::Zen.start!
63
+
64
64
  # Agent's bootstrap process has finished —Controllers are patched to block
65
65
  # unwanted requests, sinks are loaded, scanners are running—, so we mark
66
66
  # the agent as installed.
@@ -31,13 +31,13 @@ module Aikido::Zen
31
31
  @opened_at = @clock.call
32
32
  end
33
33
 
34
- # @param event [#type] an event which we'll discriminate by type to decide
34
+ # @param event_type [String] an event type which we'll use to decide
35
35
  # if we should throttle it.
36
36
  # @return [Boolean]
37
- def throttle?(event)
37
+ def throttle?(event_type)
38
38
  return true if open? && !try_close
39
39
 
40
- result = @bucket.increment(event.type)
40
+ result = @bucket.increment(event_type)
41
41
  result.throttled?
42
42
  end
43
43
 
@@ -24,23 +24,18 @@ module Aikido::Zen
24
24
  }
25
25
  end
26
26
 
27
- # Checks whether the request requires rate limiting. As a side effect, this
28
- # will annotate the request with the "aikido.rate_limiting" ENV key, holding
29
- # the result of the check, and including useful stats in case you want to
30
- # return RateLimit headers..
27
+ # Calculate based on the configuration whether a request will be
28
+ # rate-limited or not.
31
29
  #
32
30
  # @param request [Aikido::Zen::Request]
33
- # @return [Boolean]
34
- #
35
- # @see Aikido::Zen::RateLimiter::Result
36
- def throttle?(request)
31
+ # @return [Aikido::Zen::RateLimiter::Result, nil]
32
+ def calculate_rate_limits(request)
37
33
  settings = settings_for(request.route)
38
- return false unless settings.enabled?
34
+ return nil unless settings.enabled?
39
35
 
40
36
  bucket = @buckets[request.route]
41
37
  key = @config.rate_limiting_discriminator.call(request)
42
- request.env["aikido.rate_limiting"] = bucket.increment(key)
43
- request.env["aikido.rate_limiting"].throttled?
38
+ bucket.increment(key)
44
39
  end
45
40
 
46
41
  private
@@ -30,6 +30,10 @@ module Aikido::Zen
30
30
 
31
31
  private def parameterize_segment(segment)
32
32
  case segment
33
+ when ULID
34
+ ":ulid"
35
+ when OBJECT_ID
36
+ ":objectId"
33
37
  when NUMBER
34
38
  ":number"
35
39
  when UUID
@@ -57,6 +61,8 @@ module Aikido::Zen
57
61
  | 00000000-0000-0000-0000-000000000000
58
62
  | ffffffff-ffff-ffff-ffff-ffffffffffff
59
63
  )\z/ix
64
+ ULID = /\A[0-9A-HJKMNP-TV-Z]{26}\z/i
65
+ OBJECT_ID = /\A[0-9a-f]{24}\z/i
60
66
  EMAIL = /\A
61
67
  [a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+
62
68
  @
@@ -58,27 +58,15 @@ module Aikido::Zen
58
58
  end
59
59
 
60
60
  private def build_route(route, request, prefix: request.script_name)
61
- Rails::Route.new(route, prefix: prefix, verb: request.request_method)
62
- end
63
- end
64
-
65
- module Rails
66
- class Route < Aikido::Zen::Route
67
- attr_reader :verb
61
+ route_wrapper = ActionDispatch::Routing::RouteWrapper.new(route)
68
62
 
69
- def initialize(rails_route, verb: rails_route.verb, prefix: nil)
70
- @route = ActionDispatch::Routing::RouteWrapper.new(rails_route)
71
- @verb = verb
72
- @prefix = prefix
63
+ path = if prefix.present?
64
+ File.join(prefix.to_s, route_wrapper.path).chomp("/")
65
+ else
66
+ route_wrapper.path
73
67
  end
74
68
 
75
- def path
76
- if @prefix.present?
77
- File.join(@prefix.to_s, @route.path).chomp("/")
78
- else
79
- @route.path
80
- end
81
- end
69
+ Aikido::Zen::Route.new(verb: request.request_method, path: path)
82
70
  end
83
71
  end
84
72
  end
@@ -18,6 +18,20 @@ module Aikido::Zen
18
18
  @schemas.map(&:as_json) unless @schemas.empty?
19
19
  end
20
20
 
21
+ def self.from_json(schemas_array)
22
+ return NONE if !schemas_array || schemas_array.empty?
23
+
24
+ AuthSchemas.new(schemas_array.map do |schema|
25
+ if schema[:type] == "http"
26
+ Authorization.new(schema[:scheme])
27
+ elsif schema[:type] == "apiKey"
28
+ ApiKey.new(schema[:in], schema[:name])
29
+ else
30
+ raise "Invalid schema type: #{schema[:type]}"
31
+ end
32
+ end)
33
+ end
34
+
21
35
  def ==(other)
22
36
  other.is_a?(self.class) && schemas == other.schemas
23
37
  end
@@ -48,6 +48,24 @@ module Aikido::Zen
48
48
  {body: body, query: query_schema.as_json, auth: auth_schema.as_json}.compact
49
49
  end
50
50
 
51
+ def self.from_json(data)
52
+ if data.empty?
53
+ return Request::Schema.new(
54
+ content_type: nil,
55
+ body_schema: EMPTY_SCHEMA,
56
+ query_schema: EMPTY_SCHEMA,
57
+ auth_schema: Aikido::Zen::Request::Schema::AuthSchemas.new([])
58
+ )
59
+ end
60
+
61
+ Request::Schema.new(
62
+ content_type: data[:body].nil? ? nil : data[:body][:type],
63
+ body_schema: data[:body].nil? ? EMPTY_SCHEMA : Aikido::Zen::Request::Schema::Definition.new(data[:body][:schema]),
64
+ query_schema: data[:query].nil? ? EMPTY_SCHEMA : Aikido::Zen::Request::Schema::Definition.new(data[:query]),
65
+ auth_schema: Aikido::Zen::Request::Schema::AuthSchemas.from_json(data[:auth])
66
+ )
67
+ end
68
+
51
69
  # Merges the request specification with another request's specification.
52
70
  #
53
71
  # @param other [Aikido::Zen::Request::Schema, nil]
@@ -45,7 +45,7 @@ module Aikido::Zen
45
45
  # @param data [Hash] the decoded JSON payload from the /api/runtime/config
46
46
  # API endpoint.
47
47
  #
48
- # @return [void]
48
+ # @return [bool]
49
49
  def update_from_json(data)
50
50
  last_updated_at = updated_at
51
51
 
@@ -56,7 +56,7 @@ module Aikido::Zen
56
56
  self.skip_protection_for_ips = RuntimeSettings::IPSet.from_json(data["allowedIPAddresses"])
57
57
  self.received_any_stats = data["receivedAnyStats"]
58
58
 
59
- Aikido::Zen.agent.updated_settings! if updated_at != last_updated_at
59
+ updated_at != last_updated_at
60
60
  end
61
61
  end
62
62
  end
@@ -5,6 +5,10 @@ require_relative "path_traversal/helpers"
5
5
  module Aikido::Zen
6
6
  module Scanners
7
7
  class PathTraversalScanner
8
+ def self.skips_on_nil_context?
9
+ true
10
+ end
11
+
8
12
  # Checks if the user introduced input is trying to access other path using
9
13
  # Path Traversal kind of attacks.
10
14
  #
@@ -16,8 +20,6 @@ module Aikido::Zen
16
20
  # @return [Aikido::Zen::Attacks::PathTraversalAttack, nil] an Attack if any
17
21
  # user input is detected to be attempting a Path Traversal Attack, or +nil+ if not.
18
22
  def self.call(filepath:, sink:, context:, operation:)
19
- return unless context
20
-
21
23
  context.payloads.each do |payload|
22
24
  next unless new(filepath, payload.value).attack?
23
25
 
@@ -5,14 +5,16 @@ require_relative "shell_injection/helpers"
5
5
  module Aikido::Zen
6
6
  module Scanners
7
7
  class ShellInjectionScanner
8
+ def self.skips_on_nil_context?
9
+ true
10
+ end
11
+
8
12
  # @param command [String]
9
13
  # @param sink [Aikido::Zen::Sink]
10
14
  # @param context [Aikido::Zen::Context]
11
15
  # @param operation [Symbol, String]
12
16
  #
13
17
  def self.call(command:, sink:, context:, operation:)
14
- return unless context
15
-
16
18
  context.payloads.each do |payload|
17
19
  next unless new(command, payload.value).attack?
18
20
 
@@ -6,6 +6,10 @@ require_relative "../internals"
6
6
  module Aikido::Zen
7
7
  module Scanners
8
8
  class SQLInjectionScanner
9
+ def self.skips_on_nil_context?
10
+ true
11
+ end
12
+
9
13
  # Checks if the given SQL query may have dangerous user input injected,
10
14
  # and returns an Attack if so, based on the current request.
11
15
  #
@@ -22,8 +26,6 @@ module Aikido::Zen
22
26
  # @raise [Aikido::Zen::InternalsError] if an error occurs when loading or
23
27
  # calling zenlib. See Sink#scan.
24
28
  def self.call(query:, dialect:, sink:, context:, operation:)
25
- return if context.nil?
26
-
27
29
  dialect = DIALECTS.fetch(dialect) do
28
30
  Aikido::Zen.config.logger.warn "Unknown SQL dialect #{dialect.inspect}"
29
31
  DIALECTS[:common]
@@ -31,35 +31,47 @@ module Aikido::Zen
31
31
  # @return [Boolean]
32
32
  def private?(hostname_or_address)
33
33
  resolve(hostname_or_address).any? do |ip|
34
- ip.loopback? || ip.private? || RFC5735.any? { |range| range === ip }
34
+ PRIVATE_RANGES.any? { |range| range === ip }
35
35
  end
36
36
  end
37
37
 
38
38
  private
39
39
 
40
- RFC5735 = [
41
- IPAddr.new("0.0.0.0/8"),
42
- IPAddr.new("100.64.0.0/10"),
43
- IPAddr.new("127.0.0.0/8"),
44
- IPAddr.new("169.254.0.0/16"),
45
- IPAddr.new("192.0.0.0/24"),
46
- IPAddr.new("192.0.2.0/24"),
47
- IPAddr.new("192.31.196.0/24"),
48
- IPAddr.new("192.52.193.0/24"),
49
- IPAddr.new("192.88.99.0/24"),
50
- IPAddr.new("192.175.48.0/24"),
51
- IPAddr.new("198.18.0.0/15"),
52
- IPAddr.new("198.51.100.0/24"),
53
- IPAddr.new("203.0.113.0/24"),
54
- IPAddr.new("240.0.0.0/4"),
55
- IPAddr.new("224.0.0.0/4"),
56
- IPAddr.new("255.255.255.255/32"),
40
+ # Source: https://github.com/AikidoSec/firewall-node/blob/main/library/vulnerabilities/ssrf/isPrivateIP.ts
41
+ PRIVATE_IPV4_RANGES = [
42
+ IPAddr.new("0.0.0.0/8"), # "This" network (RFC 1122)
43
+ IPAddr.new("10.0.0.0/8"), # Private-Use Networks (RFC 1918)
44
+ IPAddr.new("100.64.0.0/10"), # Shared Address Space (RFC 6598)
45
+ IPAddr.new("127.0.0.0/8"), # Loopback (RFC 1122)
46
+ IPAddr.new("169.254.0.0/16"), # Link Local (RFC 3927)
47
+ IPAddr.new("172.16.0.0/12"), # Private-Use Networks (RFC 1918)
48
+ IPAddr.new("192.0.0.0/24"), # IETF Protocol Assignments (RFC 5736)
49
+ IPAddr.new("192.0.2.0/24"), # TEST-NET-1 (RFC 5737)
50
+ IPAddr.new("192.31.196.0/24"), # AS112 Redirection Anycast (RFC 7535)
51
+ IPAddr.new("192.52.193.0/24"), # Automatic Multicast Tunneling (RFC 7450)
52
+ IPAddr.new("192.88.99.0/24"), # 6to4 Relay Anycast (RFC 3068)
53
+ IPAddr.new("192.168.0.0/16"), # Private-Use Networks (RFC 1918)
54
+ IPAddr.new("192.175.48.0/24"), # AS112 Redirection Anycast (RFC 7535)
55
+ IPAddr.new("198.18.0.0/15"), # Network Interconnect Device Benchmark Testing (RFC 2544)
56
+ IPAddr.new("198.51.100.0/24"), # TEST-NET-2 (RFC 5737)
57
+ IPAddr.new("203.0.113.0/24"), # TEST-NET-3 (RFC 5737)
58
+ IPAddr.new("224.0.0.0/4"), # Multicast (RFC 3171)
59
+ IPAddr.new("240.0.0.0/4"), # Reserved for Future Use (RFC 1112)
60
+ IPAddr.new("255.255.255.255/32") # Limited Broadcast (RFC 919)
61
+ ]
57
62
 
58
- IPAddr.new("::/128"), # Unspecified address
59
- IPAddr.new("fe80::/10"), # Link-local address (LLA)
60
- IPAddr.new("::ffff:127.0.0.1/128") # IPv4-mapped address
63
+ PRIVATE_IPV6_RANGES = [
64
+ IPAddr.new("::/128"), # Unspecified address (RFC 4291)
65
+ IPAddr.new("::1/128"), # Loopback address (RFC 4291)
66
+ IPAddr.new("fc00::/7"), # Unique local address (ULA) (RFC 4193
67
+ IPAddr.new("fe80::/10"), # Link-local address (LLA) (RFC 4291)
68
+ IPAddr.new("100::/64"), # Discard prefix (RFC 6666)
69
+ IPAddr.new("2001:db8::/32"), # Documentation prefix (RFC 3849)
70
+ IPAddr.new("3fff::/20") # Documentation prefix (RFC 9637)
61
71
  ]
62
72
 
73
+ PRIVATE_RANGES = PRIVATE_IPV4_RANGES + PRIVATE_IPV6_RANGES + PRIVATE_IPV4_RANGES.map(&:ipv4_mapped)
74
+
63
75
  def resolved_in_current_context
64
76
  context = Aikido::Zen.current_context
65
77
  context && context["dns.lookups"]
@@ -6,6 +6,12 @@ require_relative "ssrf/dns_lookups"
6
6
  module Aikido::Zen
7
7
  module Scanners
8
8
  class SSRFScanner
9
+ # SSRF attacks can be triggered through external inputs, so it is essential
10
+ # to have a valid context to safeguard against these attacks.
11
+ def self.skips_on_nil_context?
12
+ true
13
+ end
14
+
9
15
  # Checks if an outbound HTTP request is to a hostname supplied from user
10
16
  # input that resolves to a "dangerous" address. This is called from two
11
17
  # different places:
@@ -32,7 +38,6 @@ module Aikido::Zen
32
38
  # @return [Aikido::Zen::Attacks::SSRFAttack, nil] an Attack if any user
33
39
  # input is detected to be attempting SSRF, or +nil+ if not.
34
40
  def self.call(request:, sink:, context:, operation:, **)
35
- return if context.nil?
36
41
  return if request.nil? # See NOTE above.
37
42
 
38
43
  context["ssrf.redirects"] ||= RedirectChains.new
@@ -5,6 +5,12 @@ module Aikido::Zen
5
5
  # Inspects the result of DNS lookups, to determine if we're being the target
6
6
  # of a stored SSRF targeting IMDS addresses (169.254.169.254).
7
7
  class StoredSSRFScanner
8
+ # Stored-SSRF can occur without external input, so we do not require a
9
+ # context to determine if an attack is happening.
10
+ def self.skips_on_nil_context?
11
+ false
12
+ end
13
+
8
14
  def self.call(hostname:, addresses:, operation:, sink:, context:, **opts)
9
15
  offending_address = new(hostname, addresses).attack?
10
16
  return if offending_address.nil?
@@ -80,15 +80,23 @@ module Aikido::Zen
80
80
  # @raise [Aikido::UnderAttackError] if an attack is detected and
81
81
  # blocking_mode is enabled.
82
82
  def scan(context: Aikido::Zen.current_context, **scan_params)
83
+ return if context&.scanning
84
+ context&.scanning = true
85
+
83
86
  return if context&.protection_disabled?
84
87
 
85
88
  scan = Scan.new(sink: self, context: context)
86
89
 
90
+ scans_performed = 0
87
91
  scan.perform do
88
92
  result = nil
89
93
 
90
94
  scanners.each do |scanner|
95
+ next if scanner.skips_on_nil_context? && context.nil?
96
+
91
97
  result = scanner.call(sink: self, context: context, **scan_params)
98
+ scans_performed += 1
99
+
92
100
  break result if result
93
101
  rescue Aikido::Zen::InternalsError => error
94
102
  Aikido::Zen.config.logger.warn(error.message)
@@ -100,9 +108,11 @@ module Aikido::Zen
100
108
  result
101
109
  end
102
110
 
103
- @reporter.call(scan)
111
+ @reporter.call(scan) if scans_performed > 0
104
112
 
105
113
  scan
114
+ ensure
115
+ context&.scanning = false
106
116
  end
107
117
  end
108
118
  end