aikido-zen 1.0.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 (125) hide show
  1. checksums.yaml +7 -0
  2. data/.aikido +6 -0
  3. data/.ruby-version +1 -0
  4. data/.simplecov +32 -0
  5. data/.standard.yml +3 -0
  6. data/LICENSE +674 -0
  7. data/README.md +148 -0
  8. data/Rakefile +67 -0
  9. data/benchmarks/README.md +22 -0
  10. data/benchmarks/rails7.1_benchmark.js +1 -0
  11. data/benchmarks/rails7.1_sql_injection.js +102 -0
  12. data/docs/banner.svg +202 -0
  13. data/docs/config.md +133 -0
  14. data/docs/proxy.md +10 -0
  15. data/docs/rails.md +112 -0
  16. data/docs/troubleshooting.md +62 -0
  17. data/lib/aikido/zen/actor.rb +146 -0
  18. data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
  19. data/lib/aikido/zen/agent.rb +181 -0
  20. data/lib/aikido/zen/api_client.rb +145 -0
  21. data/lib/aikido/zen/attack.rb +217 -0
  22. data/lib/aikido/zen/attack_wave/helpers.rb +457 -0
  23. data/lib/aikido/zen/attack_wave.rb +88 -0
  24. data/lib/aikido/zen/background_worker.rb +52 -0
  25. data/lib/aikido/zen/cache.rb +91 -0
  26. data/lib/aikido/zen/capped_collections.rb +86 -0
  27. data/lib/aikido/zen/collector/event.rb +238 -0
  28. data/lib/aikido/zen/collector/hosts.rb +30 -0
  29. data/lib/aikido/zen/collector/routes.rb +71 -0
  30. data/lib/aikido/zen/collector/sink_stats.rb +95 -0
  31. data/lib/aikido/zen/collector/stats.rb +122 -0
  32. data/lib/aikido/zen/collector/users.rb +32 -0
  33. data/lib/aikido/zen/collector.rb +223 -0
  34. data/lib/aikido/zen/config.rb +312 -0
  35. data/lib/aikido/zen/context/rack_request.rb +27 -0
  36. data/lib/aikido/zen/context/rails_request.rb +47 -0
  37. data/lib/aikido/zen/context.rb +145 -0
  38. data/lib/aikido/zen/detached_agent/agent.rb +79 -0
  39. data/lib/aikido/zen/detached_agent/front_object.rb +41 -0
  40. data/lib/aikido/zen/detached_agent/server.rb +78 -0
  41. data/lib/aikido/zen/detached_agent.rb +2 -0
  42. data/lib/aikido/zen/errors.rb +107 -0
  43. data/lib/aikido/zen/event.rb +116 -0
  44. data/lib/aikido/zen/helpers.rb +24 -0
  45. data/lib/aikido/zen/internals.rb +123 -0
  46. data/lib/aikido/zen/libzen-v0.1.48-aarch64-linux.so +0 -0
  47. data/lib/aikido/zen/middleware/allowed_address_checker.rb +26 -0
  48. data/lib/aikido/zen/middleware/attack_wave_protector.rb +46 -0
  49. data/lib/aikido/zen/middleware/context_setter.rb +26 -0
  50. data/lib/aikido/zen/middleware/fork_detector.rb +23 -0
  51. data/lib/aikido/zen/middleware/middleware.rb +11 -0
  52. data/lib/aikido/zen/middleware/rack_throttler.rb +50 -0
  53. data/lib/aikido/zen/middleware/request_tracker.rb +197 -0
  54. data/lib/aikido/zen/outbound_connection.rb +62 -0
  55. data/lib/aikido/zen/outbound_connection_monitor.rb +23 -0
  56. data/lib/aikido/zen/package.rb +22 -0
  57. data/lib/aikido/zen/payload.rb +50 -0
  58. data/lib/aikido/zen/rails_engine.rb +53 -0
  59. data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
  60. data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
  61. data/lib/aikido/zen/rate_limiter/result.rb +31 -0
  62. data/lib/aikido/zen/rate_limiter.rb +50 -0
  63. data/lib/aikido/zen/request/heuristic_router.rb +115 -0
  64. data/lib/aikido/zen/request/rails_router.rb +92 -0
  65. data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
  66. data/lib/aikido/zen/request/schema/auth_schemas.rb +54 -0
  67. data/lib/aikido/zen/request/schema/builder.rb +121 -0
  68. data/lib/aikido/zen/request/schema/definition.rb +107 -0
  69. data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
  70. data/lib/aikido/zen/request/schema.rb +87 -0
  71. data/lib/aikido/zen/request.rb +88 -0
  72. data/lib/aikido/zen/route.rb +96 -0
  73. data/lib/aikido/zen/runtime_settings/endpoints.rb +78 -0
  74. data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
  75. data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
  76. data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
  77. data/lib/aikido/zen/runtime_settings.rb +66 -0
  78. data/lib/aikido/zen/scan.rb +75 -0
  79. data/lib/aikido/zen/scanners/path_traversal/helpers.rb +68 -0
  80. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +64 -0
  81. data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
  82. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +65 -0
  83. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +94 -0
  84. data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
  85. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +97 -0
  86. data/lib/aikido/zen/scanners/ssrf_scanner.rb +266 -0
  87. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +55 -0
  88. data/lib/aikido/zen/scanners.rb +7 -0
  89. data/lib/aikido/zen/sink.rb +118 -0
  90. data/lib/aikido/zen/sinks/action_controller.rb +85 -0
  91. data/lib/aikido/zen/sinks/async_http.rb +80 -0
  92. data/lib/aikido/zen/sinks/curb.rb +113 -0
  93. data/lib/aikido/zen/sinks/em_http.rb +83 -0
  94. data/lib/aikido/zen/sinks/excon.rb +118 -0
  95. data/lib/aikido/zen/sinks/file.rb +153 -0
  96. data/lib/aikido/zen/sinks/http.rb +93 -0
  97. data/lib/aikido/zen/sinks/httpclient.rb +95 -0
  98. data/lib/aikido/zen/sinks/httpx.rb +78 -0
  99. data/lib/aikido/zen/sinks/kernel.rb +33 -0
  100. data/lib/aikido/zen/sinks/mysql2.rb +31 -0
  101. data/lib/aikido/zen/sinks/net_http.rb +101 -0
  102. data/lib/aikido/zen/sinks/patron.rb +103 -0
  103. data/lib/aikido/zen/sinks/pg.rb +72 -0
  104. data/lib/aikido/zen/sinks/resolv.rb +62 -0
  105. data/lib/aikido/zen/sinks/socket.rb +85 -0
  106. data/lib/aikido/zen/sinks/sqlite3.rb +46 -0
  107. data/lib/aikido/zen/sinks/trilogy.rb +31 -0
  108. data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
  109. data/lib/aikido/zen/sinks.rb +36 -0
  110. data/lib/aikido/zen/sinks_dsl.rb +250 -0
  111. data/lib/aikido/zen/synchronizable.rb +24 -0
  112. data/lib/aikido/zen/system_info.rb +80 -0
  113. data/lib/aikido/zen/version.rb +10 -0
  114. data/lib/aikido/zen/worker.rb +87 -0
  115. data/lib/aikido/zen.rb +303 -0
  116. data/lib/aikido-zen.rb +3 -0
  117. data/placeholder/.gitignore +4 -0
  118. data/placeholder/README.md +11 -0
  119. data/placeholder/Rakefile +75 -0
  120. data/placeholder/lib/placeholder.rb.template +3 -0
  121. data/placeholder/placeholder.gemspec.template +20 -0
  122. data/tasklib/bench.rake +94 -0
  123. data/tasklib/libzen.rake +133 -0
  124. data/tasklib/wrk.rb +88 -0
  125. metadata +214 -0
