aikido-zen 0.1.0.alpha4-arm64-darwin

Sign up to get free protection for your applications and to get access to all the features.
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