aikido-zen 1.0.2.beta.9-x86_64-mingw-64 → 1.0.2-x86_64-mingw-64

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/docs/config.md +9 -1
  4. data/docs/troubleshooting.md +62 -0
  5. data/lib/aikido/zen/agent.rb +2 -2
  6. data/lib/aikido/zen/attack.rb +8 -6
  7. data/lib/aikido/zen/attack_wave/helpers.rb +457 -0
  8. data/lib/aikido/zen/attack_wave.rb +88 -0
  9. data/lib/aikido/zen/cache.rb +91 -0
  10. data/lib/aikido/zen/capped_collections.rb +22 -4
  11. data/lib/aikido/zen/collector/event.rb +29 -0
  12. data/lib/aikido/zen/collector/hosts.rb +16 -1
  13. data/lib/aikido/zen/collector/stats.rb +17 -3
  14. data/lib/aikido/zen/collector/users.rb +2 -2
  15. data/lib/aikido/zen/collector.rb +14 -0
  16. data/lib/aikido/zen/config.rb +35 -6
  17. data/lib/aikido/zen/context/rack_request.rb +3 -0
  18. data/lib/aikido/zen/context/rails_request.rb +3 -0
  19. data/lib/aikido/zen/context.rb +35 -3
  20. data/lib/aikido/zen/event.rb +47 -2
  21. data/lib/aikido/zen/helpers.rb +24 -0
  22. data/lib/aikido/zen/middleware/{check_allowed_addresses.rb → allowed_address_checker.rb} +1 -1
  23. data/lib/aikido/zen/middleware/attack_wave_protector.rb +46 -0
  24. data/lib/aikido/zen/middleware/{set_context.rb → context_setter.rb} +1 -1
  25. data/lib/aikido/zen/middleware/rack_throttler.rb +3 -1
  26. data/lib/aikido/zen/middleware/request_tracker.rb +8 -3
  27. data/lib/aikido/zen/outbound_connection.rb +11 -1
  28. data/lib/aikido/zen/rails_engine.rb +3 -2
  29. data/lib/aikido/zen/request/rails_router.rb +17 -2
  30. data/lib/aikido/zen/request.rb +2 -36
  31. data/lib/aikido/zen/route.rb +50 -0
  32. data/lib/aikido/zen/runtime_settings/endpoints.rb +37 -8
  33. data/lib/aikido/zen/runtime_settings.rb +5 -4
  34. data/lib/aikido/zen/scanners/path_traversal_scanner.rb +3 -2
  35. data/lib/aikido/zen/scanners/shell_injection_scanner.rb +3 -2
  36. data/lib/aikido/zen/scanners/sql_injection_scanner.rb +3 -2
  37. data/lib/aikido/zen/scanners/ssrf_scanner.rb +2 -1
  38. data/lib/aikido/zen/scanners/stored_ssrf_scanner.rb +5 -1
  39. data/lib/aikido/zen/sinks/action_controller.rb +3 -1
  40. data/lib/aikido/zen/sinks/file.rb +34 -32
  41. data/lib/aikido/zen/sinks/socket.rb +7 -0
  42. data/lib/aikido/zen/system_info.rb +1 -5
  43. data/lib/aikido/zen/version.rb +1 -1
  44. data/lib/aikido/zen.rb +55 -6
  45. data/tasklib/bench.rake +1 -1
  46. metadata +10 -4
@@ -33,8 +33,10 @@ module Aikido::Zen
33
33
  private
34
34
 
35
35
  def should_throttle?(request)
36
+ # Bypass rate limiting for allowed IPs
37
+ return false if @settings.allowed_ips.include?(request.ip)
38
+
36
39
  return false unless @settings.endpoints[request.route].rate_limiting.enabled?
37
- return false if @settings.skip_protection_for_ips.include?(request.ip)
38
40
 
39
41
  result = @detached_agent.calculate_rate_limits(request)
40
42
 
@@ -5,8 +5,9 @@ module Aikido::Zen
5
5
  # Rack middleware used to track request
6
6
  # It implements the logic under that which is considered worthy of being tracked.
7
7
  class RequestTracker
8
- def initialize(app)
8
+ def initialize(app, settings: Aikido::Zen.runtime_settings)
9
9
  @app = app
