aikido-zen 0.1.0.alpha4-arm64-darwin
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/.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.aarch64.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
|