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.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +674 -0
- data/README.md +40 -0
- data/Rakefile +63 -0
- data/lib/aikido/zen/actor.rb +116 -0
- data/lib/aikido/zen/agent.rb +187 -0
- data/lib/aikido/zen/api_client.rb +132 -0
- data/lib/aikido/zen/attack.rb +138 -0
- data/lib/aikido/zen/capped_collections.rb +68 -0
- data/lib/aikido/zen/config.rb +229 -0
- data/lib/aikido/zen/context/rack_request.rb +24 -0
- data/lib/aikido/zen/context/rails_request.rb +42 -0
- data/lib/aikido/zen/context.rb +101 -0
- data/lib/aikido/zen/errors.rb +88 -0
- data/lib/aikido/zen/event.rb +66 -0
- data/lib/aikido/zen/internals.rb +64 -0
- data/lib/aikido/zen/libzen-v0.1.26.aarch64.dylib +0 -0
- data/lib/aikido/zen/middleware/check_allowed_addresses.rb +38 -0
- data/lib/aikido/zen/middleware/set_context.rb +26 -0
- data/lib/aikido/zen/middleware/throttler.rb +50 -0
- data/lib/aikido/zen/outbound_connection.rb +45 -0
- data/lib/aikido/zen/outbound_connection_monitor.rb +19 -0
- data/lib/aikido/zen/package.rb +22 -0
- data/lib/aikido/zen/payload.rb +48 -0
- data/lib/aikido/zen/rails_engine.rb +53 -0
- data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
- data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
- data/lib/aikido/zen/rate_limiter/result.rb +31 -0
- data/lib/aikido/zen/rate_limiter.rb +55 -0
- data/lib/aikido/zen/request/heuristic_router.rb +109 -0
- data/lib/aikido/zen/request/rails_router.rb +84 -0
- data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
- data/lib/aikido/zen/request/schema/auth_schemas.rb +40 -0
- data/lib/aikido/zen/request/schema/builder.rb +125 -0
- data/lib/aikido/zen/request/schema/definition.rb +112 -0
- data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
- data/lib/aikido/zen/request/schema.rb +72 -0
- data/lib/aikido/zen/request.rb +97 -0
- data/lib/aikido/zen/route.rb +39 -0
- data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
- data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
- data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
- data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
- data/lib/aikido/zen/runtime_settings.rb +70 -0
- data/lib/aikido/zen/scan.rb +75 -0
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +95 -0
- data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
- data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +85 -0
- data/lib/aikido/zen/scanners/ssrf_scanner.rb +251 -0
- data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +43 -0
- data/lib/aikido/zen/scanners.rb +5 -0
- data/lib/aikido/zen/sink.rb +108 -0
- data/lib/aikido/zen/sinks/async_http.rb +63 -0
- data/lib/aikido/zen/sinks/curb.rb +89 -0
- data/lib/aikido/zen/sinks/em_http.rb +71 -0
- data/lib/aikido/zen/sinks/excon.rb +103 -0
- data/lib/aikido/zen/sinks/http.rb +76 -0
- data/lib/aikido/zen/sinks/httpclient.rb +68 -0
- data/lib/aikido/zen/sinks/httpx.rb +61 -0
- data/lib/aikido/zen/sinks/mysql2.rb +21 -0
- data/lib/aikido/zen/sinks/net_http.rb +85 -0
- data/lib/aikido/zen/sinks/patron.rb +88 -0
- data/lib/aikido/zen/sinks/pg.rb +50 -0
- data/lib/aikido/zen/sinks/resolv.rb +41 -0
- data/lib/aikido/zen/sinks/socket.rb +51 -0
- data/lib/aikido/zen/sinks/sqlite3.rb +30 -0
- data/lib/aikido/zen/sinks/trilogy.rb +21 -0
- data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
- data/lib/aikido/zen/sinks.rb +21 -0
- data/lib/aikido/zen/stats/routes.rb +53 -0
- data/lib/aikido/zen/stats/sink_stats.rb +95 -0
- data/lib/aikido/zen/stats/users.rb +26 -0
- data/lib/aikido/zen/stats.rb +171 -0
- data/lib/aikido/zen/synchronizable.rb +24 -0
- data/lib/aikido/zen/system_info.rb +84 -0
- data/lib/aikido/zen/version.rb +10 -0
- data/lib/aikido/zen.rb +138 -0
- data/lib/aikido-zen.rb +3 -0
- data/lib/aikido.rb +3 -0
- data/tasklib/libzen.rake +128 -0
- 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
|