@@ -0,0 +1,115 @@
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 ULID
34
+ ":ulid"
35
+ when OBJECT_ID
36
+ ":objectId"
37
+ when NUMBER
38
+ ":number"
39
+ when UUID
40
+ ":uuid"
41
+ when DATE
42
+ ":date"
43
+ when EMAIL
44
+ ":email"
45
+ when IP
46
+ ":ip"
47
+ when HASH
48
+ ":hash"
49
+ when SecretMatcher
50
+ ":secret"
51
+ else
52
+ segment
53
+ end
54
+ end
55
+
56
+ NUMBER = /\A\d+\z/
57
+ HEX = /\A[a-f0-9]+\z/i
58
+ DATE = /\A\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4}\z/
59
+ UUID = /\A
60
+ (?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}
61
+ | 00000000-0000-0000-0000-000000000000
62
+ | ffffffff-ffff-ffff-ffff-ffffffffffff
63
+ )\z/ix
64
+ ULID = /\A[0-9A-HJKMNP-TV-Z]{26}\z/i
65
+ OBJECT_ID = /\A[0-9a-f]{24}\z/i
66
+ EMAIL = /\A
67
+ [a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+
68
+ @
69
+ [a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?
70
+ (?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*
71
+ \z/x
72
+ IP = ->(segment) {
73
+ IPAddr::RE_IPV4ADDRLIKE.match?(segment) ||
74
+ IPAddr::RE_IPV6ADDRLIKE_COMPRESSED.match?(segment) ||
75
+ IPAddr::RE_IPV6ADDRLIKE_FULL.match?(segment)
76
+ }
77
+ HASH = ->(segment) { [32, 40, 64, 128].include?(segment.size) && HEX === segment }
78
+
79
+ class SecretMatcher
80
+ # Decides if a given string looks random enough to be a "secret".
81
+ #
82
+ # @param candidate [String]
83
+ # @return [Boolean]
84
+ def self.===(candidate)
85
+ new(candidate).matches?
86
+ end
87
+
88
+ private def initialize(string)
89
+ @string = string
90
+ end
91
+
92
+ def matches?
93
+ return false if @string.size <= MIN_LENGTH
94
+ return false if SEPARATORS === @string
95
+ return false unless DIGIT === @string
96
+ return false if [LOWER, UPPER, SPECIAL].none? { |pattern| pattern === @string }
97
+
98
+ ratios = @string.chars.each_cons(MIN_LENGTH).map do |window|
99
+ window.to_set.size / MIN_LENGTH.to_f
100
+ end
101
+
102
+ ratios.sum / ratios.size > SECRET_THRESHOLD
103
+ end
104
+
105
+ MIN_LENGTH = 10
106
+ SECRET_THRESHOLD = 0.75
107
+
108
+ LOWER = /[[:lower:]]/
109
+ UPPER = /[[:upper:]]/
110
+ DIGIT = /[[:digit:]]/
111
+ SPECIAL = /[!#\$%^&*|;:<>]/
112
+ SEPARATORS = /[[:space:]]|-/
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../route"
4
+ require_relative "../request"
5
+
6
+ module Aikido::Zen
7
+ # The Rails router relies on introspecting the routes defined in the Rails
8
+ # app to match the current request to the correct route, building Route
9
+ # objects that have the exact pattern defined by the developer, rather than
10
+ # a heuristic approximation.
11
+ #
12
+ # For example, given the following route definitions:
13
+ #
14
+ # resources :posts do
15
+ # resources :comments
16
+ # end
17
+ #
18
+ # The router will map a request to "/posts/123/comments/234" to
19
+ # "/posts/:post_id/comments/:id(.:format)".
20
+ #
21
+ # @see Aikido::Zen::Router::HeuristicRouter
22
+ class Request::RailsRouter
23
+ def initialize(route_set)
24
+ @route_set = route_set
25
+ end
26
+
27
+ def recognize(request)
28
+ recognize_in_route_set(request, @route_set)
29
+ end
30
+
31
+ private def recognize_in_route_set(request, route_set, prefix: nil)
32
+ # ActionDispatch::Journey::Router#recognize modifies the Rack environment.
33
+ # This is correct for Rails routing, but it is not expected to be used in
34
+ # Rack middleware, and using it here can break Rails routing.
35
+ #
36
+ # To avoid this, the Rack environment is duplicated when building request.
37
+ route_set.router.recognize(request) do |route, _|
38
+ app = route.app
39
+ next unless app.matches?(request)
40
+
41
+ if app.dispatcher?
42
+ return build_route(route, request, prefix: prefix)
43
+ end
44
+
45
+ if app.engine?
46
+ # If the SCRIPT_NAME has any path parameters, we want those to be
47
+ # captured by the router. (eg `mount API => "/api/:version/`)
48
+ prefix = ActionDispatch::Routing::RouteWrapper.new(route).path
49
+ return recognize_in_route_set(request, app.rack_app.routes, prefix: prefix)
50
+ end
51
+
52
+ if app.rack_app.respond_to?(:redirect?) && app.rack_app.redirect?
53
+ return build_route(route, request, prefix: prefix)
54
+ end
55
+
56
+ # At this point we're matching plain Rack apps, where Rails does not
57
+ # remove the SCRIPT_NAME from PATH_INFO, so we should avoid adding
58
+ # SCRIPT_NAME twice.
59
+ return build_route(route, request, prefix: nil)
60
+ end
61
+
62
+ nil
63
+ end
64
+
65
+ private
66
+
67
+ def build_route(route, request, prefix: request.script_name)
68
+ route_wrapper = ActionDispatch::Routing::RouteWrapper.new(route)
69
+
70
+ path = if prefix.present?
71
+ prefix_route_path(prefix.to_s, route_wrapper.path)
72
+ else
73
+ route_wrapper.path
74
+ end
75
+
76
+ Aikido::Zen::Route.new(verb: request.request_method, path: path)
77
+ end
78
+
79
+ def prefix_route_path(string1, string2)
80
+ # The strings appear to start with "/", allowing them to be concatenated
81
+ # directly after removing trailing "/". However, as it is not currently
82
+ # known whether this is guaranteed, we insert a separator when necessary.
83
+
84
+ separator = string2.start_with?("/") ? "" : "/"
85
+
86
+ string1 = string1.chomp("/")
87
+ string2 = string2.chomp("/")
88
+
89
+ "#{string1}#{separator}#{string2}"
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auth_schemas"
4
+
5
+ module Aikido::Zen
6
+ class Request::Schema
7
+ class AuthDiscovery
8
+ def initialize(context)
9
+ @context = context
10
+ end
11
+
12
+ def schemas
13
+ schemas = []
14
+ schemas << extract_from_authorization_header if headers["Authorization"]
15
+ schemas.concat(extract_from_headers)
16
+ schemas.concat(extract_from_cookies)
17
+
18
+ AuthSchemas.new(schemas)
19
+ end
20
+
21
+ private
22
+
23
+ def extract_from_authorization_header
24
+ type, _ = headers["Authorization"].to_s.split(/\s+/, 2)
25
+
26
+ if AUTHORIZATION_SCHEMES.include?(type.to_s.downcase)
27
+ AuthSchemas::Authorization.new(type)
28
+ else
29
+ AuthSchemas::ApiKey.new(:header, "Authorization")
30
+ end
31
+ end
32
+
33
+ def extract_from_headers
34
+ (headers.keys & COMMON_API_KEY_HEADERS)
35
+ .map { |header| AuthSchemas::ApiKey.new(:header, header) }
36
+ end
37
+
38
+ def extract_from_cookies
39
+ cookie_names = @context.payload_sources[:cookie].keys.map(&:downcase)
40
+
41
+ (cookie_names & COMMON_COOKIE_NAMES)
42
+ .map { |cookie| AuthSchemas::ApiKey.new(:cookie, cookie) }
43
+ end
44
+
45
+ def headers
46
+ @context.request.normalized_headers
47
+ end
48
+
49
+ AUTHORIZATION_SCHEMES = %w[
50
+ basic
51
+ bearer
52
+ digest
53
+ dpop
54
+ gnap
55
+ hoba
56
+ mutal
57
+ negotiate
58
+ privatetoken
59
+ scram-sha-1
60
+ scram-sha-256
61
+ vapid
62
+ ].freeze
63
+
64
+ COMMON_API_KEY_HEADERS = %w[
65
+ Apikey
66
+ Api-Key
67
+ Token
68
+ X-Api-Key
69
+ X-Token
70
+ ]
71
+
72
+ COMMON_COOKIE_NAMES = %w[
73
+ user_id
74
+ auth
75
+ session
76
+ jwt
77
+ token
78
+ sid
79
+ connect.sid
80
+ auth_token
81
+ access_token
82
+ refresh_token
83
+ ]
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ class Request::Schema
5
+ class AuthSchemas
6
+ attr_reader :schemas
7
+
8
+ def initialize(schemas)
9
+ @schemas = schemas
10
+ end
11
+
12
+ def merge(other)
13
+ self.class.new((schemas + other.schemas).uniq)
14
+ end
15
+ alias_method :|, :merge
16
+
17
+ def as_json
18
+ @schemas.map(&:as_json) unless @schemas.empty?
19
+ end
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
+
35
+ def ==(other)
36
+ other.is_a?(self.class) && schemas == other.schemas
37
+ end
38
+
39
+ NONE = new([])
40
+
41
+ Authorization = Struct.new(:scheme) do
42
+ def as_json
43
+ {type: "http", scheme: scheme.downcase}
44
+ end
45
+ end
46
+
47
+ ApiKey = Struct.new(:location, :name) do
48
+ def as_json
49
+ {type: "apiKey", in: location, name: name}.compact
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "definition"
4
+ require_relative "empty_schema"
5
+ require_relative "auth_discovery"
6
+
7
+ module Aikido::Zen
8
+ class Request::Schema
9
+ # @api private
10
+ class Builder
11
+ def initialize(context: Aikido::Zen.current_context, config: Aikido::Zen.config)
12
+ @context = context
13
+ @config = config
14
+ @max_depth = @config.api_schema_collection_max_depth
15
+ @max_props = @config.api_schema_collection_max_properties
16
+ end
17
+
18
+ def schema
19
+ Request::Schema.new(
20
+ content_type: body_data_type,
21
+ body_schema: body_schema,
22
+ query_schema: query_schema,
23
+ auth_schema: AuthDiscovery.new(@context).schemas
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def new(definition)
30
+ Aikido::Zen::Request::Schema::Definition.new(definition)
31
+ end
32
+
33
+ def request
34
+ @context.request
35
+ end
36
+
37
+ def body_data_type
38
+ media_type = request.media_type.to_s
39
+
40
+ # If the media type includes any tree other than the standard (vnd., prs.,
41
+ # x., etc) and a suffix, then remove that bit and just keep the suffix,
42
+ # which should tell us what the underlying data structure is.
43
+ #
44
+ # application/json => application/json
45
+ # application/vnd.github.v3+json => application/json
46
+ media_type = media_type.sub(%r{/.*\+}, "/") if media_type.include?("+")
47
+
48
+ DATA_TYPES.fetch(media_type, nil)
49
+ end
50
+
51
+ def query_schema
52
+ return EMPTY_SCHEMA if request.query_string.to_s.empty?
53
+
54
+ discover_schema(@context.payload_sources[:query])
55
+ end
56
+
57
+ def body_schema
58
+ return EMPTY_SCHEMA if request.content_length.to_i.zero?
59
+
60
+ discover_schema(sanitize_data(@context.payload_sources[:body]))
61
+ end
62
+
63
+ def discover_schema(object, depth: 0)
64
+ case object
65
+ when nil
66
+ new(type: "null")
67
+ when true, false
68
+ new(type: "boolean")
69
+ when String
70
+ new(type: "string")
71
+ when Numeric
72
+ new(type: "number")
73
+ when Array
74
+ # If the array has at least one item, we assume it's homogeneous for
75
+ # performance reasons, and so only inspect the type of the first one.
76
+ sub_schema = {items: discover_schema(object.first, depth: depth + 1)} unless object.empty?
77
+ new({type: "array"}.merge(sub_schema.to_h))
78
+ when Hash
79
+ object
80
+ .take(@max_props)
81
+ .each_with_object({type: "object", properties: {}}) { |(key, value), schema|
82
+ break schema if depth >= @max_depth
83
+ schema[:properties][key] = discover_schema(value, depth: depth + 1)
84
+ }
85
+ .then { |dfn| new(dfn) }
86
+ end
87
+ end
88
+
89
+ # By default, Rails' automatic decoding wraps non Hash inputs in a Hash
90
+ # with a _json key, so that the "params" object is always a Hash. So, for
91
+ # example, the request body: '["this","is","json"]' is transformed to
92
+ # '{"_json": ["this","is","json"]}' before being passed to the controller.
93
+ #
94
+ # We want to make sure to avoid this extra key when building the schema,
95
+ # since we won't be able to play back requests with it.
96
+ def sanitize_data(data)
97
+ return data unless @context.request.framework == "rails"
98
+ return data unless data.is_a?(Hash)
99
+
100
+ if data.is_a?(Hash) && data.keys == ["_json"]
101
+ data["_json"]
102
+ else
103
+ data
104
+ end
105
+ end
106
+
107
+ DATA_TYPES = {
108
+ "application/csp-report" => :json,
109
+ "application/x-json" => :json,
110
+ "application/json" => :json,
111
+
112
+ "application/x-www-form-urlencoded" => :"form-urlencoded",
113
+
114
+ "multipart/form-data" => :"form-data",
115
+
116
+ "application/xml" => :xml,
117
+ "text/xml" => :xml
118
+ }
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require_relative "empty_schema"
5
+
6
+ module Aikido::Zen
7
+ class Request::Schema
8
+ # @api private
9
+ #
10
+ # The "JSON Schema"-like implementation that we extract from looking at the
11
+ # request body and/or query string.
12
+ class Definition
13
+ extend Forwardable
14
+ def_delegators :definition, :deconstruct_keys
15
+
16
+ def initialize(definition)
17
+ @definition = definition
18
+ end
19
+
20
+ # Recursively merges this schema definition with another one.
21
+ #
22
+ # * Properties missing in one or the other schemas are treated as optional.
23
+ # * Merging any property with a null schema results in an optional schema.
24
+ # * Number and Integer schemas are merged into Number schemas, since it's
25
+ # the more permissive of the types.
26
+ #
27
+ # Other than that, everything else just results in additive merging,
28
+ # resulting in combining types together.
29
+ #
30
+ # @param other [Aikido::Zen::Request::Schema::Definition, nil]
31
+ # @return [Aikido::Zen::Request::Schema::Definition]
32
+ #
33
+ # @see https://cswr.github.io/JsonSchema/spec/introduction/
34
+ def merge(other)
35
+ case [self, other]
36
+
37
+ # Merging with itself or with nil results in just a copy
38
+ in [obj, ^obj | nil | EMPTY_SCHEMA]
39
+ dup
40
+
41
+ # objects where at least one of them has properties
42
+ in [{type: "object", properties: _}, {type: "object", properties: _}] |
43
+ [{type: "object", properties: _}, {type: "object"}] |
44
+ [{type: "object"}, {type: "object", properties: _}]
45
+ left, right = definition[:properties], other.definition[:properties]
46
+ merged_props = (left.keys.to_a | right.keys.to_a)
47
+ .map { |key| [key, left.fetch(key, NULL).merge(right.fetch(key, NULL))] }
48
+ .to_h
49
+ new(definition.merge(other.definition).merge(properties: merged_props))
50
+
51
+ # arrays where at least one of them has items
52
+ in [{type: "array", items: _}, {type: "array", items: _}] |
53
+ [{type: "array", items: _}, {type: "array"}] |
54
+ [{type: "array"}, {type: "array", items: _}]
55
+ items = [definition[:items], other.definition[:items]].compact.reduce(:merge)
56
+ new(definition.merge(other.definition).merge({items: items}.compact))
57
+
58
+ # x | x => x
59
+ in {type: type}, {type: ^type}
60
+ new(definition.merge(other.definition))
61
+
62
+ # any | null => any?
63
+ in {type: "null"}, {type: _}
64
+ new(other.definition.merge(optional: true))
65
+ in {type: _}, {type: "null"}
66
+ new(definition.merge(optional: true))
67
+
68
+ # x | y => [x, y] if x != y
69
+ else
70
+ left_type, right_type = definition[:type], other.definition[:type]
71
+ types = [left_type, right_type].flatten.uniq.sort
72
+ new(definition.merge(other.definition).merge(type: types))
73
+
74
+ end
75
+ end
76
+ alias_method :|, :merge
77
+
78
+ def ==(other)
79
+ as_json == other.as_json
80
+ end
81
+
82
+ def as_json
83
+ definition
84
+ .transform_keys(&:to_s)
85
+ .transform_values do |val|
86
+ val.respond_to?(:as_json) ? val.as_json : val
87
+ end
88
+ end
89
+
90
+ def inspect
91
+ format("#<%s %p>", self.class, definition)
92
+ end
93
+
94
+ protected
95
+
96
+ attr_reader :definition
97
+
98
+ private
99
+
100
+ def new(definition)
101
+ self.class.new(definition)
102
+ end
103
+
104
+ NULL = new(type: "null") # used as a stand-in to merge missing object props
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ class Request::Schema
5
+ # @!visibility private
6
+ #
7
+ # Singleton used as a placeholder until we get a schema for a request.
8
+ # When "merged" it waits until a non-nil value is given and returns that.
9
+ EMPTY_SCHEMA = Object.new
10
+
11
+ class << EMPTY_SCHEMA
12
+ # @!visibility private
13
+ def merge(schema)
14
+ schema || self
15
+ end
16
+ alias_method :|, :merge
17
+
18
+ # @!visibility private
19
+ def as_json
20
+ nil
21
+ end
22
+
23
+ def inspect
24
+ "#<Aikido::Zen::Schema EMPTY>"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Aikido::Zen
6
+ # Defines the shape of a request received by your application as seen by Zen.
7
+ # This is used to understand how requests are made against your app, so
8
+ # dynamic security testing on your API endpoints can take place.
9
+ #
10
+ # @see Aikido::Zen::Config#api_schema_collection_enabled?
11
+ class Request::Schema
12
+ # @return [Symbol, nil] an identifier for the Content-Type header of the
13
+ # request, if sent.
14
+ attr_reader :content_type
15
+
16
+ # @return [Aikido::Zen::Request::Schema::Definition]
17
+ attr_reader :body_schema
18
+
19
+ # @return [Aikido::Zen::Request::Schema::Definition]
20
+ attr_reader :query_schema
21
+
22
+ # @return [Aikido::Zen::Request::Schema::AuthSchemas]
23
+ attr_reader :auth_schema
24
+
25
+ # Extracts the request information from the current Context (if configured)
26
+ # and builds a Schema out of it.
27
+ #
28
+ # @param context [Aikido::Zen::Context, nil]
29
+ # @return [Aikido::Zen::Request::Schema, nil]
30
+ def self.build(context = Aikido::Zen.current_context)
31
+ return if context.nil?
32
+
33
+ Request::Schema::Builder.new(context: context).schema
34
+ end
35
+
36
+ def initialize(content_type:, body_schema:, query_schema:, auth_schema:)
37
+ @content_type = content_type
38
+ @query_schema = query_schema
39
+ @body_schema = body_schema
40
+ @auth_schema = auth_schema
41
+ end
42
+
43
+ # @return [Hash]
44
+ def as_json
45
+ body = {type: content_type, schema: body_schema.as_json}.compact
46
+ body = nil if body.empty?
47
+
48
+ {body: body, query: query_schema.as_json, auth: auth_schema.as_json}.compact
49
+ end
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
+
69
+ # Merges the request specification with another request's specification.
70
+ #
71
+ # @param other [Aikido::Zen::Request::Schema, nil]
72
+ # @return [Aikido::Zen::Request::Schema]
73
+ def merge(other)
74
+ return self if other.nil?
75
+
76
+ self.class.new(
77
+ content_type: other.content_type,
78
+ body_schema: body_schema.merge(other.body_schema),
79
+ query_schema: query_schema.merge(other.query_schema),
80
+ auth_schema: auth_schema.merge(other.auth_schema)
81
+ )
82
+ end
83
+ alias_method :|, :merge
84
+ end
85
+ end
86
+
87
+ require_relative "schema/builder"