aikido-zen 1.0.1.beta.2-arm64-linux-musl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/.aikido +6 -0
  3. data/.ruby-version +1 -0
  4. data/.simplecov +26 -0
  5. data/.standard.yml +3 -0
  6. data/LICENSE +674 -0
  7. data/README.md +146 -0
  8. data/Rakefile +67 -0
  9. data/benchmarks/README.md +23 -0
  10. data/benchmarks/rails7.1_sql_injection.js +70 -0
  11. data/docs/banner.svg +202 -0
  12. data/docs/config.md +125 -0
  13. data/docs/rails.md +70 -0
  14. data/lib/aikido/zen/actor.rb +116 -0
  15. data/lib/aikido/zen/agent/heartbeats_manager.rb +66 -0
  16. data/lib/aikido/zen/agent.rb +179 -0
  17. data/lib/aikido/zen/api_client.rb +142 -0
  18. data/lib/aikido/zen/attack.rb +207 -0
  19. data/lib/aikido/zen/background_worker.rb +52 -0
  20. data/lib/aikido/zen/capped_collections.rb +68 -0
  21. data/lib/aikido/zen/collector/hosts.rb +15 -0
  22. data/lib/aikido/zen/collector/routes.rb +66 -0
  23. data/lib/aikido/zen/collector/sink_stats.rb +95 -0
  24. data/lib/aikido/zen/collector/stats.rb +111 -0
  25. data/lib/aikido/zen/collector/users.rb +30 -0
  26. data/lib/aikido/zen/collector.rb +144 -0
  27. data/lib/aikido/zen/config.rb +279 -0
  28. data/lib/aikido/zen/context/rack_request.rb +24 -0
  29. data/lib/aikido/zen/context/rails_request.rb +42 -0
  30. data/lib/aikido/zen/context.rb +112 -0
  31. data/lib/aikido/zen/detached_agent/agent.rb +78 -0
  32. data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
  33. data/lib/aikido/zen/detached_agent/server.rb +41 -0
  34. data/lib/aikido/zen/detached_agent.rb +2 -0
  35. data/lib/aikido/zen/errors.rb +107 -0
  36. data/lib/aikido/zen/event.rb +71 -0
  37. data/lib/aikido/zen/internals.rb +102 -0
  38. data/lib/aikido/zen/libzen-v0.1.39-arm64-linux-musl.so +0 -0
  39. data/lib/aikido/zen/middleware/check_allowed_addresses.rb +26 -0
  40. data/lib/aikido/zen/middleware/middleware.rb +11 -0
  41. data/lib/aikido/zen/middleware/rack_throttler.rb +48 -0
  42. data/lib/aikido/zen/middleware/request_tracker.rb +192 -0
  43. data/lib/aikido/zen/middleware/set_context.rb +26 -0
  44. data/lib/aikido/zen/outbound_connection.rb +45 -0
  45. data/lib/aikido/zen/outbound_connection_monitor.rb +23 -0
  46. data/lib/aikido/zen/package.rb +22 -0
  47. data/lib/aikido/zen/payload.rb +50 -0
  48. data/lib/aikido/zen/rails_engine.rb +70 -0
  49. data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
  50. data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
  51. data/lib/aikido/zen/rate_limiter/result.rb +31 -0
  52. data/lib/aikido/zen/rate_limiter.rb +50 -0
  53. data/lib/aikido/zen/request/heuristic_router.rb +115 -0
  54. data/lib/aikido/zen/request/rails_router.rb +72 -0
  55. data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
  56. data/lib/aikido/zen/request/schema/auth_schemas.rb +54 -0
  57. data/lib/aikido/zen/request/schema/builder.rb +121 -0
  58. data/lib/aikido/zen/request/schema/definition.rb +107 -0
  59. data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
  60. data/lib/aikido/zen/request/schema.rb +87 -0
  61. data/lib/aikido/zen/request.rb +103 -0
  62. data/lib/aikido/zen/route.rb +39 -0
  63. data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
  64. data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
  65. data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
  66. data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
  67. data/lib/aikido/zen/runtime_settings.rb +65 -0
  68. data/lib/aikido/zen/scan.rb +75 -0
  69. data/lib/aikido/zen/scanners/path_traversal/helpers.rb +65 -0
  70. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +63 -0
  71. data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
  72. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +64 -0
  73. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +93 -0
  74. data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
  75. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +97 -0
  76. data/lib/aikido/zen/scanners/ssrf_scanner.rb +265 -0
  77. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +49 -0
  78. data/lib/aikido/zen/scanners.rb +7 -0
  79. data/lib/aikido/zen/sink.rb +118 -0
  80. data/lib/aikido/zen/sinks/action_controller.rb +83 -0
  81. data/lib/aikido/zen/sinks/async_http.rb +82 -0
  82. data/lib/aikido/zen/sinks/curb.rb +115 -0
  83. data/lib/aikido/zen/sinks/em_http.rb +85 -0
  84. data/lib/aikido/zen/sinks/excon.rb +121 -0
  85. data/lib/aikido/zen/sinks/file.rb +116 -0
  86. data/lib/aikido/zen/sinks/http.rb +95 -0
  87. data/lib/aikido/zen/sinks/httpclient.rb +97 -0
  88. data/lib/aikido/zen/sinks/httpx.rb +80 -0
  89. data/lib/aikido/zen/sinks/kernel.rb +34 -0
  90. data/lib/aikido/zen/sinks/mysql2.rb +33 -0
  91. data/lib/aikido/zen/sinks/net_http.rb +103 -0
  92. data/lib/aikido/zen/sinks/patron.rb +105 -0
  93. data/lib/aikido/zen/sinks/pg.rb +74 -0
  94. data/lib/aikido/zen/sinks/resolv.rb +62 -0
  95. data/lib/aikido/zen/sinks/socket.rb +80 -0
  96. data/lib/aikido/zen/sinks/sqlite3.rb +49 -0
  97. data/lib/aikido/zen/sinks/trilogy.rb +33 -0
  98. data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
  99. data/lib/aikido/zen/sinks.rb +39 -0
  100. data/lib/aikido/zen/sinks_dsl.rb +226 -0
  101. data/lib/aikido/zen/synchronizable.rb +24 -0
  102. data/lib/aikido/zen/system_info.rb +84 -0
  103. data/lib/aikido/zen/version.rb +10 -0
  104. data/lib/aikido/zen/worker.rb +87 -0
  105. data/lib/aikido/zen.rb +206 -0
  106. data/lib/aikido-zen.rb +3 -0
  107. data/placeholder/.gitignore +4 -0
  108. data/placeholder/README.md +11 -0
  109. data/placeholder/Rakefile +75 -0
  110. data/placeholder/lib/placeholder.rb.template +3 -0
  111. data/placeholder/placeholder.gemspec.template +20 -0
  112. data/tasklib/bench.rake +94 -0
  113. data/tasklib/libzen.rake +132 -0
  114. data/tasklib/wrk.rb +88 -0
  115. metadata +204 -0