10
+ @settings = settings
10
11
  end
11
12
 
12
13
  def call(env)
@@ -16,7 +17,8 @@ module Aikido::Zen
16
17
  if request.route && track?(
17
18
  status_code: response[0],
18
19
  route: request.route.path,
19
- http_method: request.request_method
20
+ http_method: request.request_method,
21
+ ip: request.ip
20
22
  )
21
23
  Aikido::Zen.track_request(request)
22
24
 
@@ -126,7 +128,10 @@ module Aikido::Zen
126
128
  # @param status_code [Integer]
127
129
  # @param route [String]
128
130
  # @param http_method [String]
129
- def track?(status_code:, route:, http_method:)
131
+ def track?(status_code:, route:, http_method:, ip: nil)
132
+ # Bypass request and route tracking for allowed IPs
133
+ return false if @settings.allowed_ips.include?(ip)
134
+
130
135
  # In the UI we want to show only successful (2xx) or redirect (3xx) responses
131
136
  # anything else is discarded.
132
137
  return false unless status_code >= 200 && status_code <= 399
@@ -25,13 +25,23 @@ module Aikido::Zen
25
25
  # @return [Integer] the port number to which the connection was attempted.
26
26
  attr_reader :port
27
27
 
28
+ # @return [Integer] the number of times that this connection was seen by
29
+ # the hosts collector.
30
+ attr_reader :hits
31
+
28
32
  def initialize(host:, port:)
29
33
  @host = host
30
34
  @port = port
31
35
  end
32
36
 
37
+ def hit
38
+ # Lazy initialize @hits, so it stays nil until the connection is tracked.
39
+ @hits ||= 0
40
+ @hits += 1
41
+ end
42
+
33
43
  def as_json
34
- {hostname: host, port: port}
44
+ {hostname: host, port: port, hits: hits}.compact
35
45
  end
36
46
 
37
47
  def ==(other)
@@ -12,8 +12,9 @@ module Aikido::Zen
12
12
  initializer "aikido.add_middleware" do |app|
13
13
  app.middleware.insert_before 0, Aikido::Zen::Middleware::ForkDetector
14
14
 
15
- app.middleware.use Aikido::Zen::Middleware::SetContext
16
- app.middleware.use Aikido::Zen::Middleware::CheckAllowedAddresses
15
+ app.middleware.use Aikido::Zen::Middleware::ContextSetter
16
+ app.middleware.use Aikido::Zen::Middleware::AllowedAddressChecker
17
+ app.middleware.use Aikido::Zen::Middleware::AttackWaveProtector
17
18
  # Request Tracker stats do not consider failed request or 40x, so the middleware
18
19
  # must be the last one wrapping the request.
19
20
  app.middleware.use Aikido::Zen::Middleware::RequestTracker
@@ -62,16 +62,31 @@ module Aikido::Zen
62
62
  nil
63
63
  end
64
64
 
65
- private def build_route(route, request, prefix: request.script_name)
65
+ private
66
+
67
+ def build_route(route, request, prefix: request.script_name)
66
68
  route_wrapper = ActionDispatch::Routing::RouteWrapper.new(route)
67
69
 
68
70
  path = if prefix.present?
69
- File.join(prefix.to_s, route_wrapper.path).chomp("/")
71
+ prefix_route_path(prefix.to_s, route_wrapper.path)
70
72
  else
71
73
  route_wrapper.path
72
74
  end
73
75
 
74
76
  Aikido::Zen::Route.new(verb: request.request_method, path: path)
75
77
  end
78
+
79
+ def prefix_route_path(string1, string2)
80
+ # The strings appear to start with "/", allowing them to be concatenated
81
+ # directly after removing trailing "/". However, as it is not currently
82
+ # known whether this is guaranteed, we insert a separator when necessary.
83
+
84
+ separator = string2.start_with?("/") ? "" : "/"
85
+
86
+ string1 = string1.chomp("/")
87
+ string2 = string2.chomp("/")
88
+
89
+ "#{string1}#{separator}#{string2}"
90
+ end
76
91
  end
77
92
  end
@@ -22,13 +22,11 @@ module Aikido::Zen
22
22
  @config = config
23
23
  @framework = framework
