aikido-zen 1.0.2.beta.2-aarch64-linux

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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/.aikido +6 -0
  3. data/.ruby-version +1 -0
  4. data/.simplecov +26 -0
  5. data/.standard.yml +3 -0
  6. data/LICENSE +674 -0
  7. data/README.md +146 -0
  8. data/Rakefile +67 -0
  9. data/benchmarks/README.md +23 -0
  10. data/benchmarks/rails7.1_sql_injection.js +70 -0
  11. data/docs/banner.svg +202 -0
  12. data/docs/config.md +125 -0
  13. data/docs/proxy.md +10 -0
  14. data/docs/rails.md +114 -0
  15. data/lib/aikido/zen/actor.rb +116 -0
  16. data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
  17. data/lib/aikido/zen/agent.rb +179 -0
  18. data/lib/aikido/zen/api_client.rb +145 -0
  19. data/lib/aikido/zen/attack.rb +207 -0
  20. data/lib/aikido/zen/background_worker.rb +52 -0
  21. data/lib/aikido/zen/capped_collections.rb +68 -0
  22. data/lib/aikido/zen/collector/hosts.rb +15 -0
  23. data/lib/aikido/zen/collector/routes.rb +66 -0
  24. data/lib/aikido/zen/collector/sink_stats.rb +95 -0
  25. data/lib/aikido/zen/collector/stats.rb +111 -0
  26. data/lib/aikido/zen/collector/users.rb +30 -0
  27. data/lib/aikido/zen/collector.rb +144 -0
  28. data/lib/aikido/zen/config.rb +282 -0
  29. data/lib/aikido/zen/context/rack_request.rb +24 -0
  30. data/lib/aikido/zen/context/rails_request.rb +44 -0
  31. data/lib/aikido/zen/context.rb +112 -0
  32. data/lib/aikido/zen/detached_agent/agent.rb +78 -0
  33. data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
  34. data/lib/aikido/zen/detached_agent/server.rb +78 -0
  35. data/lib/aikido/zen/detached_agent.rb +2 -0
  36. data/lib/aikido/zen/errors.rb +107 -0
  37. data/lib/aikido/zen/event.rb +71 -0
  38. data/lib/aikido/zen/internals.rb +103 -0
  39. data/lib/aikido/zen/libzen-v0.1.39-aarch64-linux.so +0 -0
  40. data/lib/aikido/zen/middleware/check_allowed_addresses.rb +26 -0
  41. data/lib/aikido/zen/middleware/middleware.rb +11 -0
  42. data/lib/aikido/zen/middleware/rack_throttler.rb +48 -0
  43. data/lib/aikido/zen/middleware/request_tracker.rb +192 -0
  44. data/lib/aikido/zen/middleware/set_context.rb +26 -0
  45. data/lib/aikido/zen/outbound_connection.rb +45 -0
  46. data/lib/aikido/zen/outbound_connection_monitor.rb +23 -0
  47. data/lib/aikido/zen/package.rb +22 -0
  48. data/lib/aikido/zen/payload.rb +50 -0
  49. data/lib/aikido/zen/rails_engine.rb +56 -0
  50. data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
  51. data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
  52. data/lib/aikido/zen/rate_limiter/result.rb +31 -0
  53. data/lib/aikido/zen/rate_limiter.rb +50 -0
  54. data/lib/aikido/zen/request/heuristic_router.rb +115 -0
  55. data/lib/aikido/zen/request/rails_router.rb +77 -0
  56. data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
  57. data/lib/aikido/zen/request/schema/auth_schemas.rb +54 -0
  58. data/lib/aikido/zen/request/schema/builder.rb +121 -0
  59. data/lib/aikido/zen/request/schema/definition.rb +107 -0
  60. data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
  61. data/lib/aikido/zen/request/schema.rb +87 -0
  62. data/lib/aikido/zen/request.rb +122 -0
  63. data/lib/aikido/zen/route.rb +39 -0
  64. data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
  65. data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
  66. data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
  67. data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
  68. data/lib/aikido/zen/runtime_settings.rb +65 -0
  69. data/lib/aikido/zen/scan.rb +75 -0
  70. data/lib/aikido/zen/scanners/path_traversal/helpers.rb +65 -0
  71. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +63 -0
  72. data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
  73. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +64 -0
  74. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +93 -0
  75. data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
  76. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +97 -0
  77. data/lib/aikido/zen/scanners/ssrf_scanner.rb +265 -0
  78. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +49 -0
  79. data/lib/aikido/zen/scanners.rb +7 -0
  80. data/lib/aikido/zen/sink.rb +118 -0
  81. data/lib/aikido/zen/sinks/action_controller.rb +83 -0
  82. data/lib/aikido/zen/sinks/async_http.rb +80 -0
  83. data/lib/aikido/zen/sinks/curb.rb +113 -0
  84. data/lib/aikido/zen/sinks/em_http.rb +83 -0
  85. data/lib/aikido/zen/sinks/excon.rb +118 -0
  86. data/lib/aikido/zen/sinks/file.rb +112 -0
  87. data/lib/aikido/zen/sinks/http.rb +93 -0
  88. data/lib/aikido/zen/sinks/httpclient.rb +95 -0
  89. data/lib/aikido/zen/sinks/httpx.rb +78 -0
  90. data/lib/aikido/zen/sinks/kernel.rb +33 -0
  91. data/lib/aikido/zen/sinks/mysql2.rb +31 -0
  92. data/lib/aikido/zen/sinks/net_http.rb +101 -0
  93. data/lib/aikido/zen/sinks/patron.rb +103 -0
  94. data/lib/aikido/zen/sinks/pg.rb +72 -0
  95. data/lib/aikido/zen/sinks/resolv.rb +62 -0
  96. data/lib/aikido/zen/sinks/socket.rb +78 -0
  97. data/lib/aikido/zen/sinks/sqlite3.rb +46 -0
  98. data/lib/aikido/zen/sinks/trilogy.rb +31 -0
  99. data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
  100. data/lib/aikido/zen/sinks.rb +36 -0
  101. data/lib/aikido/zen/sinks_dsl.rb +250 -0
  102. data/lib/aikido/zen/synchronizable.rb +24 -0
  103. data/lib/aikido/zen/system_info.rb +84 -0
  104. data/lib/aikido/zen/version.rb +10 -0
  105. data/lib/aikido/zen/worker.rb +87 -0
  106. data/lib/aikido/zen.rb +246 -0
  107. data/lib/aikido-zen.rb +3 -0
  108. data/placeholder/.gitignore +4 -0
  109. data/placeholder/README.md +11 -0
  110. data/placeholder/Rakefile +75 -0
  111. data/placeholder/lib/placeholder.rb.template +3 -0
  112. data/placeholder/placeholder.gemspec.template +20 -0
  113. data/tasklib/bench.rake +94 -0
  114. data/tasklib/libzen.rake +133 -0
  115. data/tasklib/wrk.rb +88 -0
  116. metadata +205 -0