@@ -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,103 @@
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, framework:, router:)
21
+ super(delegate)
22
+ @framework = framework
23
+ @router = router
24
+ @body_read = false
25
+ end
26
+
27
+ def __setobj__(delegate) # :nodoc:
28
+ super
29
+ @body_read = false
30
+ @route = @normalized_header = @truncated_body = nil
31
+ end
32
+
33
+ # @return [Aikido::Zen::Route] the framework route being requested.
34
+ def route
35
+ @route ||= @router.recognize(self)
36
+ end
37
+
38
+ # @return [Aikido::Zen::Request::Schema, nil]
39
+ def schema
40
+ @schema ||= Aikido::Zen::Request::Schema.build
41
+ end
42
+
43
+ # Map the CGI-style env Hash into "pretty-looking" headers, preserving the
44
+ # values as-is. For example, HTTP_ACCEPT turns into "Accept", CONTENT_TYPE
45
+ # turns into "Content-Type", and HTTP_X_FORWARDED_FOR turns into
46
+ # "X-Forwarded-For".
47
+ #
48
+ # @return [Hash<String, String>]
49
+ def normalized_headers
50
+ @normalized_headers ||= env.slice(*BLESSED_CGI_HEADERS)
51
+ .merge(env.select { |key, _| key.start_with?("HTTP_") })
52
+ .transform_keys { |header|
53
+ name = header.sub(/^HTTP_/, "").downcase
54
+ name.split("_").map { |part| part[0].upcase + part[1..] }.join("-")
55
+ }
56
+ end
57
+
58
+ # @api private
59
+ #
60
+ # Reads the first 16KiB of the request body, to include in attack reports
61
+ # back to the Aikido server. This method should only be called if an attack
62
+ # is detected during the current request.
63
+ #
64
+ # If the underlying IO object has been partially (or fully) read before,
65
+ # this will attempt to restore the previous cursor position after reading it
66
+ # if possible, or leave if rewund if not.
67
+ #
68
+ # @param max_size [Integer] number of bytes to read at most.
69
+ #
70
+ # @return [String]
71
+ def truncated_body(max_size: 16384)
72
+ return @truncated_body if @body_read
73
+ return nil if body.nil?
74
+
75
+ begin
76
+ initial_pos = body.pos if body.respond_to?(:pos)
77
+ body.rewind
78
+ @truncated_body = body.read(max_size)
79
+ ensure
80
+ @body_read = true
81
+ body.rewind
82
+ body.seek(initial_pos) if initial_pos && body.respond_to?(:seek)
83
+ end
84
+ end
85
+
86
+ def as_json
87
+ {
88
+ method: request_method.downcase,
89
+ url: url,
90
+ ipAddress: ip,
91
+ userAgent: user_agent,
92
+ headers: normalized_headers.reject { |_, val| val.to_s.empty? },
93
+ body: truncated_body,
94
+ source: framework,
95
+ route: route&.path
96
+ }
97
+ end
98
+
99
+ BLESSED_CGI_HEADERS = %w[CONTENT_TYPE CONTENT_LENGTH]
100
+ end
101
+ end
102
+
103
+ 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
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Aikido::Zen
6
+ # Models a list of IP addresses or CIDR blocks, where we can check if a given
7
+ # address is part of any of the members.
8
+ class RuntimeSettings::IPSet
9
+ def self.from_json(ips)
10
+ new(Array(ips).map { |ip| IPAddr.new(ip) })
11
+ end
12
+
13
+ def initialize(ips = Set.new)
14
+ @ips = ips.to_set
15
+ end
16
+
17
+ def empty?
18
+ @ips.empty?
19
+ end
20
+
21
+ def include?(ip)
22
+ @ips.any? { |pattern| pattern === ip }
23
+ end
24
+ alias_method :===, :include?
25
+
26
+ def ==(other)
27
+ other.is_a?(RuntimeSettings::IPSet) && to_set == other.to_set
28
+ end
29
+
30
+ protected
31
+
32
+ def to_set
33
+ @ips
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ip_set"
4
+ require_relative "rate_limit_settings"
5
+
6
+ module Aikido::Zen
7
+ # Models the settings for a given Route as configured in the Aikido UI.
8
+ class RuntimeSettings::ProtectionSettings
9
+ # @return [Aikido::Zen::RuntimeSettings::ProtectionSettings] singleton
10
+ # instance for endpoints with no configured protections on a given route,
11
+ # that can be used as a default value for routes.
12
+ def self.none
13
+ @no_settings ||= new
14
+ end
15
+
16
+ # Initialize settings from an API response.
17
+ #
18
+ # @param data [Hash] the deserialized JSON data.
19
+ # @option data [Boolean] "forceProtectionOff" whether the user has
20
+ # disabled attack protection for this route.
21
+ # @option data [Array<String>] "allowedIPAddresses" the list of IPs that
22
+ # can make requests to this endpoint.
23
+ # @option data [Hash] "rateLimiting" the rate limiting options for this
24
+ # endpoint. See {Aikido::Zen::RuntimeSettings::RateLimitSettings.from_json}.
25
+ #
26
+ # @return [Aikido::Zen::RuntimeSettings::ProtectionSettings]
27
+ # @raise [IPAddr::InvalidAddressError] if any of the IPs in
28
+ # "allowedIPAddresses" is not a valid address or family.
29
+ def self.from_json(data)
30
+ ips = RuntimeSettings::IPSet.from_json(data["allowedIPAddresses"])
31
+ rate_limiting = RuntimeSettings::RateLimitSettings.from_json(data["rateLimiting"])
32
+
33
+ new(
34
+ protected: !data["forceProtectionOff"],
35
+ allowed_ips: ips,
36
+ rate_limiting: rate_limiting
37
+ )
38
+ end
39
+
40
+ # @return [Aikido::Zen::RuntimeSettings::IPSet] list of IP addresses which
41
+ # are allowed to make requests on this route. If empty, all IP addresses
42
+ # are allowed.
43
+ attr_reader :allowed_ips
44
+
45
+ # @return [Aikido::Zen::RuntimeSettings::RateLimitSettings]
46
+ attr_reader :rate_limiting
47
+
48
+ def initialize(
49
+ protected: true,
50
+ allowed_ips: RuntimeSettings::IPSet.new,
51
+ rate_limiting: RuntimeSettings::RateLimitSettings.disabled
52
+ )
53
+ @protected = !!protected
54
+ @rate_limiting = rate_limiting
55
+ @allowed_ips = allowed_ips
56
+ end
57
+
58
+ def protected?
59
+ @protected
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ # Simple data object that holds the configuration for rate limiting a given
5
+ # endpoint.
6
+ class RuntimeSettings::RateLimitSettings
7
+ # Initialize the settings from an API response.
8
+ #
9
+ # @param data [Hash] the deserialized JSON data.
10
+ # @option data [Boolean] "enabled"
11
+ # @option data [Integer] "maxRequests"
12
+ # @option data [Integer] "windowSizeInMS"
13
+ #
14
+ # @return [Aikido::Zen::RateLimitSettings]
15
+ def self.from_json(data)
16
+ new(
17
+ enabled: !!data["enabled"],
18
+ max_requests: Integer(data["maxRequests"]),
19
+ period: Integer(data["windowSizeInMS"]) / 1000
20
+ )
21
+ end
22
+
23
+ # Initializes a disabled object that we can use as a default value for
24
+ # endpoints that have not configured rate limiting.
25
+ #
26
+ # @return [Aikido::Zen::RuntimeSettings::RateLimitSettings]
27
+ def self.disabled
28
+ new(enabled: false)
29
+ end
30
+
31
+ # @return [Integer] the fixed window to bucket requests in, in seconds.
32
+ attr_reader :period
33
+
34
+ # @return [Integer]
35
+ attr_reader :max_requests
36
+
37
+ def initialize(enabled: false, max_requests: 1000, period: 60)
38
+ @enabled = enabled
39
+ @period = period
40
+ @max_requests = max_requests
41
+ end
42
+
43
+ def enabled?
44
+ @enabled
45
+ end
46
+ end
47
+ end