aikido-zen 0.1.0.alpha4-x86_64-darwin
Sign up to get free protection for your applications and to get access to all the features.
- 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.x86_64.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
|