aikido-zen 0.1.0.alpha4
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/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 +171 -0
@@ -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
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
# Stores the firewall configuration sourced from the Aikido dashboard. This
|
5
|
+
# object is updated by the Agent regularly.
|
6
|
+
#
|
7
|
+
# Because the RuntimeSettings object can be modified in runtime, it implements
|
8
|
+
# the {Observable} API, allowing you to subscribe to updates. These are
|
9
|
+
# triggered whenever #update_from_json makes a change (i.e. if the settings
|
10
|
+
# don't change, no update is triggered).
|
11
|
+
#
|
12
|
+
# You can subscribe to changes with +#add_observer(object, func_name)+, which
|
13
|
+
# will call the function passing the settings as an argument.
|
14
|
+
class RuntimeSettings < Concurrent::MutableStruct.new(
|
15
|
+
:updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :skip_protection_for_ips, :received_any_stats
|
16
|
+
)
|
17
|
+
include Concurrent::Concern::Observable
|
18
|
+
|
19
|
+
def initialize(*)
|
20
|
+
self.observers = Concurrent::Collection::CopyOnWriteObserverSet.new
|
21
|
+
super
|
22
|
+
self.endpoints ||= Endpoints.new
|
23
|
+
self.skip_protection_for_ips ||= IPSet.new
|
24
|
+
end
|
25
|
+
|
26
|
+
# @!attribute [rw] updated_at
|
27
|
+
# @return [Time] when these settings were updated in the Aikido dashboard.
|
28
|
+
|
29
|
+
# @!attribute [rw] heartbeat_interval
|
30
|
+
# @return [Integer] duration in seconds between heartbeat requests to the
|
31
|
+
# Aikido server.
|
32
|
+
|
33
|
+
# @!attribute [rw] received_any_stats
|
34
|
+
# @return [Boolean] whether the Aikido server has received any data from
|
35
|
+
# this application.
|
36
|
+
|
37
|
+
# @!attribute [rw] endpoints
|
38
|
+
# @return [Aikido::Zen::RuntimeSettings::Endpoints]
|
39
|
+
|
40
|
+
# @!attribute [rw] blocked_user_ids
|
41
|
+
# @return [Array]
|
42
|
+
|
43
|
+
# @!attribute [rw] skip_protection_for_ips
|
44
|
+
# @return [Aikido::Zen::RuntimeSettings::IPSet]
|
45
|
+
|
46
|
+
# Parse and interpret the JSON response from the core API with updated
|
47
|
+
# settings, and apply the changes. This will also notify any subscriber
|
48
|
+
# to updates
|
49
|
+
#
|
50
|
+
# @param data [Hash] the decoded JSON payload from the /api/runtime/config
|
51
|
+
# API endpoint.
|
52
|
+
#
|
53
|
+
# @return [void]
|
54
|
+
def update_from_json(data)
|
55
|
+
last_updated_at = updated_at
|
56
|
+
|
57
|
+
self.updated_at = Time.at(data["configUpdatedAt"].to_i / 1000)
|
58
|
+
self.heartbeat_interval = (data["heartbeatIntervalInMS"].to_i / 1000)
|
59
|
+
self.endpoints = Endpoints.from_json(data["endpoints"])
|
60
|
+
self.blocked_user_ids = data["blockedUserIds"]
|
61
|
+
self.skip_protection_for_ips = IPSet.from_json(data["allowedIPAddresses"])
|
62
|
+
self.received_any_stats = data["receivedAnyStats"]
|
63
|
+
|
64
|
+
observers.notify_observers(self) if updated_at != last_updated_at
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
require_relative "runtime_settings/ip_set"
|
70
|
+
require_relative "runtime_settings/endpoints"
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Aikido::Zen
|
4
|
+
# Scans track information about a single call made by one of our Sinks
|
5
|
+
# including whether it was detected as an attack or how long it took.
|
6
|
+
class Scan
|
7
|
+
# @return [Aikido::Zen::Sink] the originating Sink.
|
8
|
+
attr_reader :sink
|
9
|
+
|
10
|
+
# @return [Aikido::Zen::Context] the current Context, wrapping the HTTP
|
11
|
+
# request during which this scan was performed.
|
12
|
+
attr_reader :context
|
13
|
+
|
14
|
+
# @return [Aikido::Zen::Attack, nil] a detected Attack, or
|
15
|
+
# +nil+ if the scan was considered safe.
|
16
|
+
attr_reader :attack
|
17
|
+
|
18
|
+
# @return [Float, nil] duration in (fractional) seconds of the scan.
|
19
|
+
attr_reader :duration
|
20
|
+
|
21
|
+
# @return [Array<Hash>] list of captured exceptions while scanning.
|
22
|
+
attr_reader :errors
|
23
|
+
|
24
|
+
# @param sink [Aikido::Zen::Sink]
|
25
|
+
# @param context [Aikido::Zen::Context]
|
26
|
+
def initialize(sink:, context:)
|
27
|
+
@sink = sink
|
28
|
+
@context = context
|
29
|
+
@errors = []
|
30
|
+
@performed = false
|
31
|
+
end
|
32
|
+
|
33
|
+
def performed?
|
34
|
+
@performed
|
35
|
+
end
|
36
|
+
|
37
|
+
# @return [Boolean] whether this scan detected an Attack.
|
38
|
+
def attack?
|
39
|
+
@attack != nil
|
40
|
+
end
|
41
|
+
|
42
|
+
# @return [Boolean] whether any errors were caught by this Scan.
|
43
|
+
def errors?
|
44
|
+
@errors.any?
|
45
|
+
end
|
46
|
+
|
47
|
+
# Runs a block of code, capturing its return value as the potential
|
48
|
+
# Attack object (or nil, if safe), and how long it took to run.
|
49
|
+
#
|
50
|
+
# @yieldreturn [Aikido::Zen::Attack, nil]
|
51
|
+
# @return [void]
|
52
|
+
def perform
|
53
|
+
@performed = true
|
54
|
+
started_at = monotonic_time
|
55
|
+
@attack = yield
|
56
|
+
ensure
|
57
|
+
@duration = monotonic_time - started_at
|
58
|
+
end
|
59
|
+
|
60
|
+
# Keep track of exceptions encountered during scanning.
|
61
|
+
#
|
62
|
+
# @param error [Exception]
|
63
|
+
# @param scanner [#call]
|
64
|
+
#
|
65
|
+
# @return [nil]
|
66
|
+
def track_error(error, scanner)
|
67
|
+
@errors << {error: error, scanner: scanner}
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
|
71
|
+
private def monotonic_time
|
72
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../attack"
|
4
|
+
require_relative "../internals"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Scanners
|
8
|
+
class SQLInjectionScanner
|
9
|
+
# Checks if the given SQL query may have dangerous user input injected,
|
10
|
+
# and returns an Attack if so, based on the current request.
|
11
|
+
#
|
12
|
+
# @param query [String]
|
13
|
+
# @param context [Aikido::Zen::Context]
|
14
|
+
# @param sink [Aikido::Zen::Sink] the Sink that is running the scan.
|
15
|
+
# @param dialect [Symbol] one of +:mysql+, +:postgesql+, or +:sqlite+.
|
16
|
+
# @param operation [Symbol, String] name of the method being scanned.
|
17
|
+
# Expects +sink.operation+ being set to get the full module/name combo.
|
18
|
+
#
|
19
|
+
# @return [Aikido::Zen::Attack, nil] an Attack if any user input is
|
20
|
+
# detected to be attempting a SQL injection, or nil if this is safe.
|
21
|
+
#
|
22
|
+
# @raise [Aikido::Zen::InternalsError] if an error occurs when loading or
|
23
|
+
# calling zenlib. See Sink#scan.
|
24
|
+
def self.call(query:, dialect:, sink:, context:, operation:)
|
25
|
+
# FIXME: This assumes queries executed outside of an HTTP request are
|
26
|
+
# safe, but this is not the case. For example, if an HTTP request
|
27
|
+
# enqueues a background job, passing user input verbatim, the job might
|
28
|
+
# pass that input to a query without having a current request in scope.
|
29
|
+
return if context.nil?
|
30
|
+
|
31
|
+
dialect = DIALECTS.fetch(dialect) do
|
32
|
+
Aikido::Zen.config.logger.warn "Unknown SQL dialect #{dialect.inspect}"
|
33
|
+
DIALECTS[:common]
|
34
|
+
end
|
35
|
+
|
36
|
+
context.payloads.each do |payload|
|
37
|
+
next unless new(query, payload.value, dialect).attack?
|
38
|
+
|
39
|
+
return Attacks::SQLInjectionAttack.new(
|
40
|
+
sink: sink,
|
41
|
+
query: query,
|
42
|
+
input: payload,
|
43
|
+
dialect: dialect,
|
44
|
+
context: context,
|
45
|
+
operation: "#{sink.operation}.#{operation}"
|
46
|
+
)
|
47
|
+
end
|
48
|
+
|
49
|
+
nil
|
50
|
+
end
|
51
|
+
|
52
|
+
def initialize(query, input, dialect)
|
53
|
+
@query = query.downcase
|
54
|
+
@input = input.downcase
|
55
|
+
@dialect = dialect
|
56
|
+
end
|
57
|
+
|
58
|
+
def attack?
|
59
|
+
# Ignore single char inputs since they shouldn't be able to do much harm
|
60
|
+
return false if @input.length <= 1
|
61
|
+
|
62
|
+
# If the input is longer than the query, then it is not part of it
|
63
|
+
return false if @input.length > @query.length
|
64
|
+
|
65
|
+
# If the input is not included in the query at all, then we are safe
|
66
|
+
return false unless @query.include?(@input)
|
67
|
+
|
68
|
+
# If the input is solely alphanumeric, we can ignore it
|
69
|
+
return false if /\A[[:alnum:]_]+\z/i.match?(@input)
|
70
|
+
|
71
|
+
# If the input is a comma-separated list of numbers, ignore it.
|
72
|
+
return false if /\A(?:\d+(?:,\s*)?)+\z/i.match?(@input)
|
73
|
+
|
74
|
+
Internals.detect_sql_injection(@query, @input, @dialect)
|
75
|
+
end
|
76
|
+
|
77
|
+
# @api private
|
78
|
+
Dialect = Struct.new(:name, :internals_key, keyword_init: true) do
|
79
|
+
alias_method :to_s, :name
|
80
|
+
alias_method :to_int, :internals_key
|
81
|
+
end
|
82
|
+
|
83
|
+
# Maps easy-to-use Symbols to a struct that keeps both the name and the
|
84
|
+
# internal identifier used by libzen.
|
85
|
+
#
|
86
|
+
# @see https://github.com/AikidoSec/zen-internals/blob/main/src/sql_injection/helpers/select_dialect_based_on_enum.rs
|
87
|
+
DIALECTS = {
|
88
|
+
common: Dialect.new(name: "SQL", internals_key: 0),
|
89
|
+
mysql: Dialect.new(name: "MySQL", internals_key: 8),
|
90
|
+
postgresql: Dialect.new(name: "PostgreSQL", internals_key: 9),
|
91
|
+
sqlite: Dialect.new(name: "SQLite", internals_key: 12)
|
92
|
+
}
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "delegate"
|
4
|
+
|
5
|
+
module Aikido::Zen
|
6
|
+
module Scanners
|
7
|
+
module SSRF
|
8
|
+
# Simple per-request cache of all DNS lookups performed for a given host.
|
9
|
+
# We can store this in the context after performing a lookup, and have the
|
10
|
+
# SSRF scanner make sure the hostname being inspected doesn't actually
|
11
|
+
# resolve to an internal/dangerous IP.
|
12
|
+
class DNSLookups < SimpleDelegator
|
13
|
+
def initialize
|
14
|
+
super(Hash.new { |h, k| h[k] = [] })
|
15
|
+
end
|
16
|
+
|
17
|
+
def add(hostname, addresses)
|
18
|
+
self[hostname].concat(Array(addresses))
|
19
|
+
end
|
20
|
+
|
21
|
+
def ===(hostname)
|
22
|
+
key?(hostname)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "resolv"
|
4
|
+
require "ipaddr"
|
5
|
+
|
6
|
+
module Aikido::Zen
|
7
|
+
module Scanners
|
8
|
+
module SSRF
|
9
|
+
# Little helper to check if a given hostname or address is to be
|
10
|
+
# considered "dangerous" when used for an outbound HTTP request.
|
11
|
+
#
|
12
|
+
# When given a hostname:
|
13
|
+
#
|
14
|
+
# * If any DNS lookups have been performed and stored in the current Zen
|
15
|
+
# context (under the "dns.lookups" metadata key), we will map it to the
|
16
|
+
# list of IPs that we've resolved it to.
|
17
|
+
#
|
18
|
+
# * If not, we'll still try to map it to any statically defined address in
|
19
|
+
# the system hosts file (e.g. /etc/hosts).
|
20
|
+
#
|
21
|
+
# Once we mapped the hostname to an IP address (or, if given an IP
|
22
|
+
# address), this will check that it's not a loopback address, a private IP
|
23
|
+
# address (as defined by RFCs 1918 and 4193), or in one of the
|
24
|
+
# "special-use" IP ranges defined in RFC 5735.
|
25
|
+
class PrivateIPChecker
|
26
|
+
def initialize(resolver = Resolv::Hosts.new)
|
27
|
+
@resolver = resolver
|
28
|
+
end
|
29
|
+
|
30
|
+
# @param hostname_or_address [String]
|
31
|
+
# @return [Boolean]
|
32
|
+
def private?(hostname_or_address)
|
33
|
+
resolve(hostname_or_address).any? do |ip|
|
34
|
+
ip.loopback? || ip.private? || RFC5735.any? { |range| range === ip }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
RFC5735 = [
|
41
|
+
IPAddr.new("0.0.0.0/8"),
|
42
|
+
IPAddr.new("100.64.0.0/10"),
|
43
|
+
IPAddr.new("127.0.0.0/8"),
|
44
|
+
IPAddr.new("169.254.0.0/16"),
|
45
|
+
IPAddr.new("192.0.0.0/24"),
|
46
|
+
IPAddr.new("192.0.2.0/24"),
|
47
|
+
IPAddr.new("192.31.196.0/24"),
|
48
|
+
IPAddr.new("192.52.193.0/24"),
|
49
|
+
IPAddr.new("192.88.99.0/24"),
|
50
|
+
IPAddr.new("192.175.48.0/24"),
|
51
|
+
IPAddr.new("198.18.0.0/15"),
|
52
|
+
IPAddr.new("198.51.100.0/24"),
|
53
|
+
IPAddr.new("203.0.113.0/24"),
|
54
|
+
IPAddr.new("240.0.0.0/4"),
|
55
|
+
IPAddr.new("224.0.0.0/4"),
|
56
|
+
IPAddr.new("255.255.255.255/32"),
|
57
|
+
|
58
|
+
IPAddr.new("::/128"), # Unspecified address
|
59
|
+
IPAddr.new("fe80::/10"), # Link-local address (LLA)
|
60
|
+
IPAddr.new("::ffff:127.0.0.1/128") # IPv4-mapped address
|
61
|
+
]
|
62
|
+
|
63
|
+
def resolved_in_current_context
|
64
|
+
context = Aikido::Zen.current_context
|
65
|
+
context && context["dns.lookups"]
|
66
|
+
end
|
67
|
+
|
68
|
+
def resolve(hostname_or_address)
|
69
|
+
return [] if hostname_or_address.nil?
|
70
|
+
|
71
|
+
case hostname_or_address
|
72
|
+
when Resolv::AddressRegex
|
73
|
+
[IPAddr.new(hostname_or_address)]
|
74
|
+
when resolved_in_current_context
|
75
|
+
resolved_in_current_context[hostname_or_address]
|
76
|
+
.map { |address| IPAddr.new(address) }
|
77
|
+
else
|
78
|
+
@resolver.getaddresses(hostname_or_address.to_s)
|
79
|
+
.map { |address| IPAddr.new(address) }
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|