24
24
  @router = router
25
- @body_read = false
26
25
  end
27
26
 
28
27
  def __setobj__(delegate) # :nodoc:
29
28
  super
30
- @body_read = false
31
- @route = @normalized_header = @truncated_body = nil
29
+ @route = @normalized_header = nil
32
30
  end
33
31
 
34
32
  # @return [Aikido::Zen::Route] the framework route being requested.
@@ -41,8 +39,6 @@ module Aikido::Zen
41
39
  @schema ||= Aikido::Zen::Request::Schema.build
42
40
  end
43
41
 
44
- # @api private
45
- #
46
42
  # @return [String] the IP address of the client making the request.
47
43
  def client_ip
48
44
  return @client_ip if @client_ip
@@ -74,42 +70,12 @@ module Aikido::Zen
74
70
  }
75
71
  end
76
72
 
77
- # @api private
78
- #
79
- # Reads the first 16KiB of the request body, to include in attack reports
80
- # back to the Aikido server. This method should only be called if an attack
81
- # is detected during the current request.
82
- #
83
- # If the underlying IO object has been partially (or fully) read before,
84
- # this will attempt to restore the previous cursor position after reading it
85
- # if possible, or leave if rewund if not.
86
- #
87
- # @param max_size [Integer] number of bytes to read at most.
88
- #
89
- # @return [String]
90
- def truncated_body(max_size: 16384)
91
- return @truncated_body if @body_read
92
- return nil if body.nil?
93
-
94
- begin
95
- initial_pos = body.pos if body.respond_to?(:pos)
96
- body.rewind
97
- @truncated_body = body.read(max_size)
98
- ensure
99
- @body_read = true
100
- body.rewind
101
- body.seek(initial_pos) if initial_pos && body.respond_to?(:seek)
102
- end
103
- end
104
-
105
73
  def as_json
106
74
  {
107
- method: request_method.downcase,
75
+ method: request_method.upcase,
108
76
  url: url,
109
77
  ipAddress: client_ip,
110
78
  userAgent: user_agent,
111
- headers: normalized_headers.reject { |_, val| val.to_s.empty? },
112
- body: truncated_body,
113
79
  source: framework,
114
80
  route: route&.path
115
81
  }
@@ -39,8 +39,58 @@ module Aikido::Zen
39
39
  [verb, path].hash
40
40
  end
41
41
 
42
+ # Sort routes by wildcard matching order deterministically:
43
+ #
44
+ # 1. Exact path before wildcard path
45
+ # 2. Fewer wildcards in path relative to path length
46
+ # 3. Earliest wildcard position in path
47
+ # 4. Exact verb before wildcard verb
48
+ # 5. Lexicographic path (tie-break)
49
+ # 6. Lexicographic verb (tie-break)
50
+ #
51
+ # @return [Array] the sort key
52
+ def sort_key
53
+ @sort_key ||= begin
54
+ stars = []
55
+ i = -1
56
+ while (i = path.index("*", i + 1))
57
+ stars << i
58
+ end
59
+
60
+ [
61
+ stars.empty? ? 0 : 1,
62
+ stars.length - path.length,
63
+ stars,
64
+ (verb == "*") ? 1 : 0,
65
+ path,
66
+ verb
67
+ ].freeze
68
+ end
69
+ end
70
+
71
+ def match?(other)
72
+ other.is_a?(Route) &&
73
+ pattern(verb).match?(other.verb) &&
74
+ pattern(path).match?(other.path)
75
+ end
76
+
42
77
  def inspect
43
78
  "#<#{self.class.name} #{verb} #{path.inspect}>"
44
79
  end
80
+
81
+ # Construct a regular expression equivalent to the wildcard string,
82
+ # where '*' is the wildcard operator.
83
+ #
84
+ # The resulting pattern matches the entire input, allows an optional
85
+ # trailing slash, and is case-insensitive.
86
+ #
87
+ # All other special characters in the regular expression are escaped
88
+ # so that they are treated literally.
89
+ #
90
+ # @param string [String] wildcard string
91
+ # @return [Regexp] regular expression matching the wildcard string
92
+ private def pattern(string)
93
+ /^#{Regexp.escape(string).gsub("\\*", ".*")}\/?$/i
94
+ end
45
95
  end
