aikido-zen 0.1.1-arm64-linux → 1.0.0.pre.beta.1-arm64-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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/.simplecov +7 -0
  3. data/CHANGELOG.md +4 -0
  4. data/README.md +11 -2
  5. data/benchmarks/README.md +8 -12
  6. data/benchmarks/rails7.1_sql_injection.js +30 -34
  7. data/docs/banner.svg +128 -129
  8. data/docs/config.md +8 -6
  9. data/docs/rails.md +1 -1
  10. data/lib/aikido/zen/agent.rb +13 -9
  11. data/lib/aikido/zen/api_client.rb +17 -7
  12. data/lib/aikido/zen/attack.rb +105 -36
  13. data/lib/aikido/zen/background_worker.rb +52 -0
  14. data/lib/aikido/zen/collector/routes.rb +2 -0
  15. data/lib/aikido/zen/collector.rb +31 -4
  16. data/lib/aikido/zen/config.rb +55 -20
  17. data/lib/aikido/zen/detached_agent/agent.rb +78 -0
  18. data/lib/aikido/zen/detached_agent/front_object.rb +37 -0
  19. data/lib/aikido/zen/detached_agent/server.rb +41 -0
  20. data/lib/aikido/zen/detached_agent.rb +2 -0
  21. data/lib/aikido/zen/errors.rb +18 -1
  22. data/lib/aikido/zen/event.rb +4 -2
  23. data/lib/aikido/zen/libzen-v0.1.37.aarch64.so +0 -0
  24. data/lib/aikido/zen/middleware/check_allowed_addresses.rb +2 -14
  25. data/lib/aikido/zen/middleware/middleware.rb +11 -0
  26. data/lib/aikido/zen/middleware/{throttler.rb → rack_throttler.rb} +11 -13
  27. data/lib/aikido/zen/middleware/request_tracker.rb +190 -0
  28. data/lib/aikido/zen/middleware/set_context.rb +1 -4
  29. data/lib/aikido/zen/outbound_connection_monitor.rb +4 -0
  30. data/lib/aikido/zen/payload.rb +2 -0
  31. data/lib/aikido/zen/rails_engine.rb +12 -0
  32. data/lib/aikido/zen/rate_limiter/breaker.rb +3 -3
  33. data/lib/aikido/zen/rate_limiter.rb +7 -12
  34. data/lib/aikido/zen/request/rails_router.rb +6 -18
  35. data/lib/aikido/zen/request/schema/auth_schemas.rb +14 -0
  36. data/lib/aikido/zen/request/schema/builder.rb +0 -2
  37. data/lib/aikido/zen/request/schema/definition.rb +0 -5
  38. data/lib/aikido/zen/request/schema.rb +18 -3
  39. data/lib/aikido/zen/runtime_settings.rb +2 -2
  40. data/lib/aikido/zen/scanners/path_traversal/helpers.rb +65 -0
  41. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +63 -0
  42. data/lib/aikido/zen/scanners/shell_injection/helpers.rb +159 -0
  43. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +64 -0
  44. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +4 -6
  45. data/lib/aikido/zen/scanners/ssrf/private_ip_checker.rb +33 -21
  46. data/lib/aikido/zen/scanners/ssrf_scanner.rb +15 -7
  47. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +6 -0
  48. data/lib/aikido/zen/scanners.rb +2 -0
  49. data/lib/aikido/zen/sink.rb +6 -1
  50. data/lib/aikido/zen/sinks/action_controller.rb +34 -15
  51. data/lib/aikido/zen/sinks/file.rb +120 -0
  52. data/lib/aikido/zen/sinks/kernel.rb +73 -0
  53. data/lib/aikido/zen/sinks/socket.rb +13 -0
  54. data/lib/aikido/zen/sinks.rb +8 -0
  55. data/lib/aikido/zen/system_info.rb +1 -1
  56. data/lib/aikido/zen/version.rb +2 -2
  57. data/lib/aikido/zen/worker.rb +5 -0
  58. data/lib/aikido/zen.rb +54 -8
  59. data/tasklib/bench.rake +31 -7
  60. data/tasklib/wrk.rb +88 -0
  61. metadata +22 -8
  62. data/lib/aikido/zen/libzen-v0.1.31.aarch64.so +0 -0
@@ -18,6 +18,20 @@ module Aikido::Zen
18
18
  @schemas.map(&:as_json) unless @schemas.empty?
