threatstack-agent-ruby 0.2.1

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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +3 -0
  3. data/LICENSE +6 -0
  4. data/ext/libinjection/extconf.rb +4 -0
  5. data/ext/libinjection/libinjection.h +65 -0
  6. data/ext/libinjection/libinjection.i +13 -0
  7. data/ext/libinjection/libinjection_html5.c +850 -0
  8. data/ext/libinjection/libinjection_html5.h +54 -0
  9. data/ext/libinjection/libinjection_sqli.c +2325 -0
  10. data/ext/libinjection/libinjection_sqli.h +298 -0
  11. data/ext/libinjection/libinjection_sqli_data.h +9654 -0
  12. data/ext/libinjection/libinjection_wrap.c +2393 -0
  13. data/ext/libinjection/libinjection_xss.c +532 -0
  14. data/ext/libinjection/libinjection_xss.h +21 -0
  15. data/lib/constants.rb +110 -0
  16. data/lib/control.rb +61 -0
  17. data/lib/events/event_accumulator.rb +36 -0
  18. data/lib/events/models/attack_event.rb +58 -0
  19. data/lib/events/models/base_event.rb +41 -0
  20. data/lib/events/models/dependency_event.rb +93 -0
  21. data/lib/events/models/environment_event.rb +93 -0
  22. data/lib/events/models/instrumentation_event.rb +46 -0
  23. data/lib/exceptions/request_blocked_error.rb +11 -0
  24. data/lib/instrumentation/common.rb +172 -0
  25. data/lib/instrumentation/instrumenter.rb +144 -0
  26. data/lib/instrumentation/kernel.rb +45 -0
  27. data/lib/instrumentation/rails.rb +61 -0
  28. data/lib/jobs/delayed_job.rb +26 -0
  29. data/lib/jobs/event_submitter.rb +101 -0
  30. data/lib/jobs/job_queue.rb +38 -0
  31. data/lib/jobs/recurrent_job.rb +61 -0
  32. data/lib/threatstack-agent-ruby.rb +7 -0
  33. data/lib/utils/aws_utils.rb +46 -0
  34. data/lib/utils/formatter.rb +47 -0
  35. data/lib/utils/logger.rb +43 -0
  36. data/threatstack-agent-ruby.gemspec +35 -0
  37. metadata +221 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './base_event'
