aikido-zen 0.1.0.alpha4-x86_64-darwin
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE +674 -0
- data/README.md +40 -0
- data/Rakefile +63 -0
- data/lib/aikido/zen/actor.rb +116 -0
- data/lib/aikido/zen/agent.rb +187 -0
- data/lib/aikido/zen/api_client.rb +132 -0
- data/lib/aikido/zen/attack.rb +138 -0
- data/lib/aikido/zen/capped_collections.rb +68 -0
- data/lib/aikido/zen/config.rb +229 -0
- data/lib/aikido/zen/context/rack_request.rb +24 -0
- data/lib/aikido/zen/context/rails_request.rb +42 -0
- data/lib/aikido/zen/context.rb +101 -0
- data/lib/aikido/zen/errors.rb +88 -0
- data/lib/aikido/zen/event.rb +66 -0
- data/lib/aikido/zen/internals.rb +64 -0
- data/lib/aikido/zen/libzen-v0.1.26.x86_64.dylib +0 -0
- data/lib/aikido/zen/middleware/check_allowed_addresses.rb +38 -0
- data/lib/aikido/zen/middleware/set_context.rb +26 -0
- data/lib/aikido/zen/middleware/throttler.rb +50 -0
- data/lib/aikido/zen/outbound_connection.rb +45 -0
- data/lib/aikido/zen/outbound_connection_monitor.rb +19 -0
- data/lib/aikido/zen/package.rb +22 -0
- data/lib/aikido/zen/payload.rb +48 -0
- data/lib/aikido/zen/rails_engine.rb +53 -0
- data/lib/aikido/zen/rate_limiter/breaker.rb +61 -0
- data/lib/aikido/zen/rate_limiter/bucket.rb +76 -0
- data/lib/aikido/zen/rate_limiter/result.rb +31 -0
- data/lib/aikido/zen/rate_limiter.rb +55 -0
- data/lib/aikido/zen/request/heuristic_router.rb +109 -0
- data/lib/aikido/zen/request/rails_router.rb +84 -0
- data/lib/aikido/zen/request/schema/auth_discovery.rb +86 -0
- data/lib/aikido/zen/request/schema/auth_schemas.rb +40 -0
- data/lib/aikido/zen/request/schema/builder.rb +125 -0
- data/lib/aikido/zen/request/schema/definition.rb +112 -0
- data/lib/aikido/zen/request/schema/empty_schema.rb +28 -0
- data/lib/aikido/zen/request/schema.rb +72 -0
- data/lib/aikido/zen/request.rb +97 -0
- data/lib/aikido/zen/route.rb +39 -0
- data/lib/aikido/zen/runtime_settings/endpoints.rb +49 -0
- data/lib/aikido/zen/runtime_settings/ip_set.rb +36 -0
- data/lib/aikido/zen/runtime_settings/protection_settings.rb +62 -0
- data/lib/aikido/zen/runtime_settings/rate_limit_settings.rb +47 -0
- data/lib/aikido/zen/runtime_settings.rb +70 -0
- data/lib/aikido/zen/scan.rb +75 -0
- data/lib/aikido/zen/scanners/sql_injection_scanner.rb +95 -0
- data/lib/aikido/zen/scanners/ssrf/dns_lookups.rb +27 -0
- data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +85 -0
- data/lib/aikido/zen/scanners/ssrf_scanner.rb +251 -0
- data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +43 -0
- data/lib/aikido/zen/scanners.rb +5 -0
- data/lib/aikido/zen/sink.rb +108 -0
- data/lib/aikido/zen/sinks/async_http.rb +63 -0
- data/lib/aikido/zen/sinks/curb.rb +89 -0
- data/lib/aikido/zen/sinks/em_http.rb +71 -0
- data/lib/aikido/zen/sinks/excon.rb +103 -0
- data/lib/aikido/zen/sinks/http.rb +76 -0
- data/lib/aikido/zen/sinks/httpclient.rb +68 -0
- data/lib/aikido/zen/sinks/httpx.rb +61 -0
- data/lib/aikido/zen/sinks/mysql2.rb +21 -0
- data/lib/aikido/zen/sinks/net_http.rb +85 -0
- data/lib/aikido/zen/sinks/patron.rb +88 -0
- data/lib/aikido/zen/sinks/pg.rb +50 -0
- data/lib/aikido/zen/sinks/resolv.rb +41 -0
- data/lib/aikido/zen/sinks/socket.rb +51 -0
- data/lib/aikido/zen/sinks/sqlite3.rb +30 -0
- data/lib/aikido/zen/sinks/trilogy.rb +21 -0
- data/lib/aikido/zen/sinks/typhoeus.rb +78 -0
- data/lib/aikido/zen/sinks.rb +21 -0
- data/lib/aikido/zen/stats/routes.rb +53 -0
- data/lib/aikido/zen/stats/sink_stats.rb +95 -0
- data/lib/aikido/zen/stats/users.rb +26 -0
- data/lib/aikido/zen/stats.rb +171 -0
- data/lib/aikido/zen/synchronizable.rb +24 -0
- data/lib/aikido/zen/system_info.rb +84 -0
- data/lib/aikido/zen/version.rb +10 -0
- data/lib/aikido/zen.rb +138 -0
- data/lib/aikido-zen.rb +3 -0
- data/lib/aikido.rb +3 -0
- data/tasklib/libzen.rake +128 -0
- metadata +175 -0
data/README.md
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
# Aikido::Firewall
|
2
|
+
|
3
|
+
TODO: Write me :)
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Install the gem and add to the application's Gemfile by executing:
|
8
|
+
|
9
|
+
$ bundle add aikido-firewall
|
10
|
+
|
11
|
+
If bundler is not being used to manage dependencies, install the gem by executing:
|
12
|
+
|
13
|
+
$ gem install aikido-firewall
|
14
|
+
|
15
|
+
## Development
|
16
|
+
|
17
|
+
After checking out the repo, run `bin/setup` to install dependencies. Then, run
|
18
|
+
`rake test` to run the tests. You can also run `bin/console` for an interactive
|
19
|
+
prompt that will allow you to experiment.
|
20
|
+
|
21
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To
|
22
|
+
release a new version, update the version number in `version.rb`, and then run
|
23
|
+
`bundle exec rake release`, which will create a git tag for the version, push
|
24
|
+
git commits and the created tag, and push the `.gem` file to
|
25
|
+
[rubygems.org](https://rubygems.org).
|
26
|
+
|
27
|
+
## Contributing
|
28
|
+
|
29
|
+
Bug reports and pull requests are welcome [on GitHub][repo]. This project is
|
30
|
+
intended to be a safe, welcoming space for collaboration, and contributors are
|
31
|
+
expected to adhere to the [code of conduct][coc].
|
32
|
+
|
33
|
+
## Code of Conduct
|
34
|
+
|
35
|
+
Everyone interacting in the `Aikido::Firewall` project's codebases, issue
|
36
|
+
trackers, chat rooms and mailing lists is expected to follow the [code of
|
37
|
+
conduct][coc].
|
38
|
+
|
39
|
+
[repo]: https://github.com/aikidosec/firewall-ruby
|
40
|
+
[coc]: https://github.com/aikidosec/firewall-ruby/blob/main/CODE_OF_CONDUCT.md
|
data/Rakefile
ADDED
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "bundler/gem_tasks"
|
4
|
+
require "minitest/test_task"
|
5
|
+
require "standard/rake"
|
6
|
+
require "rake/clean"
|
7
|
+
|
8
|
+
load "tasklib/libzen.rake"
|
9
|
+
|
10
|
+
namespace :build do
|
11
|
+
desc "Ensure Gemfile.lock is up-to-date"
|
12
|
+
task "update_gem_lockfile" do
|
13
|
+
sh "bundle check >/dev/null || bundle"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
task build: ["build:update_gem_lockfile", "libzen:download:all"]
|
17
|
+
|
18
|
+
# Build all the native gems as well
|
19
|
+
Rake::Task["build"].enhance(["libzen:gems"])
|
20
|
+
|
21
|
+
# rake release wants to tag the commit and push the tag, but we run the release
|
22
|
+
# workflow after creating the tag, and so we don't need another one.
|
23
|
+
Rake::Task["release:source_control_push"].clear
|
24
|
+
task "release:source_control_push" do
|
25
|
+
# do nothing
|
26
|
+
end
|
27
|
+
|
28
|
+
# Push all the native gems before the libzen-less one.
|
29
|
+
task "release:rubygem_push" => "libzen:release"
|
30
|
+
|
31
|
+
Pathname.glob("sample_apps/*").select(&:directory?).each do |dir|
|
32
|
+
namespace :build do
|
33
|
+
desc "Ensure Gemfile.lock is up-to-date in the #{dir.basename} sample app"
|
34
|
+
task "update_#{dir.basename}_lockfile" do
|
35
|
+
Dir.chdir(dir) { sh "bundle check >/dev/null || bundle" }
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
task build: "build:update_#{dir.basename}_lockfile"
|
40
|
+
end
|
41
|
+
|
42
|
+
Minitest::TestTask.create do |test_task|
|
43
|
+
test_task.test_globs = FileList["test/**/{test_*,*_test}.rb"]
|
44
|
+
.exclude("test/e2e/**/*.rb")
|
45
|
+
end
|
46
|
+
task test: "libzen:download:current"
|
47
|
+
|
48
|
+
Pathname.glob("test/e2e/*").select(&:directory?).each do |dir|
|
49
|
+
namespace :e2e do
|
50
|
+
desc "Run e2e tests for the #{dir.basename} sample app"
|
51
|
+
task dir.basename do
|
52
|
+
Dir.chdir(dir) do
|
53
|
+
sh "rake ci:setup"
|
54
|
+
sh "rake test"
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
desc "Run all e2e tests"
|
60
|
+
task e2e: "e2e:#{dir.basename}"
|
61
|
+
end
|
62
|
+
|
63
|
+
task default: %i[test standard]
|
@@ -0,0 +1,116 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent"
|
4
|
+
require_relative "config"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
# Converts an object into an Actor for reporting back to the Aikido Dashboard.
|
8
|
+
#
|
9
|
+
# @overload Actor(actor)
|
10
|
+
# @param actor [#to_aikido_actor] anything that implements #to_aikido_actor
|
11
|
+
# will have that method called and its value returned.
|
12
|
+
# @return Aikido::Zen::Actor
|
13
|
+
#
|
14
|
+
# @overload Actor(data)
|
15
|
+
# @param data [Hash<Symbol, String>]
|
16
|
+
# @option data [String] :id a unique identifier for this user.
|
17
|
+
# @option data [String, nil] :name an optional name to display in the UI.
|
18
|
+
# @return Aikido::Zen::Actor
|
19
|
+
def self.Actor(data)
|
20
|
+
return if data.nil?
|
21
|
+
return data.to_aikido_actor if data.respond_to?(:to_aikido_actor)
|
22
|
+
|
23
|
+
attrs = {}
|
24
|
+
if data.respond_to?(:to_hash)
|
25
|
+
attrs = data.to_hash
|
26
|
+
.slice("id", "name", :id, :name)
|
27
|
+
.compact
|
28
|
+
.transform_keys(&:to_sym)
|
29
|
+
.transform_values(&:to_s)
|
30
|
+
else
|
31
|
+
return nil
|
32
|
+
end
|
33
|
+
|
34
|
+
return nil if attrs[:id].nil? || attrs[:id].to_s.strip.empty?
|
35
|
+
|
36
|
+
Actor.new(**attrs)
|
37
|
+
end
|
38
|
+
|
39
|
+
# Represents someone connecting to the application and making requests.
|
40
|
+
class Actor
|
41
|
+
# @return [String] a unique identifier for this user.
|
42
|
+
attr_reader :id
|
43
|
+
|
44
|
+
# @return [String, nil] an optional name to display in the Aikido UI.
|
45
|
+
attr_reader :name
|
46
|
+
|
47
|
+
# @return [Time]
|
48
|
+
attr_reader :first_seen_at
|
49
|
+
|
50
|
+
# @param id [String]
|
51
|
+
# @param name [String, nil]
|
52
|
+
# @param ip [String, nil]
|
53
|
+
# @param seen_at [Time]
|
54
|
+
def initialize(
|
55
|
+
id:,
|
56
|
+
name: nil,
|
57
|
+
ip: Aikido::Zen.current_context&.request&.ip,
|
58
|
+
seen_at: Time.now.utc
|
59
|
+
)
|
60
|
+
@id = id
|
61
|
+
@name = name
|
62
|
+
@first_seen_at = seen_at
|
63
|
+
@last_seen_at = Concurrent::AtomicReference.new(seen_at)
|
64
|
+
@ip = Concurrent::AtomicReference.new(ip)
|
65
|
+
end
|
66
|
+
|
67
|
+
# @return [Time]
|
68
|
+
def last_seen_at
|
69
|
+
@last_seen_at.get
|
70
|
+
end
|
71
|
+
|
72
|
+
# @return [String, nil] the IP address last used by this user, if available.
|
73
|
+
def ip
|
74
|
+
@ip.get
|
75
|
+
end
|
76
|
+
|
77
|
+
# Atomically update the last IP used by the user, and the last time they've
|
78
|
+
# been "seen" connecting to the app.
|
79
|
+
#
|
80
|
+
# @param ip [String, nil] the last-seen IP address for the user. If +nil+
|
81
|
+
# and we had a non-empty value before, we won't update it. Defaults to
|
82
|
+
# the current HTTP request's IP address, if any.
|
83
|
+
# @param seen_at [Time] the time at which we're making the update. We will
|
84
|
+
# always keep the most recent time if this conflicts with the current
|
85
|
+
# value.
|
86
|
+
# @return [void]
|
87
|
+
def update(seen_at: Time.now.utc, ip: Aikido::Zen.current_context&.request&.ip)
|
88
|
+
@last_seen_at.try_update { |last_seen_at| [last_seen_at, seen_at].max }
|
89
|
+
@ip.try_update { |last_ip| [ip, last_ip].compact.first }
|
90
|
+
end
|
91
|
+
|
92
|
+
# @return [self]
|
93
|
+
def to_aikido_actor
|
94
|
+
self
|
95
|
+
end
|
96
|
+
|
97
|
+
def ==(other)
|
98
|
+
other.is_a?(Actor) && id == other.id
|
99
|
+
end
|
100
|
+
alias_method :eql?, :==
|
101
|
+
|
102
|
+
def hash
|
103
|
+
id.hash
|
104
|
+
end
|
105
|
+
|
106
|
+
def as_json
|
107
|
+
{
|
108
|
+
id: id,
|
109
|
+
name: name,
|
110
|
+
lastIpAddress: ip,
|
111
|
+
firstSeenAt: first_seen_at.to_i * 1000,
|
112
|
+
lastSeenAt: last_seen_at.to_i * 1000
|
113
|
+
}
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,187 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent"
|
4
|
+
require_relative "event"
|
5
|
+
require_relative "stats"
|
6
|
+
require_relative "config"
|
7
|
+
require_relative "system_info"
|
8
|
+
|
9
|
+
module Aikido::Zen
|
10
|
+
# Handles the background processes that communicate with the Aikido servers,
|
11
|
+
# including managing the runtime settings that keep the app protected.
|
12
|
+
class Agent
|
13
|
+
# @return [Aikido::Zen::Stats] the statistics collected by the agent.
|
14
|
+
attr_reader :stats
|
15
|
+
|
16
|
+
def initialize(
|
17
|
+
stats: Aikido::Zen::Stats.new,
|
18
|
+
config: Aikido::Zen.config,
|
19
|
+
info: Aikido::Zen.system_info,
|
20
|
+
api_client: Aikido::Zen::APIClient.new
|
21
|
+
)
|
22
|
+
@started_at = nil
|
23
|
+
|
24
|
+
@stats = stats
|
25
|
+
@info = info
|
26
|
+
@config = config
|
27
|
+
@api_client = api_client
|
28
|
+
@timer_tasks = []
|
29
|
+
@delayed_tasks = []
|
30
|
+
@reporting_pool = nil
|
31
|
+
@heartbeats = nil
|
32
|
+
end
|
33
|
+
|
34
|
+
def started?
|
35
|
+
!!@started_at
|
36
|
+
end
|
37
|
+
|
38
|
+
# Given an Attack, report it to the Aikido server, and/or block the request
|
39
|
+
# depending on configuration.
|
40
|
+
#
|
41
|
+
# @param attack [Attack] a detected attack.
|
42
|
+
# @return [void]
|
43
|
+
#
|
44
|
+
# @raise [Aikido::Zen::UnderAttackError] if the firewall is configured
|
45
|
+
# to block requests.
|
46
|
+
def handle_attack(attack)
|
47
|
+
attack.will_be_blocked! if @config.blocking_mode?
|
48
|
+
|
49
|
+
@config.logger.error("[ATTACK DETECTED] #{attack.log_message}")
|
50
|
+
report(Events::Attack.new(attack: attack)) if @api_client.can_make_requests?
|
51
|
+
|
52
|
+
stats.add_attack(attack, being_blocked: @config.blocking_mode?)
|
53
|
+
raise attack if @config.blocking_mode?
|
54
|
+
end
|
55
|
+
|
56
|
+
# Asynchronously reports an Event of any kind to the Aikido dashboard. If
|
57
|
+
# given a block, the API response will be passed to the block for handling.
|
58
|
+
#
|
59
|
+
# @param event [Aikido::Zen::Event]
|
60
|
+
# @yieldparam response [Object] the response from the reporting API in case
|
61
|
+
# of a successful request.
|
62
|
+
#
|
63
|
+
# @return [void]
|
64
|
+
def report(event)
|
65
|
+
reporting_pool.post do
|
66
|
+
response = @api_client.report(event)
|
67
|
+
yield response if response && block_given?
|
68
|
+
rescue Aikido::Zen::APIError, Aikido::Zen::NetworkError => err
|
69
|
+
@config.logger.error(err.message)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def start!
|
74
|
+
@config.logger.info "Starting Aikido agent"
|
75
|
+
|
76
|
+
raise Aikido::ZenError, "Aikido Agent already started!" if started?
|
77
|
+
@started_at = Time.now.utc
|
78
|
+
|
79
|
+
stats.start(@started_at)
|
80
|
+
|
81
|
+
if @config.blocking_mode?
|
82
|
+
@config.logger.info "Requests identified as attacks will be blocked"
|
83
|
+
else
|
84
|
+
@config.logger.warn "Non-blocking mode enabled! No requests will be blocked."
|
85
|
+
end
|
86
|
+
|
87
|
+
if @api_client.can_make_requests?
|
88
|
+
@config.logger.info "API Token set! Reporting has been enabled."
|
89
|
+
else
|
90
|
+
@config.logger.warn "No API Token set! Reporting has been disabled."
|
91
|
+
return
|
92
|
+
end
|
93
|
+
|
94
|
+
# Subscribe to firewall setting changes so we can correctly re-configure
|
95
|
+
# the heartbeat process.
|
96
|
+
Aikido::Zen.runtime_settings.add_observer(self, :setup_heartbeat)
|
97
|
+
|
98
|
+
at_exit { stop! if started? }
|
99
|
+
|
100
|
+
report(Events::Started.new(time: @started_at)) do |response|
|
101
|
+
Aikido::Zen.runtime_settings.update_from_json(response)
|
102
|
+
@config.logger.info "Updated runtime settings."
|
103
|
+
end
|
104
|
+
|
105
|
+
poll_for_setting_updates
|
106
|
+
end
|
107
|
+
|
108
|
+
def send_heartbeat
|
109
|
+
return unless @api_client.can_make_requests?
|
110
|
+
|
111
|
+
flushed_stats = stats.reset
|
112
|
+
report(Events::Heartbeat.new(stats: flushed_stats)) do |response|
|
113
|
+
Aikido::Zen.runtime_settings.update_from_json(response)
|
114
|
+
@config.logger.info "Updated runtime settings after heartbeat"
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def poll_for_setting_updates
|
119
|
+
timer_task(every: @config.polling_interval) do
|
120
|
+
if @api_client.should_fetch_settings?
|
121
|
+
Aikido::Zen.runtime_settings.update_from_json(@api_client.fetch_settings)
|
122
|
+
@config.logger.info "Updated runtime settings after polling"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def setup_heartbeat(settings)
|
128
|
+
return unless @api_client.can_make_requests?
|
129
|
+
|
130
|
+
# If the desired interval changed, then clear the current heartbeat timer
|
131
|
+
# and set up a new one.
|
132
|
+
if @heartbeats&.running? && @heartbeats.execution_interval != settings.heartbeat_interval
|
133
|
+
@heartbeats.shutdown
|
134
|
+
@heartbeats = nil
|
135
|
+
setup_heartbeat(settings)
|
136
|
+
|
137
|
+
# If the heartbeat timer isn't running but we know how often it should run, schedule it.
|
138
|
+
elsif !@heartbeats&.running? && settings.heartbeat_interval&.nonzero?
|
139
|
+
@config.logger.debug "Scheduling heartbeats every #{settings.heartbeat_interval} seconds"
|
140
|
+
@heartbeats = timer_task(every: settings.heartbeat_interval, run_now: false) do
|
141
|
+
send_heartbeat
|
142
|
+
end
|
143
|
+
|
144
|
+
elsif !@heartbeats&.running?
|
145
|
+
@config.logger.debug(format("Heartbeat could not be setup (interval: %p)", settings.heartbeat_interval))
|
146
|
+
end
|
147
|
+
|
148
|
+
# If the server hasn't received any stats, we want to also run a one-off
|
149
|
+
# heartbeat request in a minute.
|
150
|
+
if !settings.received_any_stats
|
151
|
+
delay(@config.initial_heartbeat_delay) { send_heartbeat if stats.any? }
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def stop!
|
156
|
+
@started_at = nil
|
157
|
+
|
158
|
+
@config.logger.info "Stopping Aikido agent"
|
159
|
+
|
160
|
+
@timer_tasks.each { |task| task.shutdown }
|
161
|
+
@delayed_tasks.each { |task| task.cancel if task.pending? }
|
162
|
+
|
163
|
+
@reporting_pool&.shutdown
|
164
|
+
@reporting_pool&.wait_for_termination(30)
|
165
|
+
end
|
166
|
+
|
167
|
+
private def reporting_pool
|
168
|
+
@reporting_pool ||= Concurrent::SingleThreadExecutor.new
|
169
|
+
end
|
170
|
+
|
171
|
+
private def delay(delay, &task)
|
172
|
+
Concurrent::ScheduledTask.execute(delay, executor: reporting_pool, &task)
|
173
|
+
.tap { |task| @delayed_tasks << task }
|
174
|
+
end
|
175
|
+
|
176
|
+
private def timer_task(every:, **opts, &block)
|
177
|
+
Concurrent::TimerTask.execute(
|
178
|
+
run_now: true,
|
179
|
+
interval_type: :fixed_rate,
|
180
|
+
execution_interval: every,
|
181
|
+
executor: reporting_pool,
|
182
|
+
**opts,
|
183
|
+
&block
|
184
|
+
).tap { |task| @timer_tasks << task }
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "net/http"
|
4
|
+
require_relative "rate_limiter"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
# Implements all communication with the Aikido servers.
|
8
|
+
class APIClient
|
9
|
+
def initialize(
|
10
|
+
config: Aikido::Zen.config,
|
11
|
+
rate_limiter: Aikido::Zen::RateLimiter::Breaker.new,
|
12
|
+
system_info: Aikido::Zen.system_info
|
13
|
+
)
|
14
|
+
@config = config
|
15
|
+
@system_info = system_info
|
16
|
+
@rate_limiter = rate_limiter
|
17
|
+
end
|
18
|
+
|
19
|
+
# @return [Boolean] whether we have a configured token.
|
20
|
+
def can_make_requests?
|
21
|
+
@config.api_token.to_s.size > 0
|
22
|
+
end
|
23
|
+
|
24
|
+
# Checks with the Aikido Runtime API the timestamp of the last settings
|
25
|
+
# update, and compares against the given value.
|
26
|
+
#
|
27
|
+
# @param last_updated_at [Time]
|
28
|
+
#
|
29
|
+
# @return [Boolean]
|
30
|
+
# @raise (see #request)
|
31
|
+
def should_fetch_settings?(last_updated_at = Aikido::Zen.runtime_settings.updated_at)
|
32
|
+
@config.logger.debug("Polling for new runtime settings to fetch")
|
33
|
+
|
34
|
+
return false unless can_make_requests?
|
35
|
+
return true if last_updated_at.nil?
|
36
|
+
|
37
|
+
response = request(
|
38
|
+
Net::HTTP::Get.new("/config", default_headers),
|
39
|
+
base_url: @config.runtime_api_base_url
|
40
|
+
)
|
41
|
+
|
42
|
+
new_updated_at = Time.at(response["configUpdatedAt"].to_i / 1000)
|
43
|
+
new_updated_at > last_updated_at
|
44
|
+
end
|
45
|
+
|
46
|
+
# Fetches the runtime settings from the server. In case of a timeout or
|
47
|
+
# other low-lever error, the request will be automatically retried up to two
|
48
|
+
# times, after which it will raise an error.
|
49
|
+
#
|
50
|
+
# @return [Hash] decoded JSON response from the server with the runtime
|
51
|
+
# settings.
|
52
|
+
# @raise (see #request)
|
53
|
+
def fetch_settings
|
54
|
+
@config.logger.debug("Fetching new runtime settings")
|
55
|
+
|
56
|
+
request(Net::HTTP::Get.new("/api/runtime/config", default_headers))
|
57
|
+
end
|
58
|
+
|
59
|
+
# @overload report(event)
|
60
|
+
# Reports an event to the server.
|
61
|
+
#
|
62
|
+
# @param event [Aikido::Zen::Event]
|
63
|
+
# @return [void]
|
64
|
+
# @raise (see #request)
|
65
|
+
#
|
66
|
+
# @overload report(settings_updating_event)
|
67
|
+
# Reports an event that responds with updated runtime settings, and
|
68
|
+
# requires us to update settings afterwards.
|
69
|
+
#
|
70
|
+
# @param settings_updating_event [Aikido::Zen::Events::Started,
|
71
|
+
# Aikido::Zen::Events::Heartbeat]
|
72
|
+
# @return (see #fetch_settings)
|
73
|
+
# @raise (see #request)
|
74
|
+
def report(event)
|
75
|
+
if @rate_limiter.throttle?(event)
|
76
|
+
@config.logger.error("Not reporting #{event.type.upcase} event due to rate limiting")
|
77
|
+
return
|
78
|
+
end
|
79
|
+
|
80
|
+
@config.logger.debug("Reporting #{event.type.upcase} event")
|
81
|
+
|
82
|
+
req = Net::HTTP::Post.new("/api/runtime/events", default_headers)
|
83
|
+
req.content_type = "application/json"
|
84
|
+
req.body = @config.json_encoder.call(event.as_json)
|
85
|
+
|
86
|
+
request(req)
|
87
|
+
rescue Aikido::Zen::RateLimitedError
|
88
|
+
@rate_limiter.open!
|
89
|
+
raise
|
90
|
+
end
|
91
|
+
|
92
|
+
# Perform an HTTP request against one of our API endpoints, and process the
|
93
|
+
# response.
|
94
|
+
#
|
95
|
+
# @param request [Net::HTTPRequest]
|
96
|
+
# @param base_url [URI] which API to use. Defaults to +Config#api_base_url+.
|
97
|
+
#
|
98
|
+
# @return [Object] the result of decoding the JSON response from the server.
|
99
|
+
#
|
100
|
+
# @raise [Aikido::Zen::APIError] in case of a 4XX or 5XX response.
|
101
|
+
# @raise [Aikido::Zen::NetworkError] if an error occurs trying to make the
|
102
|
+
# request.
|
103
|
+
private def request(request, base_url: @config.api_base_url)
|
104
|
+
Net::HTTP.start(base_url.host, base_url.port, http_settings) do |http|
|
105
|
+
response = http.request(request)
|
106
|
+
|
107
|
+
case response
|
108
|
+
when Net::HTTPSuccess
|
109
|
+
@config.json_decoder.call(response.body)
|
110
|
+
when Net::HTTPTooManyRequests
|
111
|
+
raise RateLimitedError.new(request, response)
|
112
|
+
else
|
113
|
+
raise APIError.new(request, response)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
rescue Timeout::Error, IOError, SystemCallError, OpenSSL::OpenSSLError => err
|
117
|
+
raise NetworkError.new(request, err)
|
118
|
+
end
|
119
|
+
|
120
|
+
private def http_settings
|
121
|
+
@http_settings ||= {use_ssl: true, max_retries: 2}.merge(@config.api_timeouts)
|
122
|
+
end
|
123
|
+
|
124
|
+
private def default_headers
|
125
|
+
@default_headers ||= {
|
126
|
+
"Authorization" => @config.api_token,
|
127
|
+
"Accept" => "application/json",
|
128
|
+
"User-Agent" => "#{@system_info.library_name} v#{@system_info.library_version}"
|
129
|
+
}
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
@@ -0,0 +1,138 @@
|
|
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 log_message
|
29
|
+
raise NotImplementedError, "implement in subclasses"
|
30
|
+
end
|
31
|
+
|
32
|
+
def as_json
|
33
|
+
raise NotImplementedError, "implement in subclasses"
|
34
|
+
end
|
35
|
+
|
36
|
+
def exception(*)
|
37
|
+
raise NotImplementedError, "implement in subclasses"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
module Attacks
|
42
|
+
class SQLInjectionAttack < Attack
|
43
|
+
attr_reader :query
|
44
|
+
attr_reader :input
|
45
|
+
attr_reader :dialect
|
46
|
+
|
47
|
+
def initialize(query:, input:, dialect:, **opts)
|
48
|
+
super(**opts)
|
49
|
+
@query = query
|
50
|
+
@input = input
|
51
|
+
@dialect = dialect
|
52
|
+
end
|
53
|
+
|
54
|
+
def log_message
|
55
|
+
format(
|
56
|
+
"SQL Injection: Malicious user input «%s» detected in %s query «%s»",
|
57
|
+
@input, @dialect, @query
|
58
|
+
)
|
59
|
+
end
|
60
|
+
|
61
|
+
def as_json
|
62
|
+
{
|
63
|
+
kind: "sql_injection",
|
64
|
+
blocked: blocked?,
|
65
|
+
metadata: {sql: @query},
|
66
|
+
operation: @operation
|
67
|
+
}.merge(@input.as_json)
|
68
|
+
end
|
69
|
+
|
70
|
+
def exception(*)
|
71
|
+
SQLInjectionError.new(self)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class SSRFAttack < Attack
|
76
|
+
attr_reader :input
|
77
|
+
attr_reader :request
|
78
|
+
|
79
|
+
def initialize(request:, input:, **opts)
|
80
|
+
super(**opts)
|
81
|
+
@input = input
|
82
|
+
@request = request
|
83
|
+
end
|
84
|
+
|
85
|
+
def log_message
|
86
|
+
format(
|
87
|
+
"SSRF: Request to user-supplied hostname «%s» detected in %s (%s).",
|
88
|
+
@input, @operation, @request
|
89
|
+
).strip
|
90
|
+
end
|
91
|
+
|
92
|
+
def exception(*)
|
93
|
+
SSRFDetectedError.new(self)
|
94
|
+
end
|
95
|
+
|
96
|
+
def as_json
|
97
|
+
{
|
98
|
+
kind: "ssrf",
|
99
|
+
metadata: {host: @request.uri.hostname, port: @request.uri.port},
|
100
|
+
blocked: blocked?,
|
101
|
+
operation: @operation
|
102
|
+
}.merge(@input.as_json)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Special case of an SSRF attack where we don't have a context—we're just
|
107
|
+
# detecting a request to a particularly sensitive address.
|
108
|
+
class StoredSSRFAttack < Attack
|
109
|
+
attr_reader :hostname
|
110
|
+
attr_reader :address
|
111
|
+
|
112
|
+
def initialize(hostname:, address:, **opts)
|
113
|
+
super(**opts)
|
114
|
+
@hostname = hostname
|
115
|
+
@address = address
|
116
|
+
end
|
117
|
+
|
118
|
+
def log_message
|
119
|
+
format(
|
120
|
+
"Stored SSRF: Request to sensitive host «%s» (%s) detected from unknown source in %s",
|
121
|
+
@hostname, @address, @operation
|
122
|
+
)
|
123
|
+
end
|
124
|
+
|
125
|
+
def exception(*)
|
126
|
+
SSRFDetectedError.new(self)
|
127
|
+
end
|
128
|
+
|
129
|
+
def as_json
|
130
|
+
{
|
131
|
+
kind: "ssrf",
|
132
|
+
blocked: blocked?,
|
133
|
+
operation: @operation
|
134
|
+
}
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|