46
96
  end
@@ -16,24 +16,53 @@ module Aikido::Zen
16
16
  # @param data [Array<Hash>]
17
17
  # @return [Aikido::Zen::RuntimeSettings::Endpoints]
18
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)
19
+ endpoint_pairs = Array(data).map do |value|
20
+ route = Route.new(verb: value["method"], path: value["route"])
21
+ settings = RuntimeSettings::ProtectionSettings.from_json(value)
22
22
  [route, settings]
23
- }.to_h
23
+ end
24
24
 
25
- new(data)
25
+ # Sort endpoints by wildcard matching order
26
+ endpoint_pairs.sort_by! do |route, settings|
27
+ route.sort_key
28
+ end
29
+
30
+ new(endpoint_pairs.to_h)
26
31
  end
27
32
 
28
- def initialize(data = {})
29
- @endpoints = data
33
+ # @param endpoints [Hash] the endpoints in wildcard matching order
34
+ # @return [Aikido::Zen::RuntimeSettings::Endpoints]
35
+ def initialize(endpoints = {})
36
+ @endpoints = endpoints
30
37
  @endpoints.default = RuntimeSettings::ProtectionSettings.none
31
38
  end
32
39
 
33
40
  # @param route [Aikido::Zen::Route]
34
41
  # @return [Aikido::Zen::RuntimeSettings::ProtectionSettings]
35
42
  def [](route)
36
- @endpoints[route]
43
+ return @endpoints[route] if @endpoints.key?(route)
44
+
45
+ # Wildcard endpoint matching
46
+
47
+ @endpoints.each do |pattern, settings|
48
+ return settings if pattern.match?(route)
49
+ end
50
+
51
+ @endpoints.default
52
+ end
53
+
54
+ # @param route [Aikido::Zen::Route]
55
+ # @return [Array<Aikido::Zen::RuntimeSettings::ProtectionSettings>]
56
+ def match(route)
57
+ matches = []
58
+
59
+ @endpoints.each do |pattern, settings|
60
+ matches << settings if pattern.match?(route)
61
+ end
62
+
63
+ matches << @endpoints.default if matches.empty?
64
+
65
+ matches
37
66
  end
38
67
 
39
68
  # @!visibility private
@@ -11,11 +11,11 @@ module Aikido::Zen
11
11
  #
12
12
  # You can subscribe to changes with +#add_observer(object, func_name)+, which
13
13
  # will call the function passing the settings as an argument.
14
- RuntimeSettings = Struct.new(:updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :skip_protection_for_ips, :received_any_stats) do
14
+ RuntimeSettings = Struct.new(:updated_at, :heartbeat_interval, :endpoints, :blocked_user_ids, :allowed_ips, :received_any_stats, :blocking_mode) do
15
15
  def initialize(*)
16
16
  super
17
17
  self.endpoints ||= RuntimeSettings::Endpoints.new
18
- self.skip_protection_for_ips ||= RuntimeSettings::IPSet.new
18
+ self.allowed_ips ||= RuntimeSettings::IPSet.new
19
19
  end
20
20
 
21
21
  # @!attribute [rw] updated_at
@@ -35,7 +35,7 @@ module Aikido::Zen
35
35
  # @!attribute [rw] blocked_user_ids
36
36
  # @return [Array]
37
37
 
38
- # @!attribute [rw] skip_protection_for_ips
38
+ # @!attribute [rw] allowed_ips
39
39
  # @return [Aikido::Zen::RuntimeSettings::IPSet]
40
40
 
41
41
  # Parse and interpret the JSON response from the core API with updated
@@ -53,8 +53,9 @@ module Aikido::Zen
53
53
  self.heartbeat_interval = data["heartbeatIntervalInMS"].to_i / 1000
54
54
  self.endpoints = RuntimeSettings::Endpoints.from_json(data["endpoints"])
55
55
  self.blocked_user_ids = data["blockedUserIds"]
56
- self.skip_protection_for_ips = RuntimeSettings::IPSet.from_json(data["allowedIPAddresses"])
56
+ self.allowed_ips = RuntimeSettings::IPSet.from_json(data["allowedIPAddresses"])
57
57
  self.received_any_stats = data["receivedAnyStats"]
