aikido-zen 1.0.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 +32 -0
- data/.standard.yml +3 -0
- data/LICENSE +674 -0
- data/README.md +148 -0
- data/Rakefile +67 -0
- data/benchmarks/README.md +22 -0
- data/benchmarks/rails7.1_benchmark.js +1 -0
- data/benchmarks/rails7.1_sql_injection.js +102 -0
- data/docs/banner.svg +202 -0
- data/docs/config.md +133 -0
- data/docs/proxy.md +10 -0
- data/docs/rails.md +112 -0
- data/docs/troubleshooting.md +62 -0
- data/lib/aikido/zen/actor.rb +146 -0
- data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
- data/lib/aikido/zen/agent.rb +181 -0
- data/lib/aikido/zen/api_client.rb +145 -0
- data/lib/aikido/zen/attack.rb +217 -0
- data/lib/aikido/zen/attack_wave/helpers.rb +457 -0
- data/lib/aikido/zen/attack_wave.rb +88 -0
- data/lib/aikido/zen/background_worker.rb +52 -0
- data/lib/aikido/zen/cache.rb +91 -0
- data/lib/aikido/zen/capped_collections.rb +86 -0
- data/lib/aikido/zen/collector/event.rb +238 -0
- data/lib/aikido/zen/collector/hosts.rb +30 -0
- data/lib/aikido/zen/collector/routes.rb +71 -0
- data/lib/aikido/zen/collector/sink_stats.rb +95 -0
- data/lib/aikido/zen/collector/stats.rb +122 -0
- data/lib/aikido/zen/collector/users.rb +32 -0
- data/lib/aikido/zen/collector.rb +223 -0
- data/lib/aikido/zen/config.rb +312 -0
- data/lib/aikido/zen/context/rack_request.rb +27 -0
- data/lib/aikido/zen/context/rails_request.rb +47 -0
- data/lib/aikido/zen/context.rb +145 -0
- data/lib/aikido/zen/detached_agent/agent.rb +79 -0
- data/lib/aikido/zen/detached_agent/front_object.rb +41 -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 +116 -0
- data/lib/aikido/zen/helpers.rb +24 -0
- data/lib/aikido/zen/internals.rb +123 -0
- data/lib/aikido/zen/libzen-v0.1.48-aarch64-linux.so +0 -0
- data/lib/aikido/zen/middleware/allowed_address_checker.rb +26 -0
- data/lib/aikido/zen/middleware/attack_wave_protector.rb +46 -0
- data/lib/aikido/zen/middleware/context_setter.rb +26 -0
- data/lib/aikido/zen/middleware/fork_detector.rb +23 -0
- data/lib/aikido/zen/middleware/middleware.rb +11 -0
- data/lib/aikido/zen/middleware/rack_throttler.rb +50 -0
- data/lib/aikido/zen/middleware/request_tracker.rb +197 -0
- data/lib/aikido/zen/outbound_connection.rb +62 -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 +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 +50 -0
- data/lib/aikido/zen/request/heuristic_router.rb +115 -0
- data/lib/aikido/zen/request/rails_router.rb +92 -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 +88 -0
- data/lib/aikido/zen/route.rb +96 -0
- data/lib/aikido/zen/runtime_settings/endpoints.rb +78 -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 +66 -0
- data/lib/aikido/zen/scan.rb +75 -0
- data/lib/aikido/zen/scanners/path_traversal/helpers.rb +68 -0
- data/lib/aikido/zen/scanners/path_traversal_scanner.rb +64 -0
- data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
- data/lib/aikido/zen/scanners/shell_injection_scanner.rb +65 -0
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +94 -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 +266 -0
- data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +55 -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 +85 -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 +153 -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 +85 -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 +80 -0
- data/lib/aikido/zen/version.rb +10 -0
- data/lib/aikido/zen/worker.rb +87 -0
- data/lib/aikido/zen.rb +303 -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 +214 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
require_relative "../route"
|
|
5
|
+
require_relative "../request"
|
|
6
|
+
|
|
7
|
+
module Aikido::Zen
|
|
8
|
+
# Simple router implementation that just identifies the currently requested
|
|
9
|
+
# URL as a route, attempting to heuristically substitute any path segments
|
|
10
|
+
# that may look like a parameterized value by something descriptive.
|
|
11
|
+
#
|
|
12
|
+
# For example, "/categories/123/events/2024-10-01" would be matched as
|
|
13
|
+
# "/categories/:number/events/:date"
|
|
14
|
+
class Request::HeuristicRouter
|
|
15
|
+
# @param request [Aikido::Zen::Request]
|
|
16
|
+
# @return [Aikido::Zen::Route, nil]
|
|
17
|
+
def recognize(request)
|
|
18
|
+
path = parameterize(request.path)
|
|
19
|
+
Route.new(verb: request.request_method, path: path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private def parameterize(path)
|
|
23
|
+
return if path.nil?
|
|
24
|
+
|
|
25
|
+
path = path.split("/").map { |part| parameterize_segment(part) }.join("/")
|
|
26
|
+
path.prepend("/") unless path.start_with?("/")
|
|
27
|
+
path.chomp!("/") if path.size > 1
|
|
28
|
+
path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private def parameterize_segment(segment)
|
|
32
|
+
case segment
|
|
33
|
+
when ULID
|
|
34
|
+
":ulid"
|
|
35
|
+
when OBJECT_ID
|
|
36
|
+
":objectId"
|
|
37
|
+
when NUMBER
|
|
38
|
+
":number"
|
|
39
|
+
when UUID
|
|
40
|
+
":uuid"
|
|
41
|
+
when DATE
|
|
42
|
+
":date"
|
|
43
|
+
when EMAIL
|
|
44
|
+
":email"
|
|
45
|
+
when IP
|
|
46
|
+
":ip"
|
|
47
|
+
when HASH
|
|
48
|
+
":hash"
|
|
49
|
+
when SecretMatcher
|
|
50
|
+
":secret"
|
|
51
|
+
else
|
|
52
|
+
segment
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
NUMBER = /\A\d+\z/
|
|
57
|
+
HEX = /\A[a-f0-9]+\z/i
|
|
58
|
+
DATE = /\A\d{4}-\d{2}-\d{2}|\d{2}-\d{2}-\d{4}\z/
|
|
59
|
+
UUID = /\A
|
|
60
|
+
(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}
|
|
61
|
+
| 00000000-0000-0000-0000-000000000000
|
|
62
|
+
| ffffffff-ffff-ffff-ffff-ffffffffffff
|
|
63
|
+
)\z/ix
|
|
64
|
+
ULID = /\A[0-9A-HJKMNP-TV-Z]{26}\z/i
|
|
65
|
+
OBJECT_ID = /\A[0-9a-f]{24}\z/i
|
|
66
|
+
EMAIL = /\A
|
|
67
|
+
[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+
|
|
68
|
+
@
|
|
69
|
+
[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?
|
|
70
|
+
(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*
|
|
71
|
+
\z/x
|
|
72
|
+
IP = ->(segment) {
|
|
73
|
+
IPAddr::RE_IPV4ADDRLIKE.match?(segment) ||
|
|
74
|
+
IPAddr::RE_IPV6ADDRLIKE_COMPRESSED.match?(segment) ||
|
|
75
|
+
IPAddr::RE_IPV6ADDRLIKE_FULL.match?(segment)
|
|
76
|
+
}
|
|
77
|
+
HASH = ->(segment) { [32, 40, 64, 128].include?(segment.size) && HEX === segment }
|
|
78
|
+
|
|
79
|
+
class SecretMatcher
|
|
80
|
+
# Decides if a given string looks random enough to be a "secret".
|
|
81
|
+
#
|
|
82
|
+
# @param candidate [String]
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def self.===(candidate)
|
|
85
|
+
new(candidate).matches?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private def initialize(string)
|
|
89
|
+
@string = string
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def matches?
|
|
93
|
+
return false if @string.size <= MIN_LENGTH
|
|
94
|
+
return false if SEPARATORS === @string
|
|
95
|
+
return false unless DIGIT === @string
|
|
96
|
+
return false if [LOWER, UPPER, SPECIAL].none? { |pattern| pattern === @string }
|
|
97
|
+
|
|
98
|
+
ratios = @string.chars.each_cons(MIN_LENGTH).map do |window|
|
|
99
|
+
window.to_set.size / MIN_LENGTH.to_f
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
ratios.sum / ratios.size > SECRET_THRESHOLD
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
MIN_LENGTH = 10
|
|
106
|
+
SECRET_THRESHOLD = 0.75
|
|
107
|
+
|
|
108
|
+
LOWER = /[[:lower:]]/
|
|
109
|
+
UPPER = /[[:upper:]]/
|
|
110
|
+
DIGIT = /[[:digit:]]/
|
|
111
|
+
SPECIAL = /[!#\$%^&*|;:<>]/
|
|
112
|
+
SEPARATORS = /[[:space:]]|-/
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
# ActionDispatch::Journey::Router#recognize modifies the Rack environment.
|
|
33
|
+
# This is correct for Rails routing, but it is not expected to be used in
|
|
34
|
+
# Rack middleware, and using it here can break Rails routing.
|
|
35
|
+
#
|
|
36
|
+
# To avoid this, the Rack environment is duplicated when building request.
|
|
37
|
+
route_set.router.recognize(request) do |route, _|
|
|
38
|
+
app = route.app
|
|
39
|
+
next unless app.matches?(request)
|
|
40
|
+
|
|
41
|
+
if app.dispatcher?
|
|
42
|
+
return build_route(route, request, prefix: prefix)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if app.engine?
|
|
46
|
+
# If the SCRIPT_NAME has any path parameters, we want those to be
|
|
47
|
+
# captured by the router. (eg `mount API => "/api/:version/`)
|
|
48
|
+
prefix = ActionDispatch::Routing::RouteWrapper.new(route).path
|
|
49
|
+
return recognize_in_route_set(request, app.rack_app.routes, prefix: prefix)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if app.rack_app.respond_to?(:redirect?) && app.rack_app.redirect?
|
|
53
|
+
return build_route(route, request, prefix: prefix)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# At this point we're matching plain Rack apps, where Rails does not
|
|
57
|
+
# remove the SCRIPT_NAME from PATH_INFO, so we should avoid adding
|
|
58
|
+
# SCRIPT_NAME twice.
|
|
59
|
+
return build_route(route, request, prefix: nil)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def build_route(route, request, prefix: request.script_name)
|
|
68
|
+
route_wrapper = ActionDispatch::Routing::RouteWrapper.new(route)
|
|
69
|
+
|
|
70
|
+
path = if prefix.present?
|
|
71
|
+
prefix_route_path(prefix.to_s, route_wrapper.path)
|
|
72
|
+
else
|
|
73
|
+
route_wrapper.path
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
Aikido::Zen::Route.new(verb: request.request_method, path: path)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def prefix_route_path(string1, string2)
|
|
80
|
+
# The strings appear to start with "/", allowing them to be concatenated
|
|
81
|
+
# directly after removing trailing "/". However, as it is not currently
|
|
82
|
+
# known whether this is guaranteed, we insert a separator when necessary.
|
|
83
|
+
|
|
84
|
+
separator = string2.start_with?("/") ? "" : "/"
|
|
85
|
+
|
|
86
|
+
string1 = string1.chomp("/")
|
|
87
|
+
string2 = string2.chomp("/")
|
|
88
|
+
|
|
89
|
+
"#{string1}#{separator}#{string2}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
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,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"
|