19
19
  end
20
20
 
21
+ def self.from_json(schemas_array)
22
+ return NONE if !schemas_array || schemas_array.empty?
23
+
24
+ AuthSchemas.new(schemas_array.map do |schema|
25
+ if schema[:type] == "http"
26
+ Authorization.new(schema[:scheme])
27
+ elsif schema[:type] == "apiKey"
28
+ ApiKey.new(schema[:in], schema[:name])
29
+ else
30
+ raise "Invalid schema type: #{schema[:type]}"
31
+ end
32
+ end)
33
+ end
34
+
21
35
  def ==(other)
22
36
  other.is_a?(self.class) && schemas == other.schemas
23
37
  end
@@ -68,8 +68,6 @@ module Aikido::Zen
68
68
  new(type: "boolean")
69
69
  when String
70
70
  new(type: "string")
71
- when Integer
72
- new(type: "integer")
73
71
  when Numeric
74
72
  new(type: "number")
75
73
  when Array
@@ -65,11 +65,6 @@ module Aikido::Zen
65
65
  in {type: _}, {type: "null"}
66
66
  new(definition.merge(optional: true))
67
67
 
68
- # number | integer => number
69
- in [{type: "integer"}, {type: "number"}] |
70
- [{type: "number"}, {type: "integer"}]
71
- new(definition.merge(other.definition).merge(type: "number"))
72
-
73
68
  # x | y => [x, y] if x != y
74
69
  else
75
70
  left_type, right_type = definition[:type], other.definition[:type]
@@ -48,6 +48,24 @@ module Aikido::Zen
48
48
  {body: body, query: query_schema.as_json, auth: auth_schema.as_json}.compact
49
49
  end
50
50
 
51
+ def self.from_json(data)
52
+ if data.empty?
53
+ return Request::Schema.new(
54
+ content_type: nil,
55
+ body_schema: EMPTY_SCHEMA,
56
+ query_schema: EMPTY_SCHEMA,
57
+ auth_schema: Aikido::Zen::Request::Schema::AuthSchemas.new([])
58
+ )
59
+ end
60
+
61
+ Request::Schema.new(
62
+ content_type: data[:body].nil? ? nil : data[:body][:type],
63
+ body_schema: data[:body].nil? ? EMPTY_SCHEMA : Aikido::Zen::Request::Schema::Definition.new(data[:body][:schema]),
64
+ query_schema: data[:query].nil? ? EMPTY_SCHEMA : Aikido::Zen::Request::Schema::Definition.new(data[:query]),
65
+ auth_schema: Aikido::Zen::Request::Schema::AuthSchemas.from_json(data[:auth])
66
+ )
67
+ end
68
+
51
69
  # Merges the request specification with another request's specification.
52
70
  #
53
71
  # @param other [Aikido::Zen::Request::Schema, nil]
@@ -56,9 +74,6 @@ module Aikido::Zen
56
74
  return self if other.nil?
57
75
 