58
+ self.blocking_mode = data["block"]
58
59
 
59
60
  updated_at != last_updated_at
60
61
  end
@@ -21,14 +21,15 @@ module Aikido::Zen
21
21
  # user input is detected to be attempting a Path Traversal Attack, or +nil+ if not.
22
22
  def self.call(filepath:, sink:, context:, operation:)
23
23
  context.payloads.each do |payload|
24
- next unless new(filepath, payload.value).attack?
24
+ next unless new(filepath, payload.value.to_s).attack?
25
25
 
26
26
  return Attacks::PathTraversalAttack.new(
27
27
  sink: sink,
28
28
  input: payload,
29
29
  filepath: filepath,
30
30
  context: context,
31
- operation: "#{sink.operation}.#{operation}"
31
+ operation: "#{sink.operation}.#{operation}",
32
+ stack: Aikido::Zen.clean_stack_trace
32
33
  )
33
34
  end
34
35
 
@@ -16,14 +16,15 @@ module Aikido::Zen
16
16
  #
17
17
  def self.call(command:, sink:, context:, operation:)
18
18
  context.payloads.each do |payload|
19
- next unless new(command, payload.value).attack?
19
+ next unless new(command, payload.value.to_s).attack?
20
20
 
21
21
  return Attacks::ShellInjectionAttack.new(
22
22
  sink: sink,
23
23
  input: payload,
24
24
  command: command,
25
25
  context: context,
26
- operation: "#{sink.operation}.#{operation}"
26
+ operation: "#{sink.operation}.#{operation}",
27
+ stack: Aikido::Zen.clean_stack_trace
27
28
  )
28
29
  end
29
30
 
@@ -32,7 +32,7 @@ module Aikido::Zen
32
32
  end
33
33
 
34
34
  context.payloads.each do |payload|
35
- next unless new(query, payload.value, dialect).attack?
35
+ next unless new(query, payload.value.to_s, dialect).attack?
36
36
 
37
37
  return Attacks::SQLInjectionAttack.new(
38
38
  sink: sink,
@@ -40,7 +40,8 @@ module Aikido::Zen
40
40
  input: payload,
41
41
  dialect: dialect,
42
42
  context: context,
43
- operation: "#{sink.operation}.#{operation}"
43
+ operation: "#{sink.operation}.#{operation}",
44
+ stack: Aikido::Zen.clean_stack_trace
44
45
  )
45
46
  end
46
47
 
@@ -51,7 +51,8 @@ module Aikido::Zen
51
51
  request: request,
52
52
  input: payload,
53
53
  context: context,
54
- operation: "#{sink.operation}.#{operation}"
54
+ operation: "#{sink.operation}.#{operation}",
55
+ stack: Aikido::Zen.clean_stack_trace
55
56
  )
56
57
 
57
58
  return attack
@@ -20,7 +20,8 @@ module Aikido::Zen
20
20
  address: offending_address,
21
21
  sink: sink,
22
22
  context: context,
23
- operation: "#{sink.operation}.#{operation}"
23
+ operation: "#{sink.operation}.#{operation}",
24
+ stack: Aikido::Zen.clean_stack_trace
24
25
  )
25
26
  end
26
27
 
@@ -44,6 +45,9 @@ module Aikido::Zen
44
45
 
45
46
  DANGEROUS_ADDRESSES = [
46
47
  IPAddr.new("169.254.169.254"),
48
+ IPAddr.new("100.100.100.200"),
49
+ IPAddr.new("::ffff:169.254.169.254"),
50
+ IPAddr.new("::ffff:100.100.100.200"),
47
51
  IPAddr.new("fd00:ec2::254")
48
52
  ]
49
53
  end
@@ -43,8 +43,10 @@ module Aikido::Zen
43
43
  end
44
44
 
45
45
  private def should_throttle?(request)
46
+ # Bypass rate limiting for allowed IPs
47
+ return false if @settings.allowed_ips.include?(request.ip)
48
+
46
49
  return false unless @settings.endpoints[request.route].rate_limiting.enabled?
47
- return false if @settings.skip_protection_for_ips.include?(request.ip)
48
50
 
49
51
  result = @detached_agent.calculate_rate_limits(request)
