aikido-zen 1.0.2.beta.2-aarch64-linux
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.aikido +6 -0
- data/.ruby-version +1 -0
- data/.simplecov +26 -0
- data/.standard.yml +3 -0
- data/LICENSE +674 -0
- data/README.md +146 -0
- data/Rakefile +67 -0
- data/benchmarks/README.md +23 -0
- data/benchmarks/rails7.1_sql_injection.js +70 -0
- data/docs/banner.svg +202 -0
- data/docs/config.md +125 -0
- data/docs/proxy.md +10 -0
- data/docs/rails.md +114 -0
- data/lib/aikido/zen/actor.rb +116 -0
- data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
- data/lib/aikido/zen/agent.rb +179 -0
- data/lib/aikido/zen/api_client.rb +145 -0
- data/lib/aikido/zen/attack.rb +207 -0
- data/lib/aikido/zen/background_worker.rb +52 -0
- data/lib/aikido/zen/capped_collections.rb +68 -0
- data/lib/aikido/zen/collector/hosts.rb +15 -0
- data/lib/aikido/zen/collector/routes.rb +66 -0
- data/lib/aikido/zen/collector/sink_stats.rb +95 -0
- data/lib/aikido/zen/collector/stats.rb +111 -0
- data/lib/aikido/zen/collector/users.rb +30 -0
- data/lib/aikido/zen/collector.rb +144 -0
- data/lib/aikido/zen/config.rb +282 -0
- data/lib/aikido/zen/context/rack_request.rb +24 -0
- data/lib/aikido/zen/context/rails_request.rb +44 -0
- data/lib/aikido/zen/context.rb +112 -0
- data/lib/aikido/zen/detached_agent/agent.rb +78 -0
- data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
- data/lib/aikido/zen/detached_agent/server.rb +78 -0
- data/lib/aikido/zen/detached_agent.rb +2 -0
- data/lib/aikido/zen/errors.rb +107 -0
- data/lib/aikido/zen/event.rb +71 -0
- data/lib/aikido/zen/internals.rb +103 -0
- data/lib/aikido/zen/libzen-v0.1.39-aarch64-linux.so +0 -0
- data/lib/aikido/zen/middleware/check_allowed_addresses.rb +26 -0
- data/lib/aikido/zen/middleware/middleware.rb +11 -0
- data/lib/aikido/zen/middleware/rack_throttler.rb +48 -0
- data/lib/aikido/zen/middleware/request_tracker.rb +192 -0
- data/lib/aikido/zen/middleware/set_context.rb +26 -0
- data/lib/aikido/zen/outbound_connection.rb +45 -0
- data/lib/aikido/zen/outbound_connection_monitor.rb +23 -0
- data/lib/aikido/zen/package.rb +22 -0
- data/lib/aikido/zen/payload.rb +50 -0
- data/lib/aikido/zen/rails_engine.rb +56 -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 +50 -0
- data/lib/aikido/zen/request/heuristic_router.rb +115 -0
- data/lib/aikido/zen/request/rails_router.rb +77 -0
- data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
- data/lib/aikido/zen/request/schema/auth_schemas.rb +54 -0
- data/lib/aikido/zen/request/schema/builder.rb +121 -0
- data/lib/aikido/zen/request/schema/definition.rb +107 -0
- data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
- data/lib/aikido/zen/request/schema.rb +87 -0
- data/lib/aikido/zen/request.rb +122 -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 +65 -0
- data/lib/aikido/zen/scan.rb +75 -0
- data/lib/aikido/zen/scanners/path_traversal/helpers.rb +65 -0
- data/lib/aikido/zen/scanners/path_traversal_scanner.rb +63 -0
- data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
- data/lib/aikido/zen/scanners/shell_injection_scanner.rb +64 -0
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +93 -0
- data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
- data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +97 -0
- data/lib/aikido/zen/scanners/ssrf_scanner.rb +265 -0
- data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +49 -0
- data/lib/aikido/zen/scanners.rb +7 -0
- data/lib/aikido/zen/sink.rb +118 -0
- data/lib/aikido/zen/sinks/action_controller.rb +83 -0
- data/lib/aikido/zen/sinks/async_http.rb +80 -0
- data/lib/aikido/zen/sinks/curb.rb +113 -0
- data/lib/aikido/zen/sinks/em_http.rb +83 -0
- data/lib/aikido/zen/sinks/excon.rb +118 -0
- data/lib/aikido/zen/sinks/file.rb +112 -0
- data/lib/aikido/zen/sinks/http.rb +93 -0
- data/lib/aikido/zen/sinks/httpclient.rb +95 -0
- data/lib/aikido/zen/sinks/httpx.rb +78 -0
- data/lib/aikido/zen/sinks/kernel.rb +33 -0
- data/lib/aikido/zen/sinks/mysql2.rb +31 -0
- data/lib/aikido/zen/sinks/net_http.rb +101 -0
- data/lib/aikido/zen/sinks/patron.rb +103 -0
- data/lib/aikido/zen/sinks/pg.rb +72 -0
- data/lib/aikido/zen/sinks/resolv.rb +62 -0
- data/lib/aikido/zen/sinks/socket.rb +78 -0
- data/lib/aikido/zen/sinks/sqlite3.rb +46 -0
- data/lib/aikido/zen/sinks/trilogy.rb +31 -0
- data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
- data/lib/aikido/zen/sinks.rb +36 -0
- data/lib/aikido/zen/sinks_dsl.rb +250 -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/worker.rb +87 -0
- data/lib/aikido/zen.rb +246 -0
- data/lib/aikido-zen.rb +3 -0
- data/placeholder/.gitignore +4 -0
- data/placeholder/README.md +11 -0
- data/placeholder/Rakefile +75 -0
- data/placeholder/lib/placeholder.rb.template +3 -0
- data/placeholder/placeholder.gemspec.template +20 -0
- data/tasklib/bench.rake +94 -0
- data/tasklib/libzen.rake +133 -0
- data/tasklib/wrk.rb +88 -0
- metadata +205 -0
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "auth_schemas"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
class Request::Schema
|
7
|
+
class AuthDiscovery
|
8
|
+
def initialize(context)
|
9
|
+
@context = context
|
10
|
+
end
|
11
|
+
|
12
|
+
def schemas
|
13
|
+
schemas = []
|
14
|
+
schemas << extract_from_authorization_header if headers["Authorization"]
|
15
|
+
schemas.concat(extract_from_headers)
|
16
|
+
schemas.concat(extract_from_cookies)
|
17
|
+
|
18
|
+
AuthSchemas.new(schemas)
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def extract_from_authorization_header
|
24
|
+
type, _ = headers["Authorization"].to_s.split(/\s+/, 2)
|
25
|
+
|
26
|
+
if AUTHORIZATION_SCHEMES.include?(type.to_s.downcase)
|
27
|
+
AuthSchemas::Authorization.new(type)
|
28
|
+
else
|
29
|
+
AuthSchemas::ApiKey.new(:header, "Authorization")
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def extract_from_headers
|
34
|
+
(headers.keys & COMMON_API_KEY_HEADERS)
|
35
|
+
.map { |header| AuthSchemas::ApiKey.new(:header, header) }
|
36
|
+
end
|
37
|
+
|
38
|
+
def extract_from_cookies
|
39
|
+
cookie_names = @context.payload_sources[:cookie].keys.map(&:downcase)
|
40
|
+
|
41
|
+
(cookie_names & COMMON_COOKIE_NAMES)
|
42
|
+
.map { |cookie| AuthSchemas::ApiKey.new(:cookie, cookie) }
|
43
|
+
end
|
44
|
+
|
45
|
+
def headers
|
46
|
+
@context.request.normalized_headers
|
47
|
+
end
|
48
|
+
|
49
|
+
AUTHORIZATION_SCHEMES = %w[
|
50
|
+
basic
|
51
|
+
bearer
|
52
|
+
digest
|
53
|
+
dpop
|
54
|
+
gnap
|
55
|
+
hoba
|
56
|
+
mutal
|
57
|
+
negotiate
|
58
|
+
privatetoken
|
59
|
+
scram-sha-1
|
60
|
+
scram-sha-256
|
61
|
+
vapid
|
62
|
+
].freeze
|
63
|
+
|
64
|
+
COMMON_API_KEY_HEADERS = %w[
|
65
|
+
Apikey
|
66
|
+
Api-Key
|
67
|
+
Token
|
68
|
+
X-Api-Key
|
69
|
+
X-Token
|
70
|
+
]
|
71
|
+
|
72
|
+
COMMON_COOKIE_NAMES = %w[
|
73
|
+
user_id
|
74
|
+
auth
|
75
|
+
session
|
76
|
+
jwt
|
77
|
+
token
|
78
|
+
sid
|
79
|
+
connect.sid
|
80
|
+
auth_token
|
81
|
+
access_token
|
82
|
+
refresh_token
|
83
|
+
]
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
class Request::Schema
|
5
|
+
class AuthSchemas
|
6
|
+
attr_reader :schemas
|
7
|
+
|
8
|
+
def initialize(schemas)
|
9
|
+
@schemas = schemas
|
10
|
+
end
|
11
|
+
|
12
|
+
def merge(other)
|
13
|
+
self.class.new((schemas + other.schemas).uniq)
|
14
|
+
end
|
15
|
+
alias_method :|, :merge
|
16
|
+
|
17
|
+
def as_json
|
18
|
+
@schemas.map(&:as_json) unless @schemas.empty?
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.from_json(schemas_array)
|
22
|
+
return NONE if !schemas_array || schemas_array.empty?
|
23
|
+
|
24
|
+
AuthSchemas.new(schemas_array.map do |schema|
|
25
|
+
if schema[:type] == "http"
|
26
|
+
Authorization.new(schema[:scheme])
|
27
|
+
elsif schema[:type] == "apiKey"
|
28
|
+
ApiKey.new(schema[:in], schema[:name])
|
29
|
+
else
|
30
|
+
raise "Invalid schema type: #{schema[:type]}"
|
31
|
+
end
|
32
|
+
end)
|
33
|
+
end
|
34
|
+
|
35
|
+
def ==(other)
|
36
|
+
other.is_a?(self.class) && schemas == other.schemas
|
37
|
+
end
|
38
|
+
|
39
|
+
NONE = new([])
|
40
|
+
|
41
|
+
Authorization = Struct.new(:scheme) do
|
42
|
+
def as_json
|
43
|
+
{type: "http", scheme: scheme.downcase}
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
ApiKey = Struct.new(:location, :name) do
|
48
|
+
def as_json
|
49
|
+
{type: "apiKey", in: location, name: name}.compact
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "definition"
|
4
|
+
require_relative "empty_schema"
|
5
|
+
require_relative "auth_discovery"
|
6
|
+
|
7
|
+
module Aikido::Zen
|
8
|
+
class Request::Schema
|
9
|
+
# @api private
|
10
|
+
class Builder
|
11
|
+
def initialize(context: Aikido::Zen.current_context, config: Aikido::Zen.config)
|
12
|
+
@context = context
|
13
|
+
@config = config
|
14
|
+
@max_depth = @config.api_schema_collection_max_depth
|
15
|
+
@max_props = @config.api_schema_collection_max_properties
|
16
|
+
end
|
17
|
+
|
18
|
+
def schema
|
19
|
+
Request::Schema.new(
|
20
|
+
content_type: body_data_type,
|
21
|
+
body_schema: body_schema,
|
22
|
+
query_schema: query_schema,
|
23
|
+
auth_schema: AuthDiscovery.new(@context).schemas
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def new(definition)
|
30
|
+
Aikido::Zen::Request::Schema::Definition.new(definition)
|
31
|
+
end
|
32
|
+
|
33
|
+
def request
|
34
|
+
@context.request
|
35
|
+
end
|
36
|
+
|
37
|
+
def body_data_type
|
38
|
+
media_type = request.media_type.to_s
|
39
|
+
|
40
|
+
# If the media type includes any tree other than the standard (vnd., prs.,
|
41
|
+
# x., etc) and a suffix, then remove that bit and just keep the suffix,
|
42
|
+
# which should tell us what the underlying data structure is.
|
43
|
+
#
|
44
|
+
# application/json => application/json
|
45
|
+
# application/vnd.github.v3+json => application/json
|
46
|
+
media_type = media_type.sub(%r{/.*\+}, "/") if media_type.include?("+")
|
47
|
+
|
48
|
+
DATA_TYPES.fetch(media_type, nil)
|
49
|
+
end
|
50
|
+
|
51
|
+
def query_schema
|
52
|
+
return EMPTY_SCHEMA if request.query_string.to_s.empty?
|
53
|
+
|
54
|
+
discover_schema(@context.payload_sources[:query])
|
55
|
+
end
|
56
|
+
|
57
|
+
def body_schema
|
58
|
+
return EMPTY_SCHEMA if request.content_length.to_i.zero?
|
59
|
+
|
60
|
+
discover_schema(sanitize_data(@context.payload_sources[:body]))
|
61
|
+
end
|
62
|
+
|
63
|
+
def discover_schema(object, depth: 0)
|
64
|
+
case object
|
65
|
+
when nil
|
66
|
+
new(type: "null")
|
67
|
+
when true, false
|
68
|
+
new(type: "boolean")
|
69
|
+
when String
|
70
|
+
new(type: "string")
|
71
|
+
when Numeric
|
72
|
+
new(type: "number")
|
73
|
+
when Array
|
74
|
+
# If the array has at least one item, we assume it's homogeneous for
|
75
|
+
# performance reasons, and so only inspect the type of the first one.
|
76
|
+
sub_schema = {items: discover_schema(object.first, depth: depth + 1)} unless object.empty?
|
77
|
+
new({type: "array"}.merge(sub_schema.to_h))
|
78
|
+
when Hash
|
79
|
+
object
|
80
|
+
.take(@max_props)
|
81
|
+
.each_with_object({type: "object", properties: {}}) { |(key, value), schema|
|
82
|
+
break schema if depth >= @max_depth
|
83
|
+
schema[:properties][key] = discover_schema(value, depth: depth + 1)
|
84
|
+
}
|
85
|
+
.then { |dfn| new(dfn) }
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# By default, Rails' automatic decoding wraps non Hash inputs in a Hash
|
90
|
+
# with a _json key, so that the "params" object is always a Hash. So, for
|
91
|
+
# example, the request body: '["this","is","json"]' is transformed to
|
92
|
+
# '{"_json": ["this","is","json"]}' before being passed to the controller.
|
93
|
+
#
|
94
|
+
# We want to make sure to avoid this extra key when building the schema,
|
95
|
+
# since we won't be able to play back requests with it.
|
96
|
+
def sanitize_data(data)
|
97
|
+
return data unless @context.request.framework == "rails"
|
98
|
+
return data unless data.is_a?(Hash)
|
99
|
+
|
100
|
+
if data.is_a?(Hash) && data.keys == ["_json"]
|
101
|
+
data["_json"]
|
102
|
+
else
|
103
|
+
data
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
DATA_TYPES = {
|
108
|
+
"application/csp-report" => :json,
|
109
|
+
"application/x-json" => :json,
|
110
|
+
"application/json" => :json,
|
111
|
+
|
112
|
+
"application/x-www-form-urlencoded" => :"form-urlencoded",
|
113
|
+
|
114
|
+
"multipart/form-data" => :"form-data",
|
115
|
+
|
116
|
+
"application/xml" => :xml,
|
117
|
+
"text/xml" => :xml
|
118
|
+
}
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
require_relative "empty_schema"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
class Request::Schema
|
8
|
+
# @api private
|
9
|
+
#
|
10
|
+
# The "JSON Schema"-like implementation that we extract from looking at the
|
11
|
+
# request body and/or query string.
|
12
|
+
class Definition
|
13
|
+
extend Forwardable
|
14
|
+
def_delegators :definition, :deconstruct_keys
|
15
|
+
|
16
|
+
def initialize(definition)
|
17
|
+
@definition = definition
|
18
|
+
end
|
19
|
+
|
20
|
+
# Recursively merges this schema definition with another one.
|
21
|
+
#
|
22
|
+
# * Properties missing in one or the other schemas are treated as optional.
|
23
|
+
# * Merging any property with a null schema results in an optional schema.
|
24
|
+
# * Number and Integer schemas are merged into Number schemas, since it's
|
25
|
+
# the more permissive of the types.
|
26
|
+
#
|
27
|
+
# Other than that, everything else just results in additive merging,
|
28
|
+
# resulting in combining types together.
|
29
|
+
#
|
30
|
+
# @param other [Aikido::Zen::Request::Schema::Definition, nil]
|
31
|
+
# @return [Aikido::Zen::Request::Schema::Definition]
|
32
|
+
#
|
33
|
+
# @see https://cswr.github.io/JsonSchema/spec/introduction/
|
34
|
+
def merge(other)
|
35
|
+
case [self, other]
|
36
|
+
|
37
|
+
# Merging with itself or with nil results in just a copy
|
38
|
+
in [obj, ^obj | nil | EMPTY_SCHEMA]
|
39
|
+
dup
|
40
|
+
|
41
|
+
# objects where at least one of them has properties
|
42
|
+
in [{type: "object", properties: _}, {type: "object", properties: _}] |
|
43
|
+
[{type: "object", properties: _}, {type: "object"}] |
|
44
|
+
[{type: "object"}, {type: "object", properties: _}]
|
45
|
+
left, right = definition[:properties], other.definition[:properties]
|
46
|
+
merged_props = (left.keys.to_a | right.keys.to_a)
|
47
|
+
.map { |key| [key, left.fetch(key, NULL).merge(right.fetch(key, NULL))] }
|
48
|
+
.to_h
|
49
|
+
new(definition.merge(other.definition).merge(properties: merged_props))
|
50
|
+
|
51
|
+
# arrays where at least one of them has items
|
52
|
+
in [{type: "array", items: _}, {type: "array", items: _}] |
|
53
|
+
[{type: "array", items: _}, {type: "array"}] |
|
54
|
+
[{type: "array"}, {type: "array", items: _}]
|
55
|
+
items = [definition[:items], other.definition[:items]].compact.reduce(:merge)
|
56
|
+
new(definition.merge(other.definition).merge({items: items}.compact))
|
57
|
+
|
58
|
+
# x | x => x
|
59
|
+
in {type: type}, {type: ^type}
|
60
|
+
new(definition.merge(other.definition))
|
61
|
+
|
62
|
+
# any | null => any?
|
63
|
+
in {type: "null"}, {type: _}
|
64
|
+
new(other.definition.merge(optional: true))
|
65
|
+
in {type: _}, {type: "null"}
|
66
|
+
new(definition.merge(optional: true))
|
67
|
+
|
68
|
+
# x | y => [x, y] if x != y
|
69
|
+
else
|
70
|
+
left_type, right_type = definition[:type], other.definition[:type]
|
71
|
+
types = [left_type, right_type].flatten.uniq.sort
|
72
|
+
new(definition.merge(other.definition).merge(type: types))
|
73
|
+
|
74
|
+
end
|
75
|
+
end
|
76
|
+
alias_method :|, :merge
|
77
|
+
|
78
|
+
def ==(other)
|
79
|
+
as_json == other.as_json
|
80
|
+
end
|
81
|
+
|
82
|
+
def as_json
|
83
|
+
definition
|
84
|
+
.transform_keys(&:to_s)
|
85
|
+
.transform_values do |val|
|
86
|
+
val.respond_to?(:as_json) ? val.as_json : val
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def inspect
|
91
|
+
format("#<%s %p>", self.class, definition)
|
92
|
+
end
|
93
|
+
|
94
|
+
protected
|
95
|
+
|
96
|
+
attr_reader :definition
|
97
|
+
|
98
|
+
private
|
99
|
+
|
100
|
+
def new(definition)
|
101
|
+
self.class.new(definition)
|
102
|
+
end
|
103
|
+
|
104
|
+
NULL = new(type: "null") # used as a stand-in to merge missing object props
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
class Request::Schema
|
5
|
+
# @!visibility private
|
6
|
+
#
|
7
|
+
# Singleton used as a placeholder until we get a schema for a request.
|
8
|
+
# When "merged" it waits until a non-nil value is given and returns that.
|
9
|
+
EMPTY_SCHEMA = Object.new
|
10
|
+
|
11
|
+
class << EMPTY_SCHEMA
|
12
|
+
# @!visibility private
|
13
|
+
def merge(schema)
|
14
|
+
schema || self
|
15
|
+
end
|
16
|
+
alias_method :|, :merge
|
17
|
+
|
18
|
+
# @!visibility private
|
19
|
+
def as_json
|
20
|
+
nil
|
21
|
+
end
|
22
|
+
|
23
|
+
def inspect
|
24
|
+
"#<Aikido::Zen::Schema EMPTY>"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
# Defines the shape of a request received by your application as seen by Zen.
|
7
|
+
# This is used to understand how requests are made against your app, so
|
8
|
+
# dynamic security testing on your API endpoints can take place.
|
9
|
+
#
|
10
|
+
# @see Aikido::Zen::Config#api_schema_collection_enabled?
|
11
|
+
class Request::Schema
|
12
|
+
# @return [Symbol, nil] an identifier for the Content-Type header of the
|
13
|
+
# request, if sent.
|
14
|
+
attr_reader :content_type
|
15
|
+
|
16
|
+
# @return [Aikido::Zen::Request::Schema::Definition]
|
17
|
+
attr_reader :body_schema
|
18
|
+
|
19
|
+
# @return [Aikido::Zen::Request::Schema::Definition]
|
20
|
+
attr_reader :query_schema
|
21
|
+
|
22
|
+
# @return [Aikido::Zen::Request::Schema::AuthSchemas]
|
23
|
+
attr_reader :auth_schema
|
24
|
+
|
25
|
+
# Extracts the request information from the current Context (if configured)
|
26
|
+
# and builds a Schema out of it.
|
27
|
+
#
|
28
|
+
# @param context [Aikido::Zen::Context, nil]
|
29
|
+
# @return [Aikido::Zen::Request::Schema, nil]
|
30
|
+
def self.build(context = Aikido::Zen.current_context)
|
31
|
+
return if context.nil?
|
32
|
+
|
33
|
+
Request::Schema::Builder.new(context: context).schema
|
34
|
+
end
|
35
|
+
|
36
|
+
def initialize(content_type:, body_schema:, query_schema:, auth_schema:)
|
37
|
+
@content_type = content_type
|
38
|
+
@query_schema = query_schema
|
39
|
+
@body_schema = body_schema
|
40
|
+
@auth_schema = auth_schema
|
41
|
+
end
|
42
|
+
|
43
|
+
# @return [Hash]
|
44
|
+
def as_json
|
45
|
+
body = {type: content_type, schema: body_schema.as_json}.compact
|
46
|
+
body = nil if body.empty?
|
47
|
+
|
48
|
+
{body: body, query: query_schema.as_json, auth: auth_schema.as_json}.compact
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.from_json(data)
|
52
|
+
if data.empty?
|
53
|
+
return Request::Schema.new(
|
54
|
+
content_type: nil,
|
55
|
+
body_schema: EMPTY_SCHEMA,
|
56
|
+
query_schema: EMPTY_SCHEMA,
|
57
|
+
auth_schema: Aikido::Zen::Request::Schema::AuthSchemas.new([])
|
58
|
+
)
|
59
|
+
end
|
60
|
+
|
61
|
+
Request::Schema.new(
|
62
|
+
content_type: data[:body].nil? ? nil : data[:body][:type],
|
63
|
+
body_schema: data[:body].nil? ? EMPTY_SCHEMA : Aikido::Zen::Request::Schema::Definition.new(data[:body][:schema]),
|
64
|
+
query_schema: data[:query].nil? ? EMPTY_SCHEMA : Aikido::Zen::Request::Schema::Definition.new(data[:query]),
|
65
|
+
auth_schema: Aikido::Zen::Request::Schema::AuthSchemas.from_json(data[:auth])
|
66
|
+
)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Merges the request specification with another request's specification.
|
70
|
+
#
|
71
|
+
# @param other [Aikido::Zen::Request::Schema, nil]
|
72
|
+
# @return [Aikido::Zen::Request::Schema]
|
73
|
+
def merge(other)
|
74
|
+
return self if other.nil?
|
75
|
+
|
76
|
+
self.class.new(
|
77
|
+
content_type: other.content_type,
|
78
|
+
body_schema: body_schema.merge(other.body_schema),
|
79
|
+
query_schema: query_schema.merge(other.query_schema),
|
80
|
+
auth_schema: auth_schema.merge(other.auth_schema)
|
81
|
+
)
|
82
|
+
end
|
83
|
+
alias_method :|, :merge
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
require_relative "schema/builder"
|
@@ -0,0 +1,122 @@
|
|
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
|
+
# The current user, if set by the host app.
|
15
|
+
#
|
16
|
+
# @return [Aikido::Zen::Actor, nil]
|
17
|
+
# @see Aikido::Zen.track_user
|
18
|
+
attr_accessor :actor
|
19
|
+
|
20
|
+
def initialize(delegate, config = Aikido::Zen.config, framework:, router:)
|
21
|
+
super(delegate)
|
22
|
+
@config = config
|
23
|
+
@framework = framework
|
24
|
+
@router = router
|
25
|
+
@body_read = false
|
26
|
+
end
|
27
|
+
|
28
|
+
def __setobj__(delegate) # :nodoc:
|
29
|
+
super
|
30
|
+
@body_read = false
|
31
|
+
@route = @normalized_header = @truncated_body = nil
|
32
|
+
end
|
33
|
+
|
34
|
+
# @return [Aikido::Zen::Route] the framework route being requested.
|
35
|
+
def route
|
36
|
+
@route ||= @router.recognize(self)
|
37
|
+
end
|
38
|
+
|
39
|
+
# @return [Aikido::Zen::Request::Schema, nil]
|
40
|
+
def schema
|
41
|
+
@schema ||= Aikido::Zen::Request::Schema.build
|
42
|
+
end
|
43
|
+
|
44
|
+
# @api private
|
45
|
+
#
|
46
|
+
# @return [String] the IP address of the client making the request.
|
47
|
+
def client_ip
|
48
|
+
return @client_ip if @client_ip
|
49
|
+
|
50
|
+
if @config.client_ip_header
|
51
|
+
value = env[@config.client_ip_header]
|
52
|
+
if Resolv::AddressRegex.match?(value)
|
53
|
+
@client_ip = value
|
54
|
+
else
|
55
|
+
@config.logger.warn("Invalid IP address in custom client IP header `#{@config.client_ip_header}`: `#{value}`")
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
@client_ip ||= respond_to?(:remote_ip) ? remote_ip : ip
|
60
|
+
end
|
61
|
+
|
62
|
+
# Map the CGI-style env Hash into "pretty-looking" headers, preserving the
|
63
|
+
# values as-is. For example, HTTP_ACCEPT turns into "Accept", CONTENT_TYPE
|
64
|
+
# turns into "Content-Type", and HTTP_X_FORWARDED_FOR turns into
|
65
|
+
# "X-Forwarded-For".
|
66
|
+
#
|
67
|
+
# @return [Hash<String, String>]
|
68
|
+
def normalized_headers
|
69
|
+
@normalized_headers ||= env.slice(*BLESSED_CGI_HEADERS)
|
70
|
+
.merge(env.select { |key, _| key.start_with?("HTTP_") })
|
71
|
+
.transform_keys { |header|
|
72
|
+
name = header.sub(/^HTTP_/, "").downcase
|
73
|
+
name.split("_").map { |part| part[0].upcase + part[1..] }.join("-")
|
74
|
+
}
|
75
|
+
end
|
76
|
+
|
77
|
+
# @api private
|
78
|
+
#
|
79
|
+
# Reads the first 16KiB of the request body, to include in attack reports
|
80
|
+
# back to the Aikido server. This method should only be called if an attack
|
81
|
+
# is detected during the current request.
|
82
|
+
#
|
83
|
+
# If the underlying IO object has been partially (or fully) read before,
|
84
|
+
# this will attempt to restore the previous cursor position after reading it
|
85
|
+
# if possible, or leave if rewund if not.
|
86
|
+
#
|
87
|
+
# @param max_size [Integer] number of bytes to read at most.
|
88
|
+
#
|
89
|
+
# @return [String]
|
90
|
+
def truncated_body(max_size: 16384)
|
91
|
+
return @truncated_body if @body_read
|
92
|
+
return nil if body.nil?
|
93
|
+
|
94
|
+
begin
|
95
|
+
initial_pos = body.pos if body.respond_to?(:pos)
|
96
|
+
body.rewind
|
97
|
+
@truncated_body = body.read(max_size)
|
98
|
+
ensure
|
99
|
+
@body_read = true
|
100
|
+
body.rewind
|
101
|
+
body.seek(initial_pos) if initial_pos && body.respond_to?(:seek)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def as_json
|
106
|
+
{
|
107
|
+
method: request_method.downcase,
|
108
|
+
url: url,
|
109
|
+
ipAddress: client_ip,
|
110
|
+
userAgent: user_agent,
|
111
|
+
headers: normalized_headers.reject { |_, val| val.to_s.empty? },
|
112
|
+
body: truncated_body,
|
113
|
+
source: framework,
|
114
|
+
route: route&.path
|
115
|
+
}
|
116
|
+
end
|
117
|
+
|
118
|
+
BLESSED_CGI_HEADERS = %w[CONTENT_TYPE CONTENT_LENGTH]
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
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
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../route"
|
4
|
+
require_relative "protection_settings"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
# Wraps the list of endpoint protection settings, providing an interface for
|
8
|
+
# checking the settings for any given route. If the route has no configured
|
9
|
+
# settings, that will return the singleton
|
10
|
+
# {RuntimeSettings::ProtectionSettings.none}.
|
11
|
+
#
|
12
|
+
# @example
|
13
|
+
# endpoint = runtime_settings.endpoints[request.route]
|
14
|
+
# block_request unless endpoint.allows?(request.ip)
|
15
|
+
class RuntimeSettings::Endpoints
|
16
|
+
# @param data [Array<Hash>]
|
17
|
+
# @return [Aikido::Zen::RuntimeSettings::Endpoints]
|
18
|
+
def self.from_json(data)
|
19
|
+
data = Array(data).map { |item|
|
20
|
+
route = Route.new(verb: item["method"], path: item["route"])
|
21
|
+
settings = RuntimeSettings::ProtectionSettings.from_json(item)
|
22
|
+
[route, settings]
|
23
|
+
}.to_h
|
24
|
+
|
25
|
+
new(data)
|
26
|
+
end
|
27
|
+
|
28
|
+
def initialize(data = {})
|
29
|
+
@endpoints = data
|
30
|
+
@endpoints.default = RuntimeSettings::ProtectionSettings.none
|
31
|
+
end
|
32
|
+
|
33
|
+
# @param route [Aikido::Zen::Route]
|
34
|
+
# @return [Aikido::Zen::RuntimeSettings::ProtectionSettings]
|
35
|
+
def [](route)
|
36
|
+
@endpoints[route]
|
37
|
+
end
|
38
|
+
|
39
|
+
# @!visibility private
|
40
|
+
def ==(other)
|
41
|
+
other.is_a?(RuntimeSettings::Endpoints) && to_h == other.to_h
|
42
|
+
end
|
43
|
+
|
44
|
+
# @!visibility private
|
45
|
+
protected def to_h
|
46
|
+
@endpoints
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|