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,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