50
52
  return false unless result
@@ -82,38 +82,40 @@ module Aikido::Zen
82
82
  end
83
83
 
84
84
  def join(*args, **kwargs, &blk)
85
- # IMPORTANT: THE BEHAVIOR OF THIS METHOD IS CHANGED!
86
- #
87
- # File.join has undocumented behavior:
88
- #
89
- # File.join recursively joins nested string arrays.
90
- #
91
- # This prevents path traversal detection when an array originates
92
- # from user input that was assumed to be a string.
93
- #
94
- # This undocumented behavior has been restricted to support path
95
- # traversal detection.
96
- #
97
- # File.join no longer joins nested string arrays, but still accepts
98
- # a single string array argument.
99
-
100
- # File.join is often incorrectly called with a single array argument.
101
- #
102
- # i.e.
103
- #
104
- # File.join(["prefix", "filename"])
105
- #
106
- # This is considered acceptable.
107
- #
108
- # Calling File.join with a single string argument returns the string
109
- # argument itself, having no practical effect. Therefore, it can be
110
- # presumed that if File.join is called with a single array argument
111
- # then this was its intended usage, and the array did not originate
112
- # from user input that was assumed to be a string.
113
- strings = args
114
- strings = args.first if args.size == 1 && args.first.is_a?(Array)
115
- strings.each do |string|
116
- raise TypeError.new("Zen prevented implicit conversion of Array to String") if string.is_a?(Array)
85
+ if Aikido::Zen.config.harden?
86
+ # IMPORTANT: THE BEHAVIOR OF THIS METHOD IS CHANGED!
87
+ #
88
+ # File.join has undocumented behavior:
89
+ #
90
+ # File.join recursively joins nested string arrays.
91
+ #
92
+ # This prevents path traversal detection when an array originates
93
+ # from user input that was assumed to be a string.
94
+ #
95
+ # This undocumented behavior has been restricted to support path
96
+ # traversal detection.
97
+ #
98
+ # File.join no longer joins nested string arrays, but still accepts
99
+ # a single string array argument.
100
+
101
+ # File.join is often incorrectly called with a single array argument.
102
+ #
103
+ # i.e.
104
+ #
105
+ # File.join(["prefix", "filename"])
106
+ #
107
+ # This is considered acceptable.
108
+ #
109
+ # Calling File.join with a single string argument returns the string
110
+ # argument itself, having no practical effect. Therefore, it can be
111
+ # presumed that if File.join is called with a single array argument
112
+ # then this was its intended usage, and the array did not originate
113
+ # from user input that was assumed to be a string.
114
+ strings = args
115
+ strings = args.first if args.size == 1 && args.first.is_a?(Array)
116
+ strings.each do |string|
117
+ raise TypeError.new("Zen prevented implicit conversion of Array to String in hardened method. Visit https://github.com/AikidoSec/firewall-ruby for more information.") if string.is_a?(Array)
118
+ end
117
119
  end
118
120
 
119
121
  result = join__internal_for_aikido_zen(*args, **kwargs, &blk)
@@ -68,6 +68,13 @@ module Aikido::Zen
68
68
  # :nocov:
69
69
  Helpers.scan(remote_host, socket, "open")
70
70
  # :nocov:
71
+ rescue Aikido::Zen::UnderAttackError, Aikido::Zen::Sinks::DSL::PresafeError
72
+ # If the scan raises an exception that will escape the safe block,
73
+ # the open socket must be closed because it will not be returned,
74
+ # so the user cannot close it.
75
+ socket.close
76
+
77
+ raise
71
78
  end
72
79
  end
73
80
  end
@@ -8,12 +8,8 @@ require_relative "package"
8
8
  module Aikido::Zen
9
9
  # Provides information about the currently running Agent.
10
10
  class SystemInfo
11
- def initialize(config = Aikido::Zen.config)
12
- @config = config
13
- end
14
-
15
11
  def attacks_block_requests?
16
- !!@config.blocking_mode
12
+ !!Aikido::Zen.blocking_mode?
17
13
  end
18
14
 
19
15
  def attacks_are_only_reported?
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "1.0.2.beta.9"
5
+ VERSION = "1.0.2"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
8
  LIBZEN_VERSION = "0.1.48"