threatstack-agent-ruby 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
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