@@ -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,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Aikido::Zen::DetachedAgent
6
+ class Server
7
+ # Initialize and start a detached agent server instance.
8
+ #
9
+ # @return [Aikido::Zen::DetachedAgent::Server]
10
+ def self.start(**opts)
11
+ new(**opts).tap(&:start!)
12
+ end
13
+
14
+ def initialize(config: Aikido::Zen.config)
15
+ @started_at = nil
16
+
17
+ @config = config
18
+
19
+ @socket_path = config.detached_agent_socket_path
20
+ @socket_uri = config.detached_agent_socket_uri
21
+ end
22
+
23
+ def started?
24
+ !!@started_at
25
+ end
26
+
27
+ def start!
28
+ @config.logger.info("Starting DRb Server...")
29
+
30
+ # Try to ensure that the DRb service can start if the DRb service did
31
+ # not stop cleanly.
32
+ begin
33
+ # Check whether the Unix domain socket is in use by another process.
34
+ UNIXSocket.new(@socket_path).close
35
+ rescue Errno::ECONNREFUSED
36
+ @config.logger.debug("Removing residual Unix domain socket...")
37
+
38
+ # Remove the residual Unix domain socket.
39
+ FileUtils.rm_f(@socket_path)
40
+ rescue
41
+ # empty
42
+ end
43
+
44
+ @front = FrontObject.new
45
+
46
+ # If the Unix domain socket is in use by another process and/or the
47
+ # residual Unix domain socket could not be removed DRb will raise an
48
+ # appropriate error.
49
+ @drb_server = DRb.start_service(@socket_uri, @front)
50
+
51
+ # Only show DRb output in debug mode.
52
+ @drb_server.verbose = @config.logger.debug?
53
+
54
+ # Ensure that the DRb server is alive.
55
+ max_attempts = 10
56
+ attempts = 0
57
+ until @drb_server.alive?
58
+ @config.logger.info("DRb Server still not alive. #{max_attempts - attempts} attempts remaining")
59
+ sleep 0.1
60
+ attempts += 1
61
+ raise Aikido::Zen::DetachedAgentError.new("Impossible to start the dRB server (socket=#{Aikido::Zen.config.detached_agent_socket_path})") \
62
+ if attempts == max_attempts
63
+ end
64
+
65
+ @started_at = Time.now.utc
66
+
67
+ at_exit { stop! if started? }
68
+ end
69
+
70
+ def stop!
71
+ @config.logger.info("Stopping DRb Server...")
72
+ @started_at = nil
73
+
74
+ @drb_server.stop_service if @drb_server.alive?
75
+ DRb.stop_service
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,2 @@
1
+ require_relative "detached_agent/agent"
2
+ require_relative "detached_agent/server"
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Aikido
6
+ # @!visibility private
7
+ # Support rescuing Aikido::Error without forcing a single base class to all
8
+ # errors (so things that should be e.g. a TypeError, can have the correct
9
+ # superclass).
10
+ module Error; end # :nodoc:
11
+
12
+ # @!visibility private
13
+ # Generic error for problems with the Agent.
14
+ class ZenError < RuntimeError
15
+ include Error
16
+ end
17
+
18
+ module Zen
19
+ # Wrapper for all low-level network errors communicating with the API. You
20
+ # can access the original error by calling #cause.
21
+ class NetworkError < StandardError
22
+ include Error
23
+
24
+ def initialize(request, cause = nil)
25
+ @request = request.dup
26
+
27
+ super("Error in #{request.method} #{request.path}: #{cause.message}")
28
+ end
29
+ end
30
+
31
+ # Raised whenever a request to the API results in a 4XX or 5XX response.
32
+ class APIError < StandardError
33
+ include Error
34
+
35
+ attr_reader :request
36
+ attr_reader :response
37
+
38
+ def initialize(request, response)
39
+ @request = anonimize_token(request.dup)
40
+ @response = response
41
+
42
+ super("Error in #{request.method} #{request.path}: #{response.code} #{response.message} (#{response.body})")
43
+ end
44
+
45
+ private def anonimize_token(request)
46
+ # Anonimize the token to `********************xxxx`,
47
+ # mimicking what we show in the dashbaord.
48
+ request["Authorization"] = request["Authorization"].to_s
49
+ .gsub(/\A.*(.{4})\z/, ("*" * 20) + "\\1")
50
+ request
51
+ end
52
+ end
53
+
54
+ # Raised whenever a response to the API results in a 429 response.
55
+ class RateLimitedError < APIError; end
56
+
57
+ class UnderAttackError < StandardError
58
+ include Error
59
+
60
+ attr_reader :attack
61
+
62
+ def initialize(attack)
63
+ @attack = attack
64
+ end
65
+ end
66
+
67
+ class SQLInjectionError < UnderAttackError
68
+ extend Forwardable
69
+ def_delegators :@attack, :query, :input, :dialect
70
+ end
71
+
72
+ class SSRFDetectedError < UnderAttackError
73
+ extend Forwardable
74
+ def_delegators :@attack, :request, :input
75
+ end
76
+
77
+ class PathTraversalError < UnderAttackError
78
+ extend Forwardable
79
+ def_delegators :@attack, :input
80
+ end
81
+
82
+ class ShellInjectionError < UnderAttackError
83
+ extend Forwardable
84
+ def_delegators :@attack, :input
85
+ end
86
+
87
+ # Raised when there's any problem communicating (or loading) libzen.
88
+ class InternalsError < ZenError
89
+ # @param attempt [String] description of what we were trying to do.
90
+ # @param problem [String] what couldn't be done.
91
+ # @param libname [String] the name of the file (including the arch).
92
+ def initialize(attempt, problem, libname)
93
+ super(format(<<~MSG.chomp, attempt, problem, libname))
94
+ Zen could not scan %s due to a problem %s the library `%s'
95
+ MSG
96
+ end
97
+ end
98
+
99
+ class DetachedAgentError < ZenError
100
+ extend Forwardable
101
+
102
+ def initialize(msg)
103
+ super
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ # Base class for all events. You should be using one of the subclasses defined
5
+ # in the Events module.
6
+ class Event
7
+ attr_reader :type
8
+ attr_reader :time
9
+ attr_reader :system_info
10
+
11
+ def initialize(type:, system_info: Aikido::Zen.system_info, time: Time.now.utc)
12
+ @type = type
13
+ @time = time
14
+ @system_info = system_info
15
+ end
16
+
17
+ def as_json
18
+ {
19
+ type: type,
20
+ time: time.to_i * 1000,
21
+ agent: system_info.as_json
22
+ }
23
+ end
24
+ end
25
+
26
+ module Events
27
+ # Event sent when starting up the agent.
28
+ class Started < Event
29
+ def initialize(**opts)
30
+ super(type: "started", **opts)
31
+ end
32
+ end
33
+
34
+ class Attack < Event
35
+ attr_reader :attack
36
+
37
+ def initialize(attack:, **opts)
38
+ @attack = attack
39
+ super(type: "detected_attack", **opts)
40
+ end
41
+
42
+ def as_json
43
+ super.update(
44
+ attack: @attack.as_json,
45
+ request: @attack.context.request.as_json
46
+ )
47
+ end
48
+ end
49
+
50
+ class Heartbeat < Event
51
+ def initialize(stats:, users:, hosts:, routes:, middleware_installed:, **opts)
52
+ super(type: "heartbeat", **opts)
53
+ @stats = stats
54
+ @users = users
55
+ @hosts = hosts
56
+ @routes = routes
57
+ @middleware_installed = middleware_installed
58
+ end
59
+
60
+ def as_json
61
+ super.update(
62
+ stats: @stats.as_json,
63
+ users: @users.as_json,
64
+ routes: @routes.as_json,
65
+ hostnames: @hosts.as_json,
66
+ middlewareInstalled: @middleware_installed
67
+ )
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+ require_relative "errors"
5
+
6
+ module Aikido::Zen
7
+ module Internals
8
+ extend FFI::Library
9
+
10
+ def self.libzen_names
11
+ lib_name = "libzen-v#{LIBZEN_VERSION}"
12
+ lib_ext = FFI::Platform::LIBSUFFIX
13
+
14
+ # Gem::Platform#version should be understood as an arbitrary Ruby defined
15
+ # OS specific string. A platform with a version string is considered more
16
+ # specific than a platform without a version string.
17
+ # https://docs.ruby-lang.org/en/3.3/Gem/Platform.html
18
+
19
+ platform = Gem::Platform.local.dup
20
+
21
+ # Library names in preferred order.
22
+ #
23
+ # If two library names are added, the specific platform library names is
24
+ # first and the generic platform library name is second.
25
+ names = []
26
+
27
+ names << "#{lib_name}-#{platform}.#{lib_ext}"
28
+
29
+ unless platform.version.nil?
30
+ platform.version = nil
31
+ names << "#{lib_name}-#{platform}.#{lib_ext}"
32
+ end
33
+
34
+ names
35
+ end
36
+
37
+ # @return [String] the name of the extension we're loading, which we can
38
+ # use in error messages.
39
+ def self.libzen_name
40
+ # The most generic platform library name.
41
+ libzen_names.last
42
+ end
43
+
44
+ # Load the most specific library
45
+ def self.load_libzen
46
+ libzen_names.each do |name|
47
+ path = File.expand_path(name, __dir__)
48
+ begin
49
+ return ffi_lib(path)
50
+ rescue LoadError
51
+ # empty
52
+ end
53
+ end
54
+ raise LoadError, "Zen could not load its native extension #{libzen_name}"
55
+ end
56
+
57
+ begin
58
+ load_libzen
59
+
60
+ # @!method self.detect_sql_injection_native(query, input, dialect)
61
+ # @param (see .detect_sql_injection)
62
+ # @returns [Integer] 0 if no injection detected, 1 if an injection was
63
+ # detected, or 2 if there was an internal error.
64
+ # @raise [Aikido::Zen::InternalsError] if there's a problem loading or
65
+ # calling libzen.
66
+ attach_function :detect_sql_injection_native, :detect_sql_injection,
67
+ [:string, :string, :int], :int
68
+ rescue LoadError, FFI::NotFoundError => err # rubocop:disable Lint/ShadowedException
69
+ # :nocov:
70
+
71
+ # Emit an $stderr warning at startup.
72
+ warn "Zen could not load its native extension #{libzen_name}: #{err}"
73
+
74
+ def self.detect_sql_injection(query, *)
75
+ attempt = format("%p for SQL injection", query)
76
+ raise InternalsError.new(attempt, "loading", libzen_name)
77
+ end
78
+
79
+ # :nocov:
80
+ else
81
+ # Analyzes the SQL query to detect if the provided user input is being
82
+ # passed as-is without escaping.
83
+ #
84
+ # @param query [String]
85
+ # @param input [String]
86
+ # @param dialect [Integer, #to_int] the SQL Dialect identifier in libzen.
87
+ # See {Aikido::Zen::Scanners::SQLInjectionScanner::DIALECTS}.
88
+ #
89
+ # @returns [Boolean]
90
+ # @raise [Aikido::Zen::InternalsError] if there's a problem loading or
91
+ # calling libzen.
92
+ def self.detect_sql_injection(query, input, dialect)
93
+ case detect_sql_injection_native(query, input, dialect)
94
+ when 0 then false
95
+ when 1 then true
96
+ when 2
97
+ attempt = format("%s query %p with input %p", dialect, query, input)
98
+ raise InternalsError.new(attempt, "calling detect_sql_injection in", libzen_name)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Middleware
5
+ # Middleware that rejects requests from IPs blocked in the Aikido dashboard.
6
+ class CheckAllowedAddresses
7
+ def initialize(app, config: Aikido::Zen.config, settings: Aikido::Zen.runtime_settings)
8
+ @app = app
9
+ @config = config
10
+ @settings = settings
11
+ end
12
+
13
+ def call(env)
14
+ request = Aikido::Zen::Middleware.request_from(env)
15
+
16
+ allowed_ips = @settings.endpoints[request.route].allowed_ips
17
+
18
+ if allowed_ips.empty? || allowed_ips.include?(request.ip)
19
+ @app.call(env)
20
+ else
21
+ @config.blocked_responder.call(request, :ip)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen::Middleware
4
+ def self.request_from(env)
5
+ if (current_context = Aikido::Zen.current_context)
6
+ current_context.request
7
+ else
8
+ Aikido::Zen::Context.from_rack_env(env).request
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../context"
4
+
5
+ module Aikido::Zen
6
+ module Middleware
7
+ # Rack middleware that rejects requests from clients that are making too many
8
+ # requests to a given endpoint, based in the runtime configuration in the
9
+ # Aikido dashboard.
10
+ class RackThrottler
11
+ def initialize(
12
+ app,
13
+ config: Aikido::Zen.config,
14
+ settings: Aikido::Zen.runtime_settings,
15
+ detached_agent: Aikido::Zen.detached_agent
16
+ )
17
+ @app = app
18
+ @config = config
19
+ @settings = settings
20
+ @detached_agent = detached_agent
21
+ end
22
+
23
+ def call(env)
24
+ request = Aikido::Zen::Middleware.request_from(env)
25
+
26
+ if should_throttle?(request)
27
+ @config.rate_limited_responder.call(request)
28
+ else
29
+ @app.call(env)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def should_throttle?(request)
36
+ return false unless @settings.endpoints[request.route].rate_limiting.enabled?
37
+ return false if @settings.skip_protection_for_ips.include?(request.ip)
38
+
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?
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Middleware
5
+ # Rack middleware used to track request
6
+ # It implements the logic under that which is considered worthy of being tracked.
7
+ class RequestTracker
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ request = Aikido::Zen::Middleware.request_from(env)
14
+ response = @app.call(env)
15
+
16
+ if request.route && track?(
17
+ status_code: response[0],
18
+ route: request.route.path,
19
+ http_method: request.request_method
20
+ )
21
+ Aikido::Zen.track_request request
22
+
23
+ if Aikido::Zen.config.collect_api_schema?
24
+ Aikido::Zen.track_discovered_route(request)
25
+ end
26
+ end
27
+
28
+ response
29
+ end
30
+
31
+ IGNORED_METHODS = %w[OPTIONS HEAD]
32
+ IGNORED_EXTENSIONS = %w[properties config webmanifest]
33
+ IGNORED_SEGMENTS = ["cgi-bin"]
34
+ WELL_KNOWN_URIS = %w[
35
+ /.well-known/acme-challenge
36
+ /.well-known/amphtml
37
+ /.well-known/api-catalog
38
+ /.well-known/appspecific
39
+ /.well-known/ashrae
40
+ /.well-known/assetlinks.json
41
+ /.well-known/broadband-labels
42
+ /.well-known/brski
43
+ /.well-known/caldav
44
+ /.well-known/carddav
45
+ /.well-known/change-password
46
+ /.well-known/cmp
47
+ /.well-known/coap
48
+ /.well-known/coap-eap
49
+ /.well-known/core
50
+ /.well-known/csaf
51
+ /.well-known/csaf-aggregator
52
+ /.well-known/csvm
53
+ /.well-known/did.json
54
+ /.well-known/did-configuration.json
55
+ /.well-known/dnt
56
+ /.well-known/dnt-policy.txt
57
+ /.well-known/dots
58
+ /.well-known/ecips
59
+ /.well-known/edhoc
60
+ /.well-known/enterprise-network-security
61
+ /.well-known/enterprise-transport-security
62
+ /.well-known/est
63
+ /.well-known/genid
64
+ /.well-known/gnap-as-rs
65
+ /.well-known/gpc.json
66
+ /.well-known/gs1resolver
67
+ /.well-known/hoba
68
+ /.well-known/host-meta
69
+ /.well-known/host-meta.json
70
+ /.well-known/hosting-provider
71
+ /.well-known/http-opportunistic
72
+ /.well-known/idp-proxy
73
+ /.well-known/jmap
74
+ /.well-known/keybase.txt
75
+ /.well-known/knx
76
+ /.well-known/looking-glass
77
+ /.well-known/masque
78
+ /.well-known/matrix
79
+ /.well-known/mercure
80
+ /.well-known/mta-sts.txt
81
+ /.well-known/mud
82
+ /.well-known/nfv-oauth-server-configuration
83
+ /.well-known/ni
84
+ /.well-known/nodeinfo
85
+ /.well-known/nostr.json
86
+ /.well-known/oauth-authorization-server
87
+ /.well-known/oauth-protected-resource
88
+ /.well-known/ohttp-gateway
89
+ /.well-known/openid-federation
90
+ /.well-known/open-resource-discovery
91
+ /.well-known/openid-configuration
92
+ /.well-known/openorg
93
+ /.well-known/oslc
94
+ /.well-known/pki-validation
95
+ /.well-known/posh
96
+ /.well-known/privacy-sandbox-attestations.json
97
+ /.well-known/private-token-issuer-directory
98
+ /.well-known/probing.txt
99
+ /.well-known/pvd
100
+ /.well-known/rd
101
+ /.well-known/related-website-set.json
102
+ /.well-known/reload-config
103
+ /.well-known/repute-template
104
+ /.well-known/resourcesync
105
+ /.well-known/sbom
106
+ /.well-known/security.txt
107
+ /.well-known/ssf-configuration
108
+ /.well-known/sshfp
109
+ /.well-known/stun-key
110
+ /.well-known/terraform.json
111
+ /.well-known/thread
112
+ /.well-known/time
113
+ /.well-known/timezone
114
+ /.well-known/tdmrep.json
115
+ /.well-known/tor-relay
116
+ /.well-known/tpcd
117
+ /.well-known/traffic-advice
118
+ /.well-known/trust.txt
119
+ /.well-known/uma2-configuration
120
+ /.well-known/void
121
+ /.well-known/webfinger
122
+ /.well-known/webweaver.json
123
+ /.well-known/wot
124
+ ]
125
+
126
+ # @param status_code [Integer]
127
+ # @param route [String]
128
+ # @param http_method [String]
129
+ def track?(status_code:, route:, http_method:)
130
+ # In the UI we want to show only successful (2xx) or redirect (3xx) responses
131
+ # anything else is discarded.
132
+ return false unless status_code >= 200 && status_code <= 399
133
+
134
+ return false if IGNORED_METHODS.include?(http_method)
135
+
136
+ segments = route.split "/"
137
+
138
+ # Do not discover routes with dot files like `/path/to/.file` or `/.directory/file`
139
+ # We want to allow discovery of well-known URIs like `/.well-known/acme-challenge`
140
+ return false if segments.any? { |s| is_dot_file s } && !is_well_known_uri(route)
141
+
142
+ return false if segments.any? { |s| contains_ignored_string s }
143
+
144
+ # Check for every file segment if it contains a file extension and if it
145
+ # should be discovered or ignored
146
+ segments.all? { |s| should_track_extension s }
147
+ end
148
+
149
+ private
150
+
151
+ # Check if a path is a well-known URI
152
+ # e.g. /.well-known/acme-challenge
153
+ # https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml
154
+ def is_well_known_uri(route)
155
+ WELL_KNOWN_URIS.include?(route)
156
+ end
157
+
158
+ def is_dot_file(segment)
159
+ segment.start_with?(".") && segment.size > 1
160
+ end
161
+
162
+ def contains_ignored_string(segment)
163
+ IGNORED_SEGMENTS.any? { |ignored| segment.include?(ignored) }
164
+ end
165
+
166
+ # Ignore routes which contain file extensions
167
+ def should_track_extension(segment)
168
+ extension = get_file_extension(segment)
169
+
170
+ return true unless extension
171
+
172
+ # Do not discover files with extensions of 1 to 5 characters,
173
+ # e.g. file.css, file.js, file.woff2
174
+ return false if extension.size > 1 && extension.size < 6
175
+
176
+ # Ignore some file extensions that are longer than 5 characters or shorter than 2 chars
177
+ return false if IGNORED_EXTENSIONS.include?(extension)
178
+
179
+ true
180
+ end
181
+
182
+ def get_file_extension(segment)
183
+ extension = File.extname(segment)
184
+ if extension&.start_with?(".")
185
+ # Remove the dot from the extension
186
+ return extension[1..]
187
+ end
188
+ extension
189
+ end
190
+ end
191
+ end
192
+ end