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,84 @@
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
+ route_set.router.recognize(request) do |route, _|
33
+ app = route.app
34
+ next unless app.matches?(request)
35
+
36
+ if app.dispatcher?
37
+ return build_route(route, request, prefix: prefix)
38
+ end
39
+
40
+ if app.engine?
41
+ # If the SCRIPT_NAME has any path parameters, we want those to be
42
+ # captured by the router. (eg `mount API => "/api/:version/`)
43
+ prefix = ActionDispatch::Routing::RouteWrapper.new(route).path
44
+ return recognize_in_route_set(request, app.rack_app.routes, prefix: prefix)
45
+ end
46
+
47
+ if app.rack_app.respond_to?(:redirect?) && app.rack_app.redirect?
48
+ return build_route(route, request, prefix: prefix)
49
+ end
50
+
51
+ # At this point we're matching plain Rack apps, where Rails does not
52
+ # remove the SCRIPT_NAME from PATH_INFO, so we should avoid adding
53
+ # SCRIPT_NAME twice.
54
+ return build_route(route, request, prefix: nil)
55
+ end
56
+
57
+ nil
58
+ end
59
+
60
+ private def build_route(route, request, prefix: request.script_name)
61
+ Rails::Route.new(route, prefix: prefix, verb: request.request_method)
62
+ end
63
+ end
64
+
65
+ module Rails
66
+ class Route < Aikido::Zen::Route
67
+ attr_reader :verb
68
+
69
+ def initialize(rails_route, verb: rails_route.verb, prefix: nil)
70
+ @route = ActionDispatch::Routing::RouteWrapper.new(rails_route)
71
+ @verb = verb
72
+ @prefix = prefix
73
+ end
74
+
75
+ def path
76
+ if @prefix.present?
77
+ File.join(@prefix.to_s, @route.path).chomp("/")
78
+ else
79
+ @route.path
80
+ end
81
+ end
82
+ end
83
+ end
84
+ 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,40 @@
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 ==(other)
22
+ other.is_a?(self.class) && schemas == other.schemas
23
+ end
24
+
25
+ NONE = new([])
26
+
27
+ Authorization = Struct.new(:scheme) do
28
+ def as_json
29
+ {type: "http", scheme: scheme.downcase}
30
+ end
31
+ end
32
+
33
+ ApiKey = Struct.new(:location, :name) do
34
+ def as_json
35
+ {type: "apiKey", in: location, name: name}.compact
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,125 @@
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
+ return unless @config.api_schema_collection_enabled?
20
+
21
+ Request::Schema.new(
22
+ content_type: body_data_type,
23
+ body_schema: body_schema,
24
+ query_schema: query_schema,
25
+ auth_schema: AuthDiscovery.new(@context).schemas
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def new(definition)
32
+ Aikido::Zen::Request::Schema::Definition.new(definition)
33
+ end
34
+
35
+ def request
36
+ @context.request
37
+ end
38
+
39
+ def body_data_type
40
+ media_type = request.media_type.to_s
41
+
42
+ # If the media type includes any tree other than the standard (vnd., prs.,
43
+ # x., etc) and a suffix, then remove that bit and just keep the suffix,
44
+ # which should tell us what the underlying data structure is.
45
+ #
46
+ # application/json => application/json
47
+ # application/vnd.github.v3+json => application/json
48
+ media_type = media_type.sub(%r{/.*\+}, "/") if media_type.include?("+")
49
+
50
+ DATA_TYPES.fetch(media_type, nil)
51
+ end
52
+
53
+ def query_schema
54
+ return EMPTY_SCHEMA if request.query_string.to_s.empty?
55
+
56
+ discover_schema(@context.payload_sources[:query])
57
+ end
58
+
59
+ def body_schema
60
+ return EMPTY_SCHEMA if request.content_length.to_i.zero?
61
+
62
+ discover_schema(sanitize_data(@context.payload_sources[:body]))
63
+ end
64
+
65
+ def discover_schema(object, depth: 0)
66
+ case object
67
+ when nil
68
+ new(type: "null")
69
+ when true, false
70
+ new(type: "boolean")
71
+ when String
72
+ new(type: "string")
73
+ when Integer
74
+ new(type: "integer")
75
+ when Numeric
76
+ new(type: "number")
77
+ when Array
78
+ # If the array has at least one item, we assume it's homogeneous for
79
+ # performance reasons, and so only inspect the type of the first one.
80
+ sub_schema = {items: discover_schema(object.first, depth: depth + 1)} unless object.empty?
81
+ new({type: "array"}.merge(sub_schema.to_h))
82
+ when Hash
83
+ object
84
+ .take(@max_props)
85
+ .each_with_object({type: "object", properties: {}}) { |(key, value), schema|
86
+ break schema if depth >= @max_depth
87
+ schema[:properties][key] = discover_schema(value, depth: depth + 1)
88
+ }
89
+ .then { |dfn| new(dfn) }
90
+ end
91
+ end
92
+
93
+ # By default, Rails' automatic decoding wraps non Hash inputs in a Hash
94
+ # with a _json key, so that the "params" object is always a Hash. So, for
95
+ # example, the request body: '["this","is","json"]' is transformed to
96
+ # '{"_json": ["this","is","json"]}' before being passed to the controller.
97
+ #
98
+ # We want to make sure to avoid this extra key when building the schema,
99
+ # since we won't be able to play back requests with it.
100
+ def sanitize_data(data)
101
+ return data unless @context.request.framework == "rails"
102
+ return data unless data.is_a?(Hash)
103
+
104
+ if data.is_a?(Hash) && data.keys == ["_json"]
105
+ data["_json"]
106
+ else
107
+ data
108
+ end
109
+ end
110
+
111
+ DATA_TYPES = {
112
+ "application/csp-report" => :json,
113
+ "application/x-json" => :json,
114
+ "application/json" => :json,
115
+
116
+ "application/x-www-form-urlencoded" => :"form-urlencoded",
117
+
118
+ "multipart/form-data" => :"form-data",
119
+
120
+ "application/xml" => :xml,
121
+ "text/xml" => :xml
122
+ }
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,112 @@
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
+ # number | integer => number
69
+ in [{type: "integer"}, {type: "number"}] |
70
+ [{type: "number"}, {type: "integer"}]
71
+ new(definition.merge(other.definition).merge(type: "number"))
72
+
73
+ # x | y => [x, y] if x != y
74
+ else
75
+ left_type, right_type = definition[:type], other.definition[:type]
76
+ types = [left_type, right_type].flatten.uniq.sort
77
+ new(definition.merge(other.definition).merge(type: types))
78
+
79
+ end
80
+ end
81
+ alias_method :|, :merge
82
+
83
+ def ==(other)
84
+ as_json == other.as_json
85
+ end
86
+
87
+ def as_json
88
+ definition
89
+ .transform_keys(&:to_s)
90
+ .transform_values do |val|
91
+ val.respond_to?(:as_json) ? val.as_json : val
92
+ end
93
+ end
94
+
95
+ def inspect
96
+ format("#<%s %p>", self.class, definition)
97
+ end
98
+
99
+ protected
100
+
101
+ attr_reader :definition
102
+
103
+ private
104
+
105
+ def new(definition)
106
+ self.class.new(definition)
107
+ end
108
+
109
+ NULL = new(type: "null") # used as a stand-in to merge missing object props
110
+ end
111
+ end
112
+ 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,72 @@
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
+ # Merges the request specification with another request's specification.
52
+ #
53
+ # @param other [Aikido::Zen::Request::Schema, nil]
54
+ # @return [Aikido::Zen::Request::Schema]
55
+ def merge(other)
56
+ return self if other.nil?
57
+
58
+ self.class.new(
59
+ # TODO: this is currently overriding the content type with the new
60
+ # value, but we should support APIs that accept input in many types
61
+ # (e.g. JSON and XML)
62
+ content_type: other.content_type,
63
+ body_schema: body_schema.merge(other.body_schema),
64
+ query_schema: query_schema.merge(other.query_schema),
65
+ auth_schema: auth_schema.merge(other.auth_schema)
66
+ )
67
+ end
68
+ alias_method :|, :merge
69
+ end
70
+ end
71
+
72
+ require_relative "schema/builder"
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module Aikido::Zen
6
+ # Wrapper around Rack::Request-like objects to add some behavior.
7
+ class Request < SimpleDelegator
8
+ # @return [String] identifier of the framework handling this HTTP request.
9
+ attr_reader :framework
10
+
11
+ # @return [Aikido::Zen::Router]
12
+ attr_reader :router
13
+
14
+ def initialize(delegate, framework:, router:)
15
+ super(delegate)
16
+ @framework = framework
17
+ @router = router
18
+ @body_read = false
19
+ end
20
+
21
+ def __setobj__(delegate) # :nodoc:
22
+ super
23
+ @body_read = false
24
+ @route = @normalized_header = @truncated_body = nil
25
+ end
26
+
27
+ # @return [Aikido::Zen::Route] the framework route being requested.
28
+ def route
29
+ @route ||= @router.recognize(self)
30
+ end
31
+
32
+ # @return [Aikido::Zen::Request::Schema, nil]
33
+ def schema
34
+ @schema ||= Aikido::Zen::Request::Schema.build
35
+ end
36
+
37
+ # Map the CGI-style env Hash into "pretty-looking" headers, preserving the
38
+ # values as-is. For example, HTTP_ACCEPT turns into "Accept", CONTENT_TYPE
39
+ # turns into "Content-Type", and HTTP_X_FORWARDED_FOR turns into
40
+ # "X-Forwarded-For".
41
+ #
42
+ # @return [Hash<String, String>]
43
+ def normalized_headers
44
+ @normalized_headers ||= env.slice(*BLESSED_CGI_HEADERS)
45
+ .merge(env.select { |key, _| key.start_with?("HTTP_") })
46
+ .transform_keys { |header|
47
+ name = header.sub(/^HTTP_/, "").downcase
48
+ name.split("_").map { |part| part[0].upcase + part[1..] }.join("-")
49
+ }
50
+ end
51
+
52
+ # @api private
53
+ #
54
+ # Reads the first 16KiB of the request body, to include in attack reports
55
+ # back to the Aikido server. This method should only be called if an attack
56
+ # is detected during the current request.
57
+ #
58
+ # If the underlying IO object has been partially (or fully) read before,
59
+ # this will attempt to restore the previous cursor position after reading it
60
+ # if possible, or leave if rewund if not.
61
+ #
62
+ # @param max_size [Integer] number of bytes to read at most.
63
+ #
64
+ # @return [String]
65
+ def truncated_body(max_size: 16384)
66
+ return @truncated_body if @body_read
67
+ return nil if body.nil?
68
+
69
+ begin
70
+ initial_pos = body.pos if body.respond_to?(:pos)
71
+ body.rewind
72
+ @truncated_body = body.read(max_size)
73
+ ensure
74
+ @body_read = true
75
+ body.rewind
76
+ body.seek(initial_pos) if initial_pos && body.respond_to?(:seek)
77
+ end
78
+ end
79
+
80
+ def as_json
81
+ {
82
+ method: request_method.downcase,
83
+ url: url,
84
+ ipAddress: ip,
85
+ userAgent: user_agent,
86
+ headers: normalized_headers.reject { |_, val| val.to_s.empty? },
87
+ body: truncated_body,
88
+ source: framework,
89
+ route: route&.path
90
+ }
91
+ end
92
+
93
+ BLESSED_CGI_HEADERS = %w[CONTENT_TYPE CONTENT_LENGTH]
94
+ end
95
+ end
96
+
97
+ require_relative "request/schema"
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ # Routes keep information about the mapping defined in the current web
5
+ # framework to go from a given HTTP request to the code that handles said
6
+ # request.
7
+ class Route
8
+ # @return [String] the HTTP verb used to request this route.
9
+ attr_reader :verb
10
+
11
+ # @return [String] the URL pattern used to match request paths. For
12
+ # example "/users/:id".
13
+ attr_reader :path
14
+
15
+ def initialize(verb:, path:)
16
+ @verb = verb
17
+ @path = path
18
+ end
19
+
20
+ def as_json
21
+ {method: verb, path: path}
22
+ end
23
+
24
+ def ==(other)
25
+ other.is_a?(Route) &&
26
+ other.verb == verb &&
27
+ other.path == path
28
+ end
29
+ alias_method :eql?, :==
30
+
31
+ def hash
32
+ [verb, path].hash
33
+ end
34
+
35
+ def inspect
36
+ "#<#{self.class.name} #{verb} #{path.inspect}>"
37
+ end
38
+ end
39
+ end