58
76
  self.class.new(
59
- # TODO: this is currently overriding the content type with the new
60
- # value, but we should support APIs that accept input in many types
61
- # (e.g. JSON and XML)
62
77
  content_type: other.content_type,
63
78
  body_schema: body_schema.merge(other.body_schema),
64
79
  query_schema: query_schema.merge(other.query_schema),
@@ -45,7 +45,7 @@ module Aikido::Zen
45
45
  # @param data [Hash] the decoded JSON payload from the /api/runtime/config
46
46
  # API endpoint.
47
47
  #
48
- # @return [void]
48
+ # @return [bool]
49
49
  def update_from_json(data)
50
50
  last_updated_at = updated_at
51
51
 
@@ -56,7 +56,7 @@ module Aikido::Zen
56
56
  self.skip_protection_for_ips = RuntimeSettings::IPSet.from_json(data["allowedIPAddresses"])
57
57
  self.received_any_stats = data["receivedAnyStats"]
58
58
 
59
- Aikido::Zen.agent.updated_settings! if updated_at != last_updated_at
59
+ updated_at != last_updated_at
60
60
  end
61
61
  end
62
62
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module Scanners
5
+ module PathTraversal
6
+ DANGEROUS_PATH_PARTS = ["../", "..\\"]
7
+ LINUX_ROOT_FOLDERS = [
8
+ "/bin/",
9
+ "/boot/",
10
+ "/dev/",
11
+ "/etc/",
12
+ "/home/",
13
+ "/init/",
14
+ "/lib/",
15
+ "/media/",
16
+ "/mnt/",
17
+ "/opt/",
18
+ "/proc/",
19
+ "/root/",
20
+ "/run/",
21
+ "/sbin/",
22
+ "/srv/",
23
+ "/sys/",
24
+ "/tmp/",
25
+ "/usr/",
26
+ "/var/"
27
+ ]
28
+
29
+ DANGEROUS_PATH_STARTS = LINUX_ROOT_FOLDERS + ["c:/", "c:\\"]
30
+
31
+ module Helpers
32
+ def self.contains_unsafe_path_parts(filepath)
33
+ DANGEROUS_PATH_PARTS.each do |dangerous_part|
34
+ return true if filepath.include?(dangerous_part)
35
+ end
36
+
37
+ false
38
+ end
39
+
40
+ def self.starts_with_unsafe_path(filepath, user_input)
41
+ # Check if path is relative (not absolute or drive letter path)
42
+ # Required because `expand_path` will build absolute paths from relative paths
43
+ return false if Pathname.new(filepath).relative? || Pathname.new(user_input).relative?
44
+
45
+ normalized_path = File.expand_path__internal_for_aikido_zen(filepath).downcase
46
+ normalized_user_input = File.expand_path__internal_for_aikido_zen(user_input).downcase
47
+
48
+ DANGEROUS_PATH_STARTS.each do |dangerous_start|
49
+ if normalized_path.start_with?(dangerous_start) && normalized_path.start_with?(normalized_user_input)
50
+ # If the user input is the same as the dangerous start, we don't want to flag it
51
+ # to prevent false positives.
52
+ # e.g., if user input is /etc/ and the path is /etc/passwd, we don't want to flag it,
53
+ # as long as the user input does not contain a subdirectory or filename
54
+ if user_input == dangerous_start || user_input == dangerous_start.chomp("/")
55
+ return false
56
+ end
57
+ return true
58
+ end
59
+ end
60
+ false
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "path_traversal/helpers"
4
+
5
+ module Aikido::Zen
6
+ module Scanners
7
+ class PathTraversalScanner
8
+ def self.skips_on_nil_context?
9
+ true
10
+ end
11
+
12
+ # Checks if the user introduced input is trying to access other path using
13
+ # Path Traversal kind of attacks.
14
+ #
15
+ # @param filepath [String] the expanded path that is tried to be read
16
+ # @param context [Aikido::Zen::Context]
17
+ # @param sink [Aikido::Zen::Sink] the Sink that is running the scan.
18
+ # @param operation [Symbol, String] name of the method being scanned.
19
+ #
20
+ # @return [Aikido::Zen::Attacks::PathTraversalAttack, nil] an Attack if any
21
+ # user input is detected to be attempting a Path Traversal Attack, or +nil+ if not.
22
+ def self.call(filepath:, sink:, context:, operation:)
23
+ context.payloads.each do |payload|
24
+ next unless new(filepath, payload.value).attack?
25
+
26
+ return Attacks::PathTraversalAttack.new(
27
+ sink: sink,
28
+ input: payload,
29
+ filepath: filepath,
30
+ context: context,
31
+ operation: "#{sink.operation}.#{operation}"
32
+ )
33
+ end
34
+
35
+ nil
36
+ end
37
+
38
+ def initialize(filepath, input)
39
+ @filepath = filepath.downcase
40
+ @input = input.downcase
41
+ end
42
+
43
+ def attack?
44
+ # Single character are ignored because they don't pose a big threat
45
+ return false if @input.length <= 1
46
+
47
+ # We ignore cases where the user input is longer than the file path.
48
+ # Because the user input can't be part of the file path.
49
+ return false if @input.length > @filepath.length
50
+
51
+ # We ignore cases where the user input is not part of the file path.
52
+ return false unless @filepath.include?(@input)
53
+
54
+ if PathTraversal::Helpers.contains_unsafe_path_parts(@filepath) && PathTraversal::Helpers.contains_unsafe_path_parts(@input)
55
+ return true
56
+ end
57
+
58
+ # Check for absolute path traversal
59
+ PathTraversal::Helpers.starts_with_unsafe_path(@filepath, @input)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen::Scanners::ShellInjection
4
+ module Helpers
5
+ ESCAPE_CHARS = %W[' "]
6
+ DANGEROUS_CHARS_INSIDE_DOUBLE_QUOTES = %W[$ ` \\ !]
7
+ DANGEROUS_CHARS = [
8
+ "#", "!", '"', "$", "&", "'", "(", ")", "*", ";", "<", "=", ">", "?",
9
+ "[", "\\", "]", "^", "`", "{", "|", "}", " ", "\n", "\t", "~"
10
+ ]
11
+
12
+ COMMANDS = %w[sleep shutdown reboot poweroff halt ifconfig chmod chown ping
13
+ ssh scp curl wget telnet kill killall rm mv cp touch echo cat head
14
+ tail grep find awk sed sort uniq wc ls env ps who whoami id w df du
15
+ pwd uname hostname netstat passwd arch printenv logname pstree hostnamectl
16
+ set lsattr killall5 dmesg history free uptime finger top shopt :]
17
+
18
+ PATH_PREFIXES = %w[/bin/ /sbin/ /usr/bin/ /usr/sbin/ /usr/local/bin/ /usr/local/sbin/]
19
+
20
+ SEPARATORS = [" ", "\t", "\n", ";", "&", "|", "(", ")", "<", ">"]
21
+
22
+ # @param command [string]
23
+ # @param user_input [string]
24
+ def self.is_safely_encapsulated(command, user_input)
25
+ segments = command.split(user_input)
26
+
27
+ # The next condition is merely here to be compliant with what javascript does when splitting strings:
28
+ # From js doc https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split
29
+ # > If separator appears at the beginning (or end) of the string, it still has the effect of splitting,
30
+ # > resulting in an empty (i.e. zero length) string appearing at the first (or last) position of
31
+ # > the returned array.
32
+ # This is necessary because this code is ported form the firewall-node code.
33
+ if user_input.length > 1
34
+ if command.start_with? user_input
35
+ segments.unshift ""
36
+ end
37
+
38
+ if command.end_with? user_input
39
+ segments << ""
40
+ end
41
+ end
42
+
43
+ # Call the helper function to get current and next segments
44
+ get_current_and_next_segments(segments).all? do |segments_pair|
45
+ char_before_user_input = segments_pair[:current_segment][-1]
46
+ char_after_user_input = segments_pair[:next_segment][0]
47
+
48
+ # Check if the character before is an escape character
49
+ is_escape_char = ESCAPE_CHARS.include?(char_before_user_input)
50
+
51
+ unless is_escape_char
52
+ next false
53
+ end
54
+
55
+ # If characters before and after the user input do not match, return false
56
+ next false if char_before_user_input != char_after_user_input
57
+
58
+ # If user input contains the escape character, return false
59
+ next false if user_input.include?(char_before_user_input)
60
+
61
+ # Handle dangerous characters inside double quotes
62
+ if char_before_user_input == '"' && DANGEROUS_CHARS_INSIDE_DOUBLE_QUOTES.any? { |char| user_input.include?(char) }
63
+ next false
64
+ end
65
+
66
+ next true
67
+ end
68
+ end
69
+
70
+ def self.get_current_and_next_segments(segments)
71
+ segments.each_cons(2).map { |current_segment, next_segment| {current_segment: current_segment, next_segment: next_segment} }
72
+ end
73
+
74
+ # Helper function for sorting commands by length (longer commands first)
75
+ def self.by_length(a, b)
76
+ b.length - a.length
77
+ end
78
+
79
+ # Escape characters with special meaning either inside or outside character sets.
80
+ # Use a simple backslash escape when it’s always valid, and a `\xnn` escape when the simpler
81
+ # form would be disallowed by Unicode patterns’ stricter grammar.
82
+ #
83
+ # Inspired by https://github.com/sindresorhus/escape-string-regexp/
84
+ def self.escape_string_regexp(string)
85
+ string.gsub(/[|\\{}()\[\]^$+*?.]/) { "\\#{$&}" }.gsub("-", '\\x2d')
86
+ end
87
+
88
+ # Construct the regex for commands
89
+ COMMANDS_REGEX = Regexp.new(
90
+ "([/.]*((#{PATH_PREFIXES.map { |p| Helpers.escape_string_regexp(p) }.join("|")})?((#{COMMANDS.sort(&method(:by_length)).join("|")}))))",
91
+ Regexp::IGNORECASE
92
+ )
93
+
94
+ def self.contains_shell_syntax(command, user_input)
95
+ # Check if input is only whitespace
96
+ return false if user_input.strip.empty?
97
+
98
+ # Check if the user input contains any dangerous characters
99
+ if DANGEROUS_CHARS.any? { |c| user_input.include?(c) }
100
+ return true
101
+ end
102
+
103
+ # If the command is exactly the same as the user input, check if it matches the regex
104
+ if command == user_input
105
+ return match_all(command, COMMANDS_REGEX).any? do |match|
106
+ match[:match].length == command.length && match[:match] == command
107
+ end
108
+ end
109
+
110
+ # Check if the command contains a commonly used command
111
+ match_all(command, COMMANDS_REGEX).each do |match|
112
+ # We found a command like `rm` or `/sbin/shutdown` in the command
113
+ # Check if the command is the same as the user input
114
+ # If it's not the same, continue searching
115
+ next if user_input != match[:match]
116
+
117
+ # Otherwise, we'll check if the command is surrounded by separators
118
+ # These separators are used to separate commands and arguments
119
+ # e.g. `rm<space>-rf`
120
+ # e.g. `ls<newline>whoami`
121
+ # e.g. `echo<tab>hello` Check if the command is surrounded by separators
122
+ char_before = if match[:index] - 1 < 0
123
+ nil
124
+ else
125
+ command[match[:index] - 1]
126
+ end
127
+
128
+ char_after = if match[:index] + match[:match].length >= command.length
129
+ nil
130
+ else
131
+ command[match[:index] + match[:match].length]
132
+ end
133
+
134
+ # e.g. `<separator>rm<separator>`
135
+ if SEPARATORS.include?(char_before) && SEPARATORS.include?(char_after)
136
+ return true
137
+ end
138
+
139
+ # e.g. `<separator>rm`
140
+ if SEPARATORS.include?(char_before) && char_after.nil?
141
+ return true
142
+ end
143
+
144
+ # e.g. `rm<separator>`
145
+ if char_before.nil? && SEPARATORS.include?(char_after)
146
+ return true
147
+ end
148
+ end
149
+
150
+ false
151
+ end
152
+
153
+ def self.match_all(string, regex)
154
+ string.enum_for(:scan, regex).map do |match|
155
+ {match: match[0], index: $~.begin(0)}
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shell_injection/helpers"
4
+
5
+ module Aikido::Zen
6
+ module Scanners
7
+ class ShellInjectionScanner
8
+ def self.skips_on_nil_context?
9
+ true
10
+ end
11
+
12
+ # @param command [String]
13
+ # @param sink [Aikido::Zen::Sink]
14
+ # @param context [Aikido::Zen::Context]
15
+ # @param operation [Symbol, String]
16
+ #
17
+ def self.call(command:, sink:, context:, operation:)
18
+ context.payloads.each do |payload|
19
+ next unless new(command, payload.value).attack?
20
+
21
+ return Attacks::ShellInjectionAttack.new(
22
+ sink: sink,
23
+ input: payload,
24
+ command: command,
25
+ context: context,
26
+ operation: "#{sink.operation}.#{operation}"
27
+ )
28
+ end
29
+
30
+ nil
31
+ end
32
+
33
+ # @param command [String]
34
+ # @param input [String]
35
+ def initialize(command, input)
36
+ @command = command
37
+ @input = input
38
+ end
39
+
40
+ def attack?
41
+ # Block single ~ character. For example `echo ~`
42
+ if @input == "~"
43
+ if @command.size > 1 && @command.include?("~")
44
+ return true
45
+ end
46
+ end
47
+
48
+ # we ignore single character since they don't pose a big threat.
49
+ # They are only able to crash the shell, not execute arbitraty commands.
50
+ return false if @input.size <= 1
51
+
52
+ # We ignore cases where the user input is longer than the command because
53
+ # the user input can't be part of the command
54
+ return false if @input.size > @command.size
55
+
56
+ return false unless @command.include?(@input)
57
+
58
+ return false if ShellInjection::Helpers.is_safely_encapsulated @command, @input
59
+
60
+ ShellInjection::Helpers.contains_shell_syntax @command, @input
61
+ end
62
+ end
63
+ end
64
+ end
@@ -6,6 +6,10 @@ require_relative "../internals"
6
6
  module Aikido::Zen
7
7
  module Scanners
8
8
  class SQLInjectionScanner
9
+ def self.skips_on_nil_context?
10
+ true
11
+ end
12
+
9
13
  # Checks if the given SQL query may have dangerous user input injected,
10
14
  # and returns an Attack if so, based on the current request.
11
15
  #
@@ -22,12 +26,6 @@ module Aikido::Zen
22
26
  # @raise [Aikido::Zen::InternalsError] if an error occurs when loading or
23
27
  # calling zenlib. See Sink#scan.
24
28
  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
29
  dialect = DIALECTS.fetch(dialect) do
32
30
  Aikido::Zen.config.logger.warn "Unknown SQL dialect #{dialect.inspect}"
33
31
  DIALECTS[:common]
@@ -31,35 +31,47 @@ module Aikido::Zen
31
31
  # @return [Boolean]
32
32
  def private?(hostname_or_address)
33
33
  resolve(hostname_or_address).any? do |ip|
34
- ip.loopback? || ip.private? || RFC5735.any? { |range| range === ip }
34
+ PRIVATE_RANGES.any? { |range| range === ip }
35
35
  end
36
36
  end
37
37
 
38
38
  private
39
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"),
40
+ # Source: https://github.com/AikidoSec/firewall-node/blob/main/library/vulnerabilities/ssrf/isPrivateIP.ts
41
+ PRIVATE_IPV4_RANGES = [
42
+ IPAddr.new("0.0.0.0/8"), # "This" network (RFC 1122)
43
+ IPAddr.new("10.0.0.0/8"), # Private-Use Networks (RFC 1918)
44
+ IPAddr.new("100.64.0.0/10"), # Shared Address Space (RFC 6598)
45
+ IPAddr.new("127.0.0.0/8"), # Loopback (RFC 1122)
46
+ IPAddr.new("169.254.0.0/16"), # Link Local (RFC 3927)
47
+ IPAddr.new("172.16.0.0/12"), # Private-Use Networks (RFC 1918)
48
+ IPAddr.new("192.0.0.0/24"), # IETF Protocol Assignments (RFC 5736)
49
+ IPAddr.new("192.0.2.0/24"), # TEST-NET-1 (RFC 5737)
50
+ IPAddr.new("192.31.196.0/24"), # AS112 Redirection Anycast (RFC 7535)
51
+ IPAddr.new("192.52.193.0/24"), # Automatic Multicast Tunneling (RFC 7450)
52
+ IPAddr.new("192.88.99.0/24"), # 6to4 Relay Anycast (RFC 3068)
53
+ IPAddr.new("192.168.0.0/16"), # Private-Use Networks (RFC 1918)
54
+ IPAddr.new("192.175.48.0/24"), # AS112 Redirection Anycast (RFC 7535)
55
+ IPAddr.new("198.18.0.0/15"), # Network Interconnect Device Benchmark Testing (RFC 2544)
56
+ IPAddr.new("198.51.100.0/24"), # TEST-NET-2 (RFC 5737)
57
+ IPAddr.new("203.0.113.0/24"), # TEST-NET-3 (RFC 5737)
58
+ IPAddr.new("224.0.0.0/4"), # Multicast (RFC 3171)
59
+ IPAddr.new("240.0.0.0/4"), # Reserved for Future Use (RFC 1112)
60
+ IPAddr.new("255.255.255.255/32") # Limited Broadcast (RFC 919)
61
+ ]
57
62
 
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
63
+ PRIVATE_IPV6_RANGES = [
64
+ IPAddr.new("::/128"), # Unspecified address (RFC 4291)
65
+ IPAddr.new("::1/128"), # Loopback address (RFC 4291)
66
+ IPAddr.new("fc00::/7"), # Unique local address (ULA) (RFC 4193
67
+ IPAddr.new("fe80::/10"), # Link-local address (LLA) (RFC 4291)
68
+ IPAddr.new("100::/64"), # Discard prefix (RFC 6666)
69
+ IPAddr.new("2001:db8::/32"), # Documentation prefix (RFC 3849)
70
+ IPAddr.new("3fff::/20") # Documentation prefix (RFC 9637)
61
71
  ]
62
72
 
73
+ PRIVATE_RANGES = PRIVATE_IPV4_RANGES + PRIVATE_IPV6_RANGES + PRIVATE_IPV4_RANGES.map(&:ipv4_mapped)
74
+
63
75
  def resolved_in_current_context
64
76
  context = Aikido::Zen.current_context
65
77
  context && context["dns.lookups"]
@@ -6,6 +6,12 @@ require_relative "ssrf/dns_lookups"
6
6
  module Aikido::Zen
7
7
  module Scanners
8
8
  class SSRFScanner
9
+ # SSRF attacks can be triggered through external inputs, so it is essential
10
+ # to have a valid context to safeguard against these attacks.
11
+ def self.skips_on_nil_context?
12
+ true
13
+ end
14
+
9
15
  # Checks if an outbound HTTP request is to a hostname supplied from user
10
16
  # input that resolves to a "dangerous" address. This is called from two
11
17
  # different places:
@@ -32,7 +38,6 @@ module Aikido::Zen
32
38
  # @return [Aikido::Zen::Attacks::SSRFAttack, nil] an Attack if any user
33
39
  # input is detected to be attempting SSRF, or +nil+ if not.
34
40
  def self.call(request:, sink:, context:, operation:, **)
35
- return if context.nil?
36
41
  return if request.nil? # See NOTE above.
37
42
 
38
43
  context["ssrf.redirects"] ||= RedirectChains.new
@@ -228,11 +233,11 @@ module Aikido::Zen
228
233
  # @api private
229
234
  class RedirectChains
230
235
  def initialize
231
- @redirects = {}
236
+ @redirects = Hash.new { |h, k| h[k] = [] }
232
237
  end
233
238
 
234
239
  def add(source:, destination:)
235
- @redirects[destination] = source
240
+ @redirects[destination].push(source)
236
241
  self
237
242
  end
238
243
 
@@ -242,11 +247,14 @@ module Aikido::Zen
242
247
  #
243
248
  # @param uri [URI]
244
249
  # @return [URI, nil]
245
- def origin(uri)
246
- source = @redirects[uri]
250
+ def origin(uri, visited = Set.new)
251
+ source = @redirects[uri].first
252
+
253
+ return source if visited.include?(source)
254
+ visited << source
247
255
 
248
- if @redirects[source]
249
- origin(source)
256
+ if !@redirects[source].empty?
257
+ origin(source, visited)
250
258
  else
251
259
  source
252
260
  end
@@ -5,6 +5,12 @@ module Aikido::Zen
5
5
  # Inspects the result of DNS lookups, to determine if we're being the target
6
6
  # of a stored SSRF targeting IMDS addresses (169.254.169.254).
7
7
  class StoredSSRFScanner
8
+ # Stored-SSRF can occur without external input, so we do not require a
9
+ # context to determine if an attack is happening.
10
+ def self.skips_on_nil_context?
11
+ false
12
+ end
13
+
8
14
  def self.call(hostname:, addresses:, operation:, sink:, context:, **opts)
9
15
  offending_address = new(hostname, addresses).attack?
10
16
  return if offending_address.nil?
@@ -3,3 +3,5 @@
3
3
  require_relative "scanners/sql_injection_scanner"
4
4
  require_relative "scanners/stored_ssrf_scanner"
5
5
  require_relative "scanners/ssrf_scanner"
6
+ require_relative "scanners/path_traversal_scanner"
7
+ require_relative "scanners/shell_injection_scanner"
@@ -84,11 +84,16 @@ module Aikido::Zen
84
84
 
85
85
  scan = Scan.new(sink: self, context: context)
86
86
 
87
+ scans_performed = 0
87
88
  scan.perform do
88
89
  result = nil
89
90
 
90
91
  scanners.each do |scanner|
92
+ next if scanner.skips_on_nil_context? && context.nil?
93
+
91
94
  result = scanner.call(sink: self, context: context, **scan_params)
95
+ scans_performed += 1
96
+
92
97
  break result if result
93
98
  rescue Aikido::Zen::InternalsError => error
94
99
  Aikido::Zen.config.logger.warn(error.message)
@@ -100,7 +105,7 @@ module Aikido::Zen
100
105
  result
101
106
  end
102
107
 
103
- @reporter.call(scan)
108
+ @reporter.call(scan) if scans_performed > 0
104
109
 
105
110
  scan
106
111
  end