4
+
5
+ module Threatstack
6
+ module Events
7
+
8
+ # Instrumentation event model that inherits the common attributes and adds its own specifics
9
+ class InstrumentationEvent < BaseEvent
10
+ attr_accessor :module_name
11
+ attr_accessor :method_name
12
+ attr_accessor :file_path
13
+ attr_accessor :line_num
14
+ attr_accessor :arguments
15
+
16
+ # @param [Hash] args
17
+ # [String] args.module_name
18
+ # [String] args.method_name
19
+ # [String] args.file_path
20
+ # [Integer] args.line_num
21
+ # [Array] args.arguments
22
+ def initialize(args)
23
+ args[:event_type] = 'instrumentation'
24
+ @module_name = args[:module_name]
25
+ @method_name = args[:method_name]
26
+ @file_path = args[:file_path]
27
+ @line_num = args[:line_num]
28
+ @arguments = args[:arguments]
29
+ super args
30
+ end
31
+
32
+ def to_hash
33
+ hash = to_core_hash
34
+ hash[:module_name] = @module_name
35
+ hash[:method_name] = @method_name
36
+ hash[:line_num] = @line_num
37
+ hash[:file_path] = @file_path
38
+ hash[:payload] = {
39
+ :arguments => @arguments
40
+ }
41
+ hash
42
+ end
43
+ end
44
+
45
+ end
46
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Threatstack
4
+ module Exceptions
5
+
6
+ # Custom exception class used when a client request is blocked by the agent
7
+ class RequestBlockedError < StandardError
8
+ end
9
+
10
+ end
11
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../ext/libinjection/libinjection'
4
+ require_relative '../events/models/instrumentation_event'
5
+ require_relative '../events/models/attack_event'
6
+ require_relative '../jobs/event_submitter'
7
+ require_relative '../utils/logger'
8
+ require_relative '../constants'
9
+
10
+ module Threatstack
11
+ module Instrumentation
12
+ include Threatstack::Constants
13
+
14
+ @@logger = Threatstack::Utils::TSLogger.create 'CommonInstrumentation'
15
+ @@submitter = Threatstack::Jobs::EventSubmitter.instance
16
+
17
+ def self.create_instrumentation_event(module_name, method_name, file_path, line_num, arguments)
18
+ data = {
19
+ :module_name => module_name,
20
+ :method_name => method_name,
21
+ :file_path => file_path,
22
+ :line_num => line_num,
23
+ :arguments => drop_sensitive_fields(arguments)
24
+ }
25
+ @@logger.debug "Creating instrumentation event with data: #{data}"
26
+ # create and submit the attack event
27
+ @@submitter.queue_event Threatstack::Events::InstrumentationEvent.new(drop_sensitive_fields(data))
28
+ end
29
+
30
+ def self.create_attack_event(payload, type, location, request, headers, backtrace)
31
+ is_blocked = (type == SQLI && BLOCK_SQLI) || (type == XSS && BLOCK_XSS)
32
+ data = {
33
+ :timestamp => Time.now.utc.strftime('%FT%T.%3NZ'),
34
+ :module_name => AGENT_NAME,
35
+ :request_ip => request.remote_ip,
36
+ :request_headers => headers,
37
+ :request_url => request.path,
38
+ :request_method => request.request_method,
39
+ :attack_message => is_blocked ? REQUEST_BLOCKED : DETECTED_NOT_BLOCKED,
40
+ :attack_stack => backtrace,
41
+ :attack_details => {
42
+ # TODO: get signature from libinjection
43
+ :details => [{ :signature => nil, :value => drop_sensitive_fields(payload) }],
44
+ :in => location,
45
+ :type => type,
46
+ :isBlocked => is_blocked,
47
+ :action => 'process_action'
48
+ }
49
+ }
50
+ @@logger.debug "Creating attack event with data: #{data}"
51
+ # create and submit the attack event
52
+ @@submitter.queue_event Threatstack::Events::AttackEvent.new(drop_sensitive_fields(data))
53
+ end
54
+
55
+ def self.const_exist?(name)
56
+ resolve_const(name) && true
57
+ rescue NameError, ArgumentError
58
+ false
59
+ end
60
+
61
+ def self.resolve_const(name)
62
+ raise ArgumentError if name.nil? || name.empty?
63
+
64
+ name.to_s.split('::').inject(Object) { |a, e| a.const_get(e) }
65
+ end
66
+
67
+ def self.flatten(obj)
68
+ if obj.is_a?(Hash)
69
+ obj.each_with_object({}) do |(k, v), h|
70
+ if v.is_a?(Hash) || v.is_a?(Array)
71
+ flatten(v).map do |h_k, h_v|
72
+ h["#{k}.#{h_k}".to_sym] = h_v
73
+ end
74
+ else
75
+ h[k] = v
76
+ end
77
+ end
78
+ elsif obj.is_a?(Array)
79
+ h = {}
80
+ obj.each_with_index do |v, index|
81
+ if v.is_a?(Hash) || v.is_a?(Array)
82
+ flatten(v).map do |h_k, h_v|
83
+ h["#{index}.#{h_k}".to_sym] = h_v
84
+ end
85
+ else
86
+ h[index.to_s] = v
87
+ end
88
+ end
89
+ h
90
+ else
91
+ obj
92
+ end
93
+ end
94
+
95
+ def self.drop_sensitive_fields(obj)
96
+ return obj if DROP_FIELDS.nil?
97
+
98
+ if obj.is_a?(Hash)
99
+ obj.each_with_object({}) do |(k, v), h|
100
+ if DROP_FIELDS[k] || DROP_FIELDS[k.to_s]
101
+ h[k] = REDACTED
102
+ next
103
+ end
104
+
105
+ if v.is_a?(Hash) || v.is_a?(Array)
106
+ h[k] = drop_sensitive_fields(v)
107
+ else
108
+ h[k] = v
109
+ end
110
+ end
111
+ elsif obj.is_a?(Array)
112
+ obj.each_with_object([]) do |v, arr|
113
+ if v.is_a?(Hash) || v.is_a?(Array)
114
+ arr.push drop_sensitive_fields(v)
115
+ else
116
+ arr.push v
117
+ end
118
+ end
119
+ else
120
+ obj
121
+ end
122
+ end
123
+
124
+ def self.check_sqli_payload(param, name = nil)
125
+ if param.nil? || !param.kind_of?(String)
126
+ @@logger.debug "SQLI Check skipped for: #{name}" unless name.nil?
127
+ return false
128
+ end
129
+ match = (Libinjection.libinjection_sqli(param, param.length, '') === 1 ? true : false)
130
+ @@logger.send(match ? :warn : :debug, "SQLI Check #{match ? 'positive' : 'negative'} for: #{name}") unless name.nil?
131
+ match
132
+ end
133
+
134
+ def self.check_xss_payload(param, name = nil)
135
+ if param.nil? || !param.kind_of?(String)
136
+ @@logger.debug "XSS Check skipped for: #{name}" unless name.nil?
137
+ return false
138
+ end
139
+
140
+ match = (Libinjection.libinjection_xss(param, param.length) === 1 ? true : false)
141
+ @@logger.send(match ? :warn : :debug, "XSS Check #{match ? 'positive' : 'negative'} for: #{name}") unless name.nil?
142
+ match
143
+ end
144
+
145
+ def self.check_parameters(params, location, request, headers, backtrace, include_payload = true)
146
+ if params.nil? || !params.is_a?(Hash)
147
+ @@logger.debug "Skipping param check for: #{params}"
148
+ return nil
149
+ end
150
+
151
+ # flatten hash for easier checking
152
+ flattened = flatten params
153
+
154
+ # check each parameter value for dangerous payloads
155
+ sqli_found, xss_found = false, false
156
+ flattened.each do |key, val|
157
+ sqli_found = check_sqli_payload(val, key) unless sqli_found
158
+ xss_found = check_xss_payload(val, key) unless xss_found
159
+ end
160
+
161
+ # whether or not to include the payload in the event
162
+ payload = include_payload ? params : nil
163
+
164
+ # create the according attack event if the checks above returned positive
165
+ create_attack_event(payload, SQLI, location, request, headers, backtrace) if sqli_found
166
+ create_attack_event(payload, XSS, location, request, headers, backtrace) if xss_found
167
+
168
+ # return results
169
+ { :sqli => sqli_found, :xss => xss_found }
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../utils/logger'
4
+ require_relative '../jobs/job_queue'
5
+
6
+ module Threatstack
7
+ module Instrumentation
8
+ class Instrumenter
9
+ include Singleton
10
+
11
+ @@logger = Threatstack::Utils::TSLogger.create 'Instrumenter'
12
+
13
+ if RUBY_VERSION < '1.9'
14
+ def normalize_method_name(method)
15
+ method.to_s
16
+ end
17
+ else
18
+ def normalize_method_name(method)
19
+ method
20
+ end
21
+ end
22
+
23
+ def self.define_callback(klass, method, &block)
24
+ backup_name = get_backup_name method
25
+ outer_block = block
26
+ Proc.new do |*args, &block|
27
+ @@logger.debug "Wrapped method called: #{klass}.#{method}"
28
+ caller_loc = caller_locations(1, 10)
29
+ if outer_block
30
+ # exec callback
31
+ outer_block.call(:caller_loc => caller_loc, :args => args, :method_name => method, :target_class => klass)
32
+ end
33
+ __send__(backup_name, *args, &block)
34
+ end
35
+ end
36
+
37
+ def wrap_method(klass, method, &block)
38
+ if is_class_method?(klass, method)
39
+ wrap_class_method(klass, method, &block)
40
+ elsif is_instance_method?(klass, method)
41
+ @@logger.debug "Wrapping instance method: #{klass}.#{method}"
42
+ wrap_instance_method(klass, method, &block)
43
+ else
44
+ raise "#{klass}.#{method} is not a class nor instance method"
45
+ end
46
+ end
47
+
48
+ def wrap_class_method(klass, method, &block)
49
+ @@logger.debug "Wrapping class method: #{klass}.#{method}"
50
+ original_name = method.to_sym
51
+ backup_name = get_backup_name method
52
+ wrapped_name = get_wrapped_name method
53
+
54
+ klass.singleton_class.instance_eval do
55
+ alias_method backup_name, original_name
56
+
57
+ p = Instrumenter.define_callback(klass, method, &block)
58
+ define_method(wrapped_name, p)
59
+
60
+ private wrapped_name
61
+
62
+ method_kind = nil
63
+ if public_method_defined? original_name
64
+ method_kind = :public
65
+ elsif protected_method_defined? original_name
66
+ method_kind = :protected
67
+ elsif private_method_defined? original_name
68
+ method_kind = :private
69
+ end
70
+
71
+ alias_method original_name, wrapped_name
72
+ __send__(method_kind, original_name)
73
+ private backup_name
74
+ end
75
+ end
76
+
77
+ def wrap_instance_method(klass, method, &block)
78
+ @@logger.debug "Wrapping instance method: #{klass}.#{method}"
79
+ backup_name = get_backup_name method
80
+ wrapped_name = get_wrapped_name method
81
+
82
+ private_methods = klass.private_instance_methods(false)
83
+ if private_methods.include?(backup_name)
84
+ @@logger.debug "#{klass}.#{method} already instrumented"
85
+ return backup_name
86
+ end
87
+
88
+ p = Instrumenter.define_callback(klass, method, &block)
89
+ visibility = nil
90
+ klass.class_eval do
91
+ alias_method backup_name, method
92
+
93
+ define_method(wrapped_name, p)
94
+
95
+ if public_method_defined?(method)
96
+ visibility = :public
97
+ elsif protected_method_defined?(method)
98
+ visibility = :protected
99
+ elsif private_method_defined?(method)
100
+ visibility = :private
101
+ end
102
+
103
+ alias_method method, wrapped_name
104
+ private backup_name
105
+ private wrapped_name
106
+ __send__(visibility, method)
107
+ end
108
+ backup_name
109
+ end
110
+
111
+ def self.get_backup_name(method, suffix = nil)
112
+ "ts_#{method}_backup#{suffix ? "_#{suffix}" : ''}".to_sym
113
+ end
114
+
115
+ def self.get_wrapped_name(method, suffix = nil)
116
+ "ts_#{method}_wrapped#{suffix ? "_#{suffix}" : ''}".to_sym
117
+ end
118
+
119
+ def get_backup_name(method, suffix = nil)
120
+ Instrumenter.get_backup_name(method, suffix)
121
+ end
122
+
123
+ def get_wrapped_name(method, suffix = nil)
124
+ Instrumenter.get_wrapped_name(method, suffix)
125
+ end
126
+
127
+ def is_instance_method?(klass, method)
128
+ method = normalize_method_name(method)
129
+ klass.instance_methods.include?(method) || klass.private_instance_methods.include?(method)
130
+ end
131
+
132
+ def is_class_method?(klass, method)
133
+ method = normalize_method_name(method)
134
+ klass.singleton_methods.include? method
135
+ end
136
+
137
+ def method_exists?(obj, method)
138
+ return true if is_class_method?(obj, method)
139
+ return false unless obj.respond_to?(:instance_methods)
140
+ is_instance_method?(obj, method)
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './common.rb'
4
+ require_relative './instrumenter.rb'
5
+ require_relative '../utils/logger'
6
+
7
+ module Threatstack
8
+ module Instrumentation
9
+ module TSKernel
10
+ @@logger = Threatstack::Utils::TSLogger.create 'KernelInstrumentation'
11
+
12
+ # methods to wrap
13
+ METHOD_NAMES = ['exec', 'system', '`'].freeze
14
+
15
+ def self.wrap_methods
16
+ # executed every time a wrapped method is called
17
+ on_method_call = Proc.new do |params|
18
+ module_name = params[:target_class].name.downcase
19
+ method_name = params[:method_name].downcase
20
+ called_by = params[:caller_loc] ? params[:caller_loc].first : nil
21
+ file_path = called_by ? called_by.absolute_path : nil
22
+ # special case for ` method emulation
23
+ if method_name == '`' && !file_path.nil? && file_path =~ /.*\/kernel\/agnostics\.rb$/
24
+ called_by = params[:caller_loc][1]
25
+ file_path = called_by ? called_by.absolute_path : nil
26
+ end
27
+ line_num = called_by ? called_by.lineno : nil
28
+
29
+ arg = params[:args] ? params[:args].first : nil
30
+ args = arg ? [arg] : []
31
+
32
+ # create and queue the event
33
+ Threatstack::Instrumentation.create_instrumentation_event(module_name, method_name, file_path, line_num, args)
34
+ end
35
+ @@logger.info "Instrumenting Kernel methods: #{METHOD_NAMES}"
36
+ instrumenter = Threatstack::Instrumentation::Instrumenter.instance
37
+ METHOD_NAMES.each do |method_name|
38
+ instrumenter.wrap_class_method(Kernel, method_name, &on_method_call)
39
+ instrumenter.wrap_instance_method(Kernel, method_name, &on_method_call)
40
+ end
41
+ end
42
+
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative '../constants'
6
+ require_relative '../exceptions/request_blocked_error'
7
+ require_relative './common'
8
+ require_relative '../utils/logger'
9
+
10
+ module Threatstack
11
+ module Instrumentation
12
+ module TSRails
13
+ @@logger = Threatstack::Utils::TSLogger.create 'RailsInstrumentation'
14
+
15
+ def self.patch_action_controller
16
+ @@logger.info 'Looking for Rails gem'
17
+ return unless defined?(::Rails) && defined?(::Rails::VERSION)
18
+ return unless defined?(ActionController) && defined?(ActionController::Base)
19
+
20
+ @@logger.info "Rails #{Rails::VERSION::MAJOR.to_s} gem found, instrumenting ActionController"
21
+ ActionController::Base.class_eval do
22
+ include Threatstack::Instrumentation::TSRails::TSActionController
23
+ end
24
+ @@logger.info 'Rails instrumentation done'
25
+ end
26
+
27
+ module TSActionController
28
+ include Threatstack::Constants
29
+ @@logger = Threatstack::Utils::TSLogger.create 'RailsInstrumentation'
30
+
31
+ def process_action(*args)
32
+ # we need the headers hack below because Rails adds a lot of internal headers
33
+ headers = request.headers.each_with_object({}) do |(k, v), obj|
34
+ obj[k] = v if k.in?(CGI_VARIABLES) || k =~ /^HTTP_/
35
+ end
36
+ @@logger.debug("Incoming request: #{{ :headers => headers, :path => request.path_parameters,
37
+ :query => Threatstack::Instrumentation.drop_sensitive_fields(request.query_parameters),
38
+ :body => Threatstack::Instrumentation.drop_sensitive_fields(request.request_parameters) }}")
39
+ backtrace = caller.join("\n")
40
+
41
+ # check path/query/body parameters
42
+ path_res = Threatstack::Instrumentation.check_parameters(request.path_parameters, 'path', request, headers, backtrace)
43
+ query_res = Threatstack::Instrumentation.check_parameters(request.query_parameters, 'query', request, headers, backtrace)
44
+ body_res = Threatstack::Instrumentation.check_parameters(request.request_parameters, 'body', request, headers, backtrace)
45
+
46
+ @@logger.debug "RequestStats -- Path: #{path_res}, Query: #{query_res}, Body: #{body_res}"
47
+ sqli_found = (path_res[:sqli] || query_res[:sqli] || body_res[:sqli])
48
+ xss_found = (path_res[:xss] || query_res[:xss] || body_res[:xss])
49
+ # raise an exception if any attack payloads were detected and blocking is enabled
50
+ if (BLOCK_SQLI && sqli_found) || (BLOCK_XSS && xss_found)
51
+ raise Threatstack::Exceptions::RequestBlockedError, REQUEST_BLOCKED
52
+ end
53
+
54
+ # continue processing the request normally if no issues were found
55
+ super
56
+ end
57
+
58
+ end
59
+ end
60
+ end
61
+ end