aikido-zen 0.1.0.alpha4-arm64-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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/.ruby-version +1 -0
  3. data/.standard.yml +3 -0
  4. data/CHANGELOG.md +5 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE +674 -0
  7. data/README.md +40 -0
  8. data/Rakefile +63 -0
  9. data/lib/aikido/zen/actor.rb +116 -0
  10. data/lib/aikido/zen/agent.rb +187 -0
  11. data/lib/aikido/zen/api_client.rb +132 -0
  12. data/lib/aikido/zen/attack.rb +138 -0
  13. data/lib/aikido/zen/capped_collections.rb +68 -0
  14. data/lib/aikido/zen/config.rb +229 -0
  15. data/lib/aikido/zen/context/rack_request.rb +24 -0
  16. data/lib/aikido/zen/context/rails_request.rb +42 -0
  17. data/lib/aikido/zen/context.rb +101 -0
  18. data/lib/aikido/zen/errors.rb +88 -0
  19. data/lib/aikido/zen/event.rb +66 -0
  20. data/lib/aikido/zen/internals.rb +64 -0
  21. data/lib/aikido/zen/libzen-v0.1.26.aarch64.dylib +0 -0
  22. data/lib/aikido/zen/middleware/check_allowed_addresses.rb +38 -0
  23. data/lib/aikido/zen/middleware/set_context.rb +26 -0
  24. data/lib/aikido/zen/middleware/throttler.rb +50 -0
  25. data/lib/aikido/zen/outbound_connection.rb +45 -0
  26. data/lib/aikido/zen/outbound_connection_monitor.rb +19 -0
  27. data/lib/aikido/zen/package.rb +22 -0
  28. data/lib/aikido/zen/payload.rb +48 -0
  29. data/lib/aikido/zen/rails_engine.rb +53 -0
  30. data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
  31. data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
  32. data/lib/aikido/zen/rate_limiter/result.rb +31 -0
  33. data/lib/aikido/zen/rate_limiter.rb +55 -0
  34. data/lib/aikido/zen/request/heuristic_router.rb +109 -0
  35. data/lib/aikido/zen/request/rails_router.rb +84 -0
  36. data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
  37. data/lib/aikido/zen/request/schema/auth_schemas.rb +40 -0
  38. data/lib/aikido/zen/request/schema/builder.rb +125 -0
  39. data/lib/aikido/zen/request/schema/definition.rb +112 -0
  40. data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
  41. data/lib/aikido/zen/request/schema.rb +72 -0
  42. data/lib/aikido/zen/request.rb +97 -0
  43. data/lib/aikido/zen/route.rb +39 -0
  44. data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
  45. data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
  46. data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
  47. data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
  48. data/lib/aikido/zen/runtime_settings.rb +70 -0
  49. data/lib/aikido/zen/scan.rb +75 -0
  50. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +95 -0
  51. data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
  52. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +85 -0
  53. data/lib/aikido/zen/scanners/ssrf_scanner.rb +251 -0
  54. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +43 -0
  55. data/lib/aikido/zen/scanners.rb +5 -0
  56. data/lib/aikido/zen/sink.rb +108 -0
  57. data/lib/aikido/zen/sinks/async_http.rb +63 -0
  58. data/lib/aikido/zen/sinks/curb.rb +89 -0
  59. data/lib/aikido/zen/sinks/em_http.rb +71 -0
  60. data/lib/aikido/zen/sinks/excon.rb +103 -0
  61. data/lib/aikido/zen/sinks/http.rb +76 -0
  62. data/lib/aikido/zen/sinks/httpclient.rb +68 -0
  63. data/lib/aikido/zen/sinks/httpx.rb +61 -0
  64. data/lib/aikido/zen/sinks/mysql2.rb +21 -0
  65. data/lib/aikido/zen/sinks/net_http.rb +85 -0
  66. data/lib/aikido/zen/sinks/patron.rb +88 -0
  67. data/lib/aikido/zen/sinks/pg.rb +50 -0
  68. data/lib/aikido/zen/sinks/resolv.rb +41 -0
  69. data/lib/aikido/zen/sinks/socket.rb +51 -0
  70. data/lib/aikido/zen/sinks/sqlite3.rb +30 -0
  71. data/lib/aikido/zen/sinks/trilogy.rb +21 -0
  72. data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
  73. data/lib/aikido/zen/sinks.rb +21 -0
  74. data/lib/aikido/zen/stats/routes.rb +53 -0
  75. data/lib/aikido/zen/stats/sink_stats.rb +95 -0
  76. data/lib/aikido/zen/stats/users.rb +26 -0
  77. data/lib/aikido/zen/stats.rb +171 -0
  78. data/lib/aikido/zen/synchronizable.rb +24 -0
  79. data/lib/aikido/zen/system_info.rb +84 -0
  80. data/lib/aikido/zen/version.rb +10 -0
  81. data/lib/aikido/zen.rb +138 -0
  82. data/lib/aikido-zen.rb +3 -0
  83. data/lib/aikido.rb +3 -0
  84. data/tasklib/libzen.rake +128 -0
  85. metadata +175 -0
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../context"
4
+
5
+ module Aikido::Zen
6
+ module Middleware
7
+ # Middleware that rejects requests from IPs blocked in the Aikido dashboard.
8
+ class CheckAllowedAddresses
9
+ def initialize(app, config: Aikido::Zen.config, settings: Aikido::Zen.runtime_settings)
10
+ @app = app
11
+ @config = config
12
+ @settings = settings
13
+ end
14
+
15
+ def call(env)
16
+ request = request_from(env)
17
+
18
+ allowed_ips = @settings.endpoints[request.route].allowed_ips
19
+
20
+ if allowed_ips.empty? || allowed_ips.include?(request.ip)
21
+ @app.call(env)
22
+ else
23
+ @config.blocked_ip_responder.call(request)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def request_from(env)
30
+ if (current_context = Aikido::Zen.current_context)
31
+ current_context.request
32
+ else
33
+ Context.from_rack_env(env).request
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../context"
4
+
5
+ module Aikido::Zen
6
+ module Middleware
7
+ # Rack middleware that keeps the current context in a Thread/Fiber-local
8
+ # variable so that other parts of the agent/firewall can access it.
9
+ class SetContext
10
+ def initialize(app)
11
+ @app = app
12
+ end
13
+
14
+ def call(env)
15
+ context = Context.from_rack_env(env)
16
+
17
+ Aikido::Zen.current_context = context
18
+ Aikido::Zen.track_request(context.request)
19
+
20
+ @app.call(env)
21
+ ensure
22
+ Aikido::Zen.current_context = nil
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../context"
4
+
5
+ module Aikido::Zen
6
+ module Middleware
7
+ # 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 Throttler
11
+ def initialize(
12
+ app,
13
+ config: Aikido::Zen.config,
14
+ settings: Aikido::Zen.runtime_settings,
15
+ rate_limiter: Aikido::Zen::RateLimiter.new
16
+ )
17
+ @app = app
18
+ @config = config
19
+ @settings = settings
20
+ @rate_limiter = rate_limiter
21
+ end
22
+
23
+ def call(env)
24
+ request = 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 if @settings.skip_protection_for_ips.include?(request.ip)
37
+
38
+ @rate_limiter.throttle?(request)
39
+ end
40
+
41
+ def request_from(env)
42
+ if (current_context = Aikido::Zen.current_context)
43
+ current_context.request
44
+ else
45
+ Context.from_rack_env(env).request
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ # Simple data object to identify connections performed to outbound servers.
5
+ class OutboundConnection
6
+ # Convenience factory to create connection descriptions out of URI objects.
7
+ #
8
+ # @param uri [URI]
9
+ # @return [Aikido::Zen::OutboundConnection]
10
+ def self.from_uri(uri)
11
+ new(host: uri.hostname, port: uri.port)
12
+ end
13
+
14
+ # @return [String] the hostname or IP address to which the connection was
15
+ # attempted.
16
+ attr_reader :host
17
+
18
+ # @return [Integer] the port number to which the connection was attempted.
19
+ attr_reader :port
20
+
21
+ def initialize(host:, port:)
22
+ @host = host
23
+ @port = port
24
+ end
25
+
26
+ def as_json
27
+ {hostname: host, port: port}
28
+ end
29
+
30
+ def ==(other)
31
+ other.is_a?(OutboundConnection) &&
32
+ host == other.host &&
33
+ port == other.port
34
+ end
35
+ alias_method :eql?, :==
36
+
37
+ def hash
38
+ [host, port].hash
39
+ end
40
+
41
+ def inspect
42
+ "#<#{self.class.name} #{host}:#{port}>"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ # This simple callable follows the Scanner API so that it can be injected into
5
+ # any Sink that wraps an HTTP library, and lets us keep track of any hosts to
6
+ # which the app communicates over HTTP.
7
+ module OutboundConnectionMonitor
8
+ # This simply reports the connection to the Agent, and always returns +nil+
9
+ # as it's not scanning for any particular attack.
10
+ #
11
+ # @param connection [Aikido::Zen::OutboundConnection]
12
+ # @return [nil]
13
+ def self.call(connection:, **)
14
+ Aikido::Zen.track_outbound(connection)
15
+
16
+ nil
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sink"
4
+
5
+ module Aikido::Zen
6
+ Package = Struct.new(:name, :version) do
7
+ def initialize(name, version, sinks = Aikido::Zen::Sinks.registry)
8
+ super(name, version)
9
+ @sinks = sinks
10
+ end
11
+
12
+ # @return [Boolean] whether we explicitly protect against exploits in this
13
+ # library.
14
+ def supported?
15
+ @sinks.include?(name)
16
+ end
17
+
18
+ def as_json
19
+ {name => version.to_s}
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ # An individual user input in a request, which may come from different
5
+ # sources (query string, body, cookies, etc).
6
+ class Payload
7
+ attr_reader :value, :source, :path
8
+
9
+ def initialize(value, source, path)
10
+ @value = value
11
+ @source = source
12
+ @path = path
13
+ end
14
+
15
+ alias_method :to_s, :value
16
+
17
+ def ==(other)
18
+ other.is_a?(Payload) &&
19
+ other.value == value &&
20
+ other.source == source &&
21
+ other.path == path
22
+ end
23
+
24
+ def as_json
25
+ {
26
+ payload: value.to_s,
27
+ source: SOURCE_SERIALIZATIONS[source],
28
+ pathToPayload: path.to_s
29
+ }
30
+ end
31
+
32
+ SOURCE_SERIALIZATIONS = {
33
+ query: "query",
34
+ body: "body",
35
+ header: "headers",
36
+ cookie: "cookies",
37
+ route: "routeParams",
38
+ graphql: "graphql",
39
+ xml: "xml",
40
+ subdomain: "subdomains"
41
+ }
42
+
43
+ def inspect
44
+ val = (value.to_s.size > 128) ? value[0..125] + "..." : value
45
+ "#<Aikido::Zen::Payload #{source}(#{path}) #{val.inspect}>"
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch"
4
+
5
+ module Aikido::Zen
6
+ class RailsEngine < ::Rails::Engine
7
+ config.before_configuration do
8
+ # Access library configuration at `Rails.application.config.aikido_zen`.
9
+ config.aikido_zen = Aikido::Zen.config
10
+ end
11
+
12
+ initializer "aikido.add_middleware" do |app|
13
+ app.middleware.use Aikido::Zen::Middleware::SetContext
14
+ app.middleware.use Aikido::Zen::Middleware::CheckAllowedAddresses
15
+ app.middleware.use Aikido::Zen::Middleware::Throttler
16
+
17
+ # Due to how Rails sets up its middleware chain, the routing is evaluated
18
+ # (and the Request object constructed) in the app that terminates the
19
+ # chain, so no amount of middleware will be able to access it.
20
+ #
21
+ # This way, we overwrite the Request object as early as we can in the
22
+ # request handling, so that by the time we start evaluating inputs, we
23
+ # have assigned the request correctly.
24
+ ActiveSupport.on_load(:action_controller) do
25
+ before_action { Aikido::Zen.current_context.update_request(request) }
26
+ end
27
+ end
28
+
29
+ initializer "aikido.configuration" do |app|
30
+ app.config.aikido_zen.logger = ::Rails.logger.tagged("aikido")
31
+ app.config.aikido_zen.request_builder = Aikido::Zen::Context::RAILS_REQUEST_BUILDER
32
+
33
+ # Plug Rails' JSON encoder/decoder, but only if the user hasn't changed
34
+ # them for something else.
35
+ if app.config.aikido_zen.json_encoder == Aikido::Zen::Config::DEFAULT_JSON_ENCODER
36
+ app.config.aikido_zen.json_encoder = ActiveSupport::JSON.method(:encode)
37
+ end
38
+
39
+ if app.config.aikido_zen.json_decoder == Aikido::Zen::Config::DEFAULT_JSON_DECODER
40
+ app.config.aikido_zen.json_decoder = ActiveSupport::JSON.method(:decode)
41
+ end
42
+ end
43
+
44
+ config.after_initialize do
45
+ Aikido::Zen.initialize!
46
+
47
+ # Make sure this is run at the end of the initialization process, so
48
+ # that any gems required after aikido-zen are detected and patched
49
+ # accordingly.
50
+ Aikido::Zen.load_sinks!
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "bucket"
4
+
5
+ module Aikido::Zen
6
+ # @api private
7
+ #
8
+ # Circuit breaker that rate limits internal API requests in two ways: By using
9
+ # a sliding window, to allow only a certain number of events over that window,
10
+ # and with the ability of manually being tripped open when the API responds to
11
+ # a request with a 429.
12
+ class RateLimiter::Breaker
13
+ def initialize(config: Aikido::Zen.config, clock: RateLimiter::Bucket::DEFAULT_CLOCK)
14
+ @config = config
15
+ @clock = clock
16
+
17
+ @bucket = RateLimiter::Bucket.new(
18
+ ttl: config.client_rate_limit_period,
19
+ max_size: config.client_rate_limit_max_events,
20
+ clock: clock
21
+ )
22
+ @opened_at = nil
23
+ end
24
+
25
+ # Trip the circuit open to force all events to be throttled until the
26
+ # deadline passes.
27
+ #
28
+ # @see Aikido::Zen::Config#server_rate_limit_deadline
29
+ # @return [void]
30
+ def open!
31
+ @opened_at = @clock.call
32
+ end
33
+
34
+ # @param event [#type] an event which we'll discriminate by type to decide
35
+ # if we should throttle it.
36
+ # @return [Boolean]
37
+ def throttle?(event)
38
+ return true if open? && !try_close
39
+
40
+ result = @bucket.increment(event.type)
41
+ result.throttled?
42
+ end
43
+
44
+ # @!visibility private
45
+ # @return [Boolean]
46
+ def open?
47
+ @opened_at
48
+ end
49
+
50
+ private
51
+
52
+ def past_deadline?
53
+ @opened_at < @clock.call - @config.server_rate_limit_deadline
54
+ end
55
+
56
+ def try_close
57
+ @opened_at = nil if past_deadline?
58
+ @opened_at.nil?
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../synchronizable"
4
+ require_relative "result"
5
+
6
+ module Aikido::Zen
7
+ # This models a "sliding window" rate limiting bucket (where we keep a bucket
8
+ # per endpoint). The timestamps of requests are kept grouped by client, and
9
+ # when a new request is made, we check if the number of requests falls within
10
+ # the configured limit.
11
+ #
12
+ # @example
13
+ # bucket = Aikido::Zen::RateLimiter::Bucket.new(ttl: 60, max_size: 3)
14
+ # bucket.increment("1.2.3.4") #=> true (count for this key: 1)
15
+ # bucket.increment("1.2.3.4") #=> true (count for this key: 2)
16
+ #
17
+ # # 30 seconds go by
18
+ # bucket.increment("1.2.3.4") #=> true (count for this key: 3)
19
+ #
20
+ # # 20 more seconds go by
21
+ # bucket.increment("1.2.3.4") #=> false (count for this key: 3)
22
+ #
23
+ # # 20 more seconds go by
24
+ # bucket.increment("1.2.3.4") #=> true (count for this key: 2)
25
+ #
26
+ class RateLimiter::Bucket
27
+ prepend Synchronizable
28
+
29
+ # @!visibility private
30
+ #
31
+ # Use the monotonic clock to ensure time differences are consistent
32
+ # and not affected by timezones.or daylight savings changes.
33
+ DEFAULT_CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC).round }
34
+
35
+ def initialize(ttl:, max_size:, clock: DEFAULT_CLOCK)
36
+ @ttl = ttl
37
+ @max_size = max_size
38
+ @data = Hash.new { |h, k| h[k] = [] }
39
+ @clock = clock
40
+ end
41
+
42
+ # Increments the key if the number of entries within the current TTL window
43
+ # is below the configured threshold.
44
+ #
45
+ # @param key [String] discriminating key to identify a client.
46
+ # See {Aikido::Zen::Config#rate_limiting_discriminator}.
47
+ #
48
+ # @return [Aikido::Zen::RateLimiter::Result] the result of the operation and
49
+ # statistics on this bucket for the given key.
50
+ def increment(key)
51
+ synchronize do
52
+ time = @clock.call
53
+ evict(key, at: time)
54
+
55
+ entries = @data[key]
56
+ throttled = entries.size >= @max_size
57
+
58
+ entries << time unless throttled
59
+
60
+ RateLimiter::Result.new(
61
+ throttled: throttled,
62
+ discriminator: key,
63
+ current_requests: entries.size,
64
+ max_requests: @max_size,
65
+ time_remaining: @ttl - (time - entries.min)
66
+ )
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def evict(key, at: @clock.call)
73
+ synchronize { @data[key].delete_if { |time| time < (at - @ttl) } }
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,31 @@
1
+ module Aikido::Zen
2
+ # Holds the stats after checking if a request should be rate limited, which
3
+ # will be added to the Rack env.
4
+ class RateLimiter::Result
5
+ # @return [String] the output of the configured discriminator block, used to
6
+ # uniquely identify a client (e.g. the remote IP).
7
+ attr_reader :discriminator
8
+
9
+ # @return [Integer] number of requests for the client in the current window.
10
+ attr_reader :current_requests
11
+
12
+ # @return [Integer] configured max number of requests per client.
13
+ attr_reader :max_requests
14
+
15
+ # @return [Integer] number of seconds remaining until the window resets.
16
+ attr_reader :time_remaining
17
+
18
+ def initialize(throttled:, discriminator:, current_requests:, max_requests:, time_remaining:)
19
+ @throttled = throttled
20
+ @discriminator = discriminator
21
+ @current_requests = current_requests
22
+ @max_requests = max_requests
23
+ @time_remaining = time_remaining
24
+ end
25
+
26
+ # @return [Boolean] whether the current request was throttled or not.
27
+ def throttled?
28
+ @throttled
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "synchronizable"
4
+ require_relative "middleware/throttler"
5
+
6
+ module Aikido::Zen
7
+ # Keeps track of all requests in this process, broken up by Route and further
8
+ # discriminated by client. Provides a single method that checks if a certain
9
+ # Request needs to be throttled or not.
10
+ class RateLimiter
11
+ prepend Synchronizable
12
+
13
+ def initialize(
14
+ config: Aikido::Zen.config,
15
+ settings: Aikido::Zen.runtime_settings
16
+ )
17
+ @config = config
18
+ @settings = settings
19
+ @buckets = Hash.new { |store, route|
20
+ synchronize {
21
+ settings = settings_for(route)
22
+ store[route] = Bucket.new(ttl: settings.period, max_size: settings.max_requests)
23
+ }
24
+ }
25
+ end
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..
31
+ #
32
+ # @param request [Aikido::Zen::Request]
33
+ # @return [Boolean]
34
+ #
35
+ # @see Aikido::Zen::RateLimiter::Result
36
+ def throttle?(request)
37
+ settings = settings_for(request.route)
38
+ return false unless settings.enabled?
39
+
40
+ bucket = @buckets[request.route]
41
+ key = @config.rate_limiting_discriminator.call(request)
42
+ request.env["aikido.rate_limiting"] = bucket.increment(key)
43
+ request.env["aikido.rate_limiting"].throttled?
44
+ end
45
+
46
+ private
47
+
48
+ def settings_for(route)
49
+ @settings.endpoints[route].rate_limiting
50
+ end
51
+ end
52
+ end
53
+
54
+ require_relative "rate_limiter/bucket"
55
+ require_relative "rate_limiter/breaker"
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require_relative "../route"
5
+ require_relative "../request"
6
+
7
+ module Aikido::Zen
8
+ # Simple router implementation that just identifies the currently requested
9
+ # URL as a route, attempting to heuristically substitute any path segments
10
+ # that may look like a parameterized value by something descriptive.
11
+ #
12
+ # For example, "/categories/123/events/2024-10-01" would be matched as
13
+ # "/categories/:number/events/:date"
14
+ class Request::HeuristicRouter
15
+ # @param request [Aikido::Zen::Request]
16
+ # @return [Aikido::Zen::Route, nil]
17
+ def recognize(request)
18
+ path = parameterize(request.path)
19
+ Route.new(verb: request.request_method, path: path)
20
+ end
21
+
22
+ private def parameterize(path)
23
+ return if path.nil?
24
+
25
+ path = path.split("/").map { |part| parameterize_segment(part) }.join("/")
26
+ path.prepend("/") unless path.start_with?("/")
27
+ path.chomp!("/") if path.size > 1
28
+ path
29
+ end
30
+
31
+ private def parameterize_segment(segment)
32
+ case segment
33
+ when NUMBER
34
+ ":number"
35
+ when UUID
36
+ ":uuid"
37
+ when DATE
38
+ ":date"
39
+ when EMAIL
40
+ ":email"
41
+ when IP
42
+ ":ip"
43
+ when HASH
44
+ ":hash"
45
+ when SecretMatcher
46
+ ":secret"
47
+ else
48
+ segment
49
+ end
50
+ end
51
+
52
+ NUMBER = /\A\d+\z/
53
+ HEX = /\A[a-f0-9]+\z/i
54
+ DATE = /\A\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4}\z/
55
+ UUID = /\A
56
+ (?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}
57
+ | 00000000-0000-0000-0000-000000000000
58
+ | ffffffff-ffff-ffff-ffff-ffffffffffff
59
+ )\z/ix
60
+ EMAIL = /\A
61
+ [a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+
62
+ @
63
+ [a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?
64
+ (?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*
65
+ \z/x
66
+ IP = ->(segment) {
67
+ IPAddr::RE_IPV4ADDRLIKE.match?(segment) ||
68
+ IPAddr::RE_IPV6ADDRLIKE_COMPRESSED.match?(segment) ||
69
+ IPAddr::RE_IPV6ADDRLIKE_FULL.match?(segment)
70
+ }
71
+ HASH = ->(segment) { [32, 40, 64, 128].include?(segment.size) && HEX === segment }
72
+
73
+ class SecretMatcher
74
+ # Decides if a given string looks random enough to be a "secret".
75
+ #
76
+ # @param candidate [String]
77
+ # @return [Boolean]
78
+ def self.===(candidate)
79
+ new(candidate).matches?
80
+ end
81
+
82
+ private def initialize(string)
83
+ @string = string
84
+ end
85
+
86
+ def matches?
87
+ return false if @string.size <= MIN_LENGTH
88
+ return false if SEPARATORS === @string
89
+ return false unless DIGIT === @string
90
+ return false if [LOWER, UPPER, SPECIAL].none? { |pattern| pattern === @string }
91
+
92
+ ratios = @string.chars.each_cons(MIN_LENGTH).map do |window|
93
+ window.to_set.size / MIN_LENGTH.to_f
94
+ end
95
+
96
+ ratios.sum / ratios.size > SECRET_THRESHOLD
97
+ end
98
+
99
+ MIN_LENGTH = 10
100
+ SECRET_THRESHOLD = 0.75
101
+
102
+ LOWER = /[[:lower:]]/
103
+ UPPER = /[[:upper:]]/
104
+ DIGIT = /[[:digit:]]/
105
+ SPECIAL = /[!#\$%^&*|;:<>]/
106
+ SEPARATORS = /[[:space:]]|-/
107
+ end
108
+ end
109
+ end