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,207 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
# Attack objects gather information about a type of detected attack.
|
5
|
+
# They can be used in a few ways, like for reporting an attack event
|
6
|
+
# to the Aikido server, or can be raised as errors to block requests
|
7
|
+
# if blocking_mode is on.
|
8
|
+
class Attack
|
9
|
+
attr_reader :context
|
10
|
+
attr_reader :operation
|
11
|
+
attr_accessor :sink
|
12
|
+
|
13
|
+
def initialize(context:, sink:, operation:)
|
14
|
+
@context = context
|
15
|
+
@operation = operation
|
16
|
+
@sink = sink
|
17
|
+
@blocked = false
|
18
|
+
end
|
19
|
+
|
20
|
+
def will_be_blocked!
|
21
|
+
@blocked = true
|
22
|
+
end
|
23
|
+
|
24
|
+
def blocked?
|
25
|
+
@blocked
|
26
|
+
end
|
27
|
+
|
28
|
+
def humanized_name
|
29
|
+
raise NotImplementedError, "implement in subclasses"
|
30
|
+
end
|
31
|
+
|
32
|
+
def kind
|
33
|
+
raise NotImplementedError, "implement in subclasses"
|
34
|
+
end
|
35
|
+
|
36
|
+
def input
|
37
|
+
raise NotImplementedError, "implement in subclasses"
|
38
|
+
end
|
39
|
+
|
40
|
+
def metadata
|
41
|
+
raise NotImplementedError, "implement in subclasses"
|
42
|
+
end
|
43
|
+
|
44
|
+
def as_json
|
45
|
+
{
|
46
|
+
kind: kind,
|
47
|
+
blocked: blocked?,
|
48
|
+
metadata: metadata,
|
49
|
+
operation: @operation
|
50
|
+
}.merge(input.as_json)
|
51
|
+
end
|
52
|
+
|
53
|
+
def exception(*)
|
54
|
+
raise NotImplementedError, "implement in subclasses"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
module Attacks
|
59
|
+
class PathTraversalAttack < Attack
|
60
|
+
attr_reader :input
|
61
|
+
attr_reader :filepath
|
62
|
+
|
63
|
+
def initialize(input:, filepath:, **opts)
|
64
|
+
super(**opts)
|
65
|
+
@input = input
|
66
|
+
@filepath = filepath
|
67
|
+
end
|
68
|
+
|
69
|
+
def metadata
|
70
|
+
{filename: filepath}
|
71
|
+
end
|
72
|
+
|
73
|
+
def humanized_name
|
74
|
+
"path traversal attack"
|
75
|
+
end
|
76
|
+
|
77
|
+
def kind
|
78
|
+
"path_traversal"
|
79
|
+
end
|
80
|
+
|
81
|
+
def exception(*)
|
82
|
+
PathTraversalError.new(self)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
class ShellInjectionAttack < Attack
|
87
|
+
attr_reader :input
|
88
|
+
attr_reader :command
|
89
|
+
|
90
|
+
def initialize(input:, command:, **opts)
|
91
|
+
super(**opts)
|
92
|
+
@input = input
|
93
|
+
@command = command
|
94
|
+
end
|
95
|
+
|
96
|
+
def humanized_name
|
97
|
+
"shell injection"
|
98
|
+
end
|
99
|
+
|
100
|
+
def kind
|
101
|
+
"shell_injection"
|
102
|
+
end
|
103
|
+
|
104
|
+
def metadata
|
105
|
+
{
|
106
|
+
command: @command
|
107
|
+
}
|
108
|
+
end
|
109
|
+
|
110
|
+
def exception(*)
|
111
|
+
ShellInjectionError.new(self)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
class SQLInjectionAttack < Attack
|
116
|
+
attr_reader :query
|
117
|
+
attr_reader :input
|
118
|
+
attr_reader :dialect
|
119
|
+
|
120
|
+
def initialize(query:, input:, dialect:, **opts)
|
121
|
+
super(**opts)
|
122
|
+
@query = query
|
123
|
+
@input = input
|
124
|
+
@dialect = dialect
|
125
|
+
end
|
126
|
+
|
127
|
+
def humanized_name
|
128
|
+
"SQL injection"
|
129
|
+
end
|
130
|
+
|
131
|
+
def kind
|
132
|
+
"sql_injection"
|
133
|
+
end
|
134
|
+
|
135
|
+
def metadata
|
136
|
+
{sql: @query}
|
137
|
+
end
|
138
|
+
|
139
|
+
def exception(*)
|
140
|
+
SQLInjectionError.new(self)
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
class SSRFAttack < Attack
|
145
|
+
attr_reader :input
|
146
|
+
attr_reader :request
|
147
|
+
|
148
|
+
def initialize(request:, input:, **opts)
|
149
|
+
super(**opts)
|
150
|
+
@input = input
|
151
|
+
@request = request
|
152
|
+
end
|
153
|
+
|
154
|
+
def humanized_name
|
155
|
+
"server-side request forgery"
|
156
|
+
end
|
157
|
+
|
158
|
+
def kind
|
159
|
+
"ssrf"
|
160
|
+
end
|
161
|
+
|
162
|
+
def exception(*)
|
163
|
+
SSRFDetectedError.new(self)
|
164
|
+
end
|
165
|
+
|
166
|
+
def metadata
|
167
|
+
{
|
168
|
+
host: @request.uri.hostname,
|
169
|
+
port: @request.uri.port
|
170
|
+
}
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
# Special case of an SSRF attack where we don't have a context—we're just
|
175
|
+
# detecting a request to a particularly sensitive address.
|
176
|
+
class StoredSSRFAttack < Attack
|
177
|
+
attr_reader :hostname
|
178
|
+
attr_reader :address
|
179
|
+
|
180
|
+
def initialize(hostname:, address:, **opts)
|
181
|
+
super(**opts)
|
182
|
+
@hostname = hostname
|
183
|
+
@address = address
|
184
|
+
end
|
185
|
+
|
186
|
+
def humanized_name
|
187
|
+
"server-side request forgery"
|
188
|
+
end
|
189
|
+
|
190
|
+
def exception(*)
|
191
|
+
SSRFDetectedError.new(self)
|
192
|
+
end
|
193
|
+
|
194
|
+
def kind
|
195
|
+
"ssrf"
|
196
|
+
end
|
197
|
+
|
198
|
+
def input
|
199
|
+
Aikido::Zen::Payload::UNKNOWN_PAYLOAD
|
200
|
+
end
|
201
|
+
|
202
|
+
def metadata
|
203
|
+
{}
|
204
|
+
end
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
module Aikido::Zen
|
2
|
+
# Generic background worker class backed by queue. Meant to be used by any
|
3
|
+
# background process that needs to do heavy tasks.
|
4
|
+
class BackgroundWorker
|
5
|
+
# @param block [block] A block that receives 1 message directly from the queue
|
6
|
+
def initialize(&block)
|
7
|
+
@queue = Queue.new
|
8
|
+
@block = block
|
9
|
+
end
|
10
|
+
|
11
|
+
# starts the background thread, blocking the thread until a new messages arrives
|
12
|
+
# or the queue is stopped.
|
13
|
+
def start
|
14
|
+
@thread = Thread.new do
|
15
|
+
while running? || actions?
|
16
|
+
action = wait_for_action
|
17
|
+
@block.call(action) unless action.nil?
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def restart
|
23
|
+
stop
|
24
|
+
@queue = Queue.new # re-open the queue
|
25
|
+
start
|
26
|
+
end
|
27
|
+
|
28
|
+
# Drain the queue to do not lose any messages
|
29
|
+
def stop
|
30
|
+
@queue.close # stop accepting messages
|
31
|
+
@thread.join # wait for the queue to be drained
|
32
|
+
end
|
33
|
+
|
34
|
+
def enqueue(scan)
|
35
|
+
@queue.push(scan)
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def actions?
|
41
|
+
!@queue.empty?
|
42
|
+
end
|
43
|
+
|
44
|
+
def running?
|
45
|
+
!@queue.closed?
|
46
|
+
end
|
47
|
+
|
48
|
+
def wait_for_action
|
49
|
+
@queue.pop(false)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "forwardable"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
# @api private
|
7
|
+
#
|
8
|
+
# Provides a FIFO set with a maximum size. Adding an element after the
|
9
|
+
# capacity has been reached kicks the oldest element in the set out,
|
10
|
+
# while maintaining the uniqueness property of a set (relying on #eql?
|
11
|
+
# and #hash).
|
12
|
+
class CappedSet
|
13
|
+
include Enumerable
|
14
|
+
extend Forwardable
|
15
|
+
|
16
|
+
def_delegators :@data, :size, :empty?
|
17
|
+
|
18
|
+
# @return [Integer]
|
19
|
+
attr_reader :capacity
|
20
|
+
|
21
|
+
def initialize(capacity)
|
22
|
+
@data = CappedMap.new(capacity)
|
23
|
+
end
|
24
|
+
|
25
|
+
def <<(element)
|
26
|
+
@data[element] = nil
|
27
|
+
self
|
28
|
+
end
|
29
|
+
alias_method :add, :<<
|
30
|
+
alias_method :push, :<<
|
31
|
+
|
32
|
+
def each(&b)
|
33
|
+
@data.each_key(&b)
|
34
|
+
end
|
35
|
+
|
36
|
+
def as_json
|
37
|
+
map(&:as_json)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# @api private
|
42
|
+
#
|
43
|
+
# Provides a FIFO hash-like structure with a maximum size. Adding a new key
|
44
|
+
# after the capacity has been reached kicks the first element pair added out.
|
45
|
+
class CappedMap
|
46
|
+
include Enumerable
|
47
|
+
extend Forwardable
|
48
|
+
|
49
|
+
def_delegators :@data,
|
50
|
+
:[], :fetch, :delete, :key?,
|
51
|
+
:each, :each_key, :each_value,
|
52
|
+
:size, :empty?, :to_hash
|
53
|
+
|
54
|
+
# @return [Integer]
|
55
|
+
attr_reader :capacity
|
56
|
+
|
57
|
+
def initialize(capacity)
|
58
|
+
raise ArgumentError, "cannot set capacity lower than 1: #{capacity}" if capacity < 1
|
59
|
+
@capacity = capacity
|
60
|
+
@data = {}
|
61
|
+
end
|
62
|
+
|
63
|
+
def []=(key, value)
|
64
|
+
@data[key] = value
|
65
|
+
@data.delete(@data.each_key.first) if @data.size > @capacity
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../capped_collections"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
# @api private
|
7
|
+
#
|
8
|
+
# Keeps track of the hostnames to which the app has made outbound HTTP
|
9
|
+
# requests.
|
10
|
+
class Collector::Hosts < Aikido::Zen::CappedSet
|
11
|
+
def initialize(config = Aikido::Zen.config)
|
12
|
+
super(config.max_outbound_connections)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../request/schema/empty_schema"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
# @api private
|
7
|
+
#
|
8
|
+
# Keeps track of the visited routes.
|
9
|
+
class Collector::Routes
|
10
|
+
attr_reader :visits
|
11
|
+
|
12
|
+
def initialize(config = Aikido::Zen.config)
|
13
|
+
@config = config
|
14
|
+
@visits = Hash.new { |h, k| h[k] = Record.new }
|
15
|
+
end
|
16
|
+
|
17
|
+
# @param request [Aikido::Zen::Request].
|
18
|
+
# @return [self]
|
19
|
+
def add(request)
|
20
|
+
@visits[request.route].increment(request) unless request.route.nil?
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
def as_json
|
25
|
+
@visits.map do |route, record|
|
26
|
+
{
|
27
|
+
method: route.verb,
|
28
|
+
path: route.path,
|
29
|
+
hits: record.hits,
|
30
|
+
apispec: record.schema.as_json
|
31
|
+
}.compact
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# @api private
|
36
|
+
def [](route)
|
37
|
+
@visits[route]
|
38
|
+
end
|
39
|
+
|
40
|
+
# @api private
|
41
|
+
def empty?
|
42
|
+
@visits.empty?
|
43
|
+
end
|
44
|
+
|
45
|
+
# @api private
|
46
|
+
Record = Struct.new(:hits, :schema, :samples) do
|
47
|
+
def initialize(config = Aikido::Zen.config)
|
48
|
+
super(0, Aikido::Zen::Request::Schema::EMPTY_SCHEMA, 0)
|
49
|
+
@config = config
|
50
|
+
end
|
51
|
+
|
52
|
+
def increment(request)
|
53
|
+
self.hits += 1
|
54
|
+
|
55
|
+
if sample_schema?
|
56
|
+
self.samples += 1
|
57
|
+
self.schema |= request.schema
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
private def sample_schema?
|
62
|
+
samples < @config.api_schema_max_samples
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../capped_collections"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
# @api private
|
7
|
+
#
|
8
|
+
# Tracks data specific to a single Sink.
|
9
|
+
class Collector::SinkStats
|
10
|
+
# @return [Integer] number of total calls to Sink#scan.
|
11
|
+
attr_accessor :scans
|
12
|
+
|
13
|
+
# @return [Integer] number of scans where our scanners raised an
|
14
|
+
# error that was handled.
|
15
|
+
attr_accessor :errors
|
16
|
+
|
17
|
+
# @return [Integer] number of scans where an attack was detected.
|
18
|
+
attr_accessor :attacks
|
19
|
+
|
20
|
+
# @return [Integer] number of scans where an attack was detected
|
21
|
+
# _and_ blocked by the Zen.
|
22
|
+
attr_accessor :blocked_attacks
|
23
|
+
|
24
|
+
# @return [Set<Float>] keeps the duration of individual scans. If
|
25
|
+
# this grows to match Config#max_performance_samples, the set is
|
26
|
+
# cleared and the data is aggregated into #compressed_timings.
|
27
|
+
attr_accessor :timings
|
28
|
+
|
29
|
+
# @return [Array<CompressedTiming>] list of aggregated stats.
|
30
|
+
attr_accessor :compressed_timings
|
31
|
+
|
32
|
+
def initialize(name, config)
|
33
|
+
@name = name
|
34
|
+
@config = config
|
35
|
+
|
36
|
+
@scans = 0
|
37
|
+
@errors = 0
|
38
|
+
|
39
|
+
@attacks = 0
|
40
|
+
@blocked_attacks = 0
|
41
|
+
|
42
|
+
@timings = Set.new
|
43
|
+
@compressed_timings = CappedSet.new(@config.max_compressed_stats)
|
44
|
+
end
|
45
|
+
|
46
|
+
def add_timing(duration)
|
47
|
+
compress_timings if @timings.size >= @config.max_performance_samples
|
48
|
+
@timings << duration
|
49
|
+
end
|
50
|
+
|
51
|
+
def compress_timings(at: Time.now.utc)
|
52
|
+
return if @timings.empty?
|
53
|
+
|
54
|
+
list = @timings.sort
|
55
|
+
@timings.clear
|
56
|
+
|
57
|
+
mean = list.sum / list.size
|
58
|
+
percentiles = percentiles(list, 50, 75, 90, 95, 99)
|
59
|
+
|
60
|
+
@compressed_timings << CompressedTiming.new(mean, percentiles, at)
|
61
|
+
end
|
62
|
+
|
63
|
+
def as_json
|
64
|
+
{
|
65
|
+
total: @scans,
|
66
|
+
interceptorThrewError: @errors,
|
67
|
+
withoutContext: 0,
|
68
|
+
attacksDetected: {
|
69
|
+
total: @attacks,
|
70
|
+
blocked: @blocked_attacks
|
71
|
+
},
|
72
|
+
compressedTimings: @compressed_timings.as_json
|
73
|
+
}
|
74
|
+
end
|
75
|
+
|
76
|
+
private def percentiles(sorted, *scores)
|
77
|
+
return {} if sorted.empty? || scores.empty?
|
78
|
+
|
79
|
+
scores.map { |p|
|
80
|
+
index = (sorted.size * (p / 100.0)).floor
|
81
|
+
[p, sorted.at(index)]
|
82
|
+
}.to_h
|
83
|
+
end
|
84
|
+
|
85
|
+
CompressedTiming = Struct.new(:mean, :percentiles, :compressed_at) do
|
86
|
+
def as_json
|
87
|
+
{
|
88
|
+
averageInMs: mean * 1000,
|
89
|
+
percentiles: percentiles.transform_values { |t| t * 1000 },
|
90
|
+
compressedAt: compressed_at.to_i * 1000
|
91
|
+
}
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../capped_collections"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
# @api private
|
7
|
+
#
|
8
|
+
# Tracks information about how the Aikido Agent is used in the app.
|
9
|
+
class Collector::Stats
|
10
|
+
# @!visibility private
|
11
|
+
attr_reader :started_at, :ended_at, :requests, :aborted_requests, :sinks
|
12
|
+
|
13
|
+
# @!visibility private
|
14
|
+
attr_writer :ended_at
|
15
|
+
|
16
|
+
def initialize(config = Aikido::Zen.config)
|
17
|
+
super()
|
18
|
+
@config = config
|
19
|
+
@sinks = Hash.new { |h, k| h[k] = Collector::SinkStats.new(k, @config) }
|
20
|
+
@started_at = @ended_at = nil
|
21
|
+
@requests = 0
|
22
|
+
@aborted_requests = 0
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [Boolean]
|
26
|
+
def empty?
|
27
|
+
@requests.zero? && @sinks.empty?
|
28
|
+
end
|
29
|
+
|
30
|
+
# @return [Boolean]
|
31
|
+
def any?
|
32
|
+
!empty?
|
33
|
+
end
|
34
|
+
|
35
|
+
# Track the timestamp we start tracking this series of stats.
|
36
|
+
#
|
37
|
+
# @param at [Time]
|
38
|
+
# @return [self]
|
39
|
+
def start(at = Time.now.utc)
|
40
|
+
@started_at = at
|
41
|
+
self
|
42
|
+
end
|
43
|
+
|
44
|
+
# Sets the end time for these stats block, freezes it to avoid any more
|
45
|
+
# writing to them, and compresses the timing stats in anticipation of
|
46
|
+
# sending these to the Aikido servers.
|
47
|
+
#
|
48
|
+
# @param at [Time] the time at which we're resetting, which is set as the
|
49
|
+
# ending time for the returned copy.
|
50
|
+
# @return [self]
|
51
|
+
def flush(at: Time.now.utc)
|
52
|
+
# Make sure the timing stats are compressed before copying, since we
|
53
|
+
# need these compressed when we serialize this for the API.
|
54
|
+
@sinks.each_value { |sink| sink.compress_timings(at: at) }
|
55
|
+
@ended_at = at
|
56
|
+
freeze
|
57
|
+
end
|
58
|
+
|
59
|
+
# @return [self]
|
60
|
+
def add_request
|
61
|
+
@requests += 1
|
62
|
+
self
|
63
|
+
end
|
64
|
+
|
65
|
+
# @param scan [Aikido::Zen::Scan]
|
66
|
+
# @return [self]
|
67
|
+
def add_scan(scan)
|
68
|
+
stats = @sinks[scan.sink.name]
|
69
|
+
stats.scans += 1
|
70
|
+
stats.errors += 1 if scan.errors?
|
71
|
+
stats.add_timing(scan.duration)
|
72
|
+
self
|
73
|
+
end
|
74
|
+
|
75
|
+
# @param attack [Aikido::Zen::Attack]
|
76
|
+
# @param being_blocked [Boolean] whether the Agent blocked the
|
77
|
+
# request where this Attack happened or not.
|
78
|
+
# @return [self]
|
79
|
+
def add_attack(attack, being_blocked:)
|
80
|
+
stats = @sinks[attack.sink.name]
|
81
|
+
stats.attacks += 1
|
82
|
+
stats.blocked_attacks += 1 if being_blocked
|
83
|
+
self
|
84
|
+
end
|
85
|
+
|
86
|
+
def as_json
|
87
|
+
total_attacks, total_blocked = aggregate_attacks_from_sinks
|
88
|
+
{
|
89
|
+
startedAt: @started_at.to_i * 1000,
|
90
|
+
endedAt: (@ended_at.to_i * 1000 if @ended_at),
|
91
|
+
sinks: @sinks.transform_values(&:as_json),
|
92
|
+
requests: {
|
93
|
+
total: @requests,
|
94
|
+
aborted: @aborted_requests,
|
95
|
+
attacksDetected: {
|
96
|
+
total: total_attacks,
|
97
|
+
blocked: total_blocked
|
98
|
+
}
|
99
|
+
}
|
100
|
+
}
|
101
|
+
end
|
102
|
+
|
103
|
+
private def aggregate_attacks_from_sinks
|
104
|
+
@sinks.each_value.reduce([0, 0]) { |(attacks, blocked), stats|
|
105
|
+
[attacks + stats.attacks, blocked + stats.blocked_attacks]
|
106
|
+
}
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
require_relative "sink_stats"
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../capped_collections"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
# @api private
|
7
|
+
#
|
8
|
+
# Keeps track of the users that were seen by the app.
|
9
|
+
class Collector::Users < Aikido::Zen::CappedMap
|
10
|
+
def initialize(config = Aikido::Zen.config)
|
11
|
+
super(config.max_users_tracked)
|
12
|
+
end
|
13
|
+
|
14
|
+
def add(actor)
|
15
|
+
if key?(actor.id)
|
16
|
+
self[actor.id].update
|
17
|
+
else
|
18
|
+
self[actor.id] = actor
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def each(&b)
|
23
|
+
each_value(&b)
|
24
|
+
end
|
25
|
+
|
26
|
+
def as_json
|
27
|
+
map(&:as_json)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|