immunio 0.15.2

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 (157) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +234 -0
  3. data/README.md +147 -0
  4. data/bin/immunio +5 -0
  5. data/lib/immunio.rb +29 -0
  6. data/lib/immunio/agent.rb +260 -0
  7. data/lib/immunio/authentication.rb +96 -0
  8. data/lib/immunio/blocked_app.rb +38 -0
  9. data/lib/immunio/channel.rb +432 -0
  10. data/lib/immunio/cli.rb +39 -0
  11. data/lib/immunio/context.rb +114 -0
  12. data/lib/immunio/errors.rb +43 -0
  13. data/lib/immunio/immunio_ca.crt +45 -0
  14. data/lib/immunio/logger.rb +87 -0
  15. data/lib/immunio/plugins/action_dispatch.rb +45 -0
  16. data/lib/immunio/plugins/action_view.rb +431 -0
  17. data/lib/immunio/plugins/active_record.rb +707 -0
  18. data/lib/immunio/plugins/active_record_relation.rb +370 -0
  19. data/lib/immunio/plugins/authlogic.rb +80 -0
  20. data/lib/immunio/plugins/csrf.rb +24 -0
  21. data/lib/immunio/plugins/devise.rb +40 -0
  22. data/lib/immunio/plugins/environment_reporter.rb +69 -0
  23. data/lib/immunio/plugins/eval.rb +51 -0
  24. data/lib/immunio/plugins/exception_handler.rb +55 -0
  25. data/lib/immunio/plugins/gems_tracker.rb +5 -0
  26. data/lib/immunio/plugins/haml.rb +36 -0
  27. data/lib/immunio/plugins/http_finisher.rb +50 -0
  28. data/lib/immunio/plugins/http_tracker.rb +203 -0
  29. data/lib/immunio/plugins/io.rb +96 -0
  30. data/lib/immunio/plugins/redirect.rb +42 -0
  31. data/lib/immunio/plugins/warden.rb +66 -0
  32. data/lib/immunio/processor.rb +234 -0
  33. data/lib/immunio/rails.rb +26 -0
  34. data/lib/immunio/request.rb +139 -0
  35. data/lib/immunio/rufus_lua_ext/ref.rb +27 -0
  36. data/lib/immunio/rufus_lua_ext/state.rb +157 -0
  37. data/lib/immunio/rufus_lua_ext/table.rb +137 -0
  38. data/lib/immunio/rufus_lua_ext/utils.rb +13 -0
  39. data/lib/immunio/version.rb +5 -0
  40. data/lib/immunio/vm.rb +291 -0
  41. data/lua-hooks/ext/all.c +78 -0
  42. data/lua-hooks/ext/bitop/README +22 -0
  43. data/lua-hooks/ext/bitop/bit.c +189 -0
  44. data/lua-hooks/ext/extconf.rb +38 -0
  45. data/lua-hooks/ext/libinjection/COPYING +37 -0
  46. data/lua-hooks/ext/libinjection/libinjection.h +65 -0
  47. data/lua-hooks/ext/libinjection/libinjection_html5.c +847 -0
  48. data/lua-hooks/ext/libinjection/libinjection_html5.h +54 -0
  49. data/lua-hooks/ext/libinjection/libinjection_sqli.c +2301 -0
  50. data/lua-hooks/ext/libinjection/libinjection_sqli.h +295 -0
  51. data/lua-hooks/ext/libinjection/libinjection_sqli_data.h +9349 -0
  52. data/lua-hooks/ext/libinjection/libinjection_xss.c +531 -0
  53. data/lua-hooks/ext/libinjection/libinjection_xss.h +21 -0
  54. data/lua-hooks/ext/libinjection/lualib.c +109 -0
  55. data/lua-hooks/ext/lpeg/HISTORY +90 -0
  56. data/lua-hooks/ext/lpeg/lpcap.c +537 -0
  57. data/lua-hooks/ext/lpeg/lpcap.h +43 -0
  58. data/lua-hooks/ext/lpeg/lpcode.c +986 -0
  59. data/lua-hooks/ext/lpeg/lpcode.h +34 -0
  60. data/lua-hooks/ext/lpeg/lpeg-128.gif +0 -0
  61. data/lua-hooks/ext/lpeg/lpeg.html +1429 -0
  62. data/lua-hooks/ext/lpeg/lpprint.c +244 -0
  63. data/lua-hooks/ext/lpeg/lpprint.h +35 -0
  64. data/lua-hooks/ext/lpeg/lptree.c +1238 -0
  65. data/lua-hooks/ext/lpeg/lptree.h +77 -0
  66. data/lua-hooks/ext/lpeg/lptypes.h +149 -0
  67. data/lua-hooks/ext/lpeg/lpvm.c +355 -0
  68. data/lua-hooks/ext/lpeg/lpvm.h +58 -0
  69. data/lua-hooks/ext/lpeg/makefile +55 -0
  70. data/lua-hooks/ext/lpeg/re.html +498 -0
  71. data/lua-hooks/ext/lpeg/test.lua +1409 -0
  72. data/lua-hooks/ext/lua-cmsgpack/CMakeLists.txt +45 -0
  73. data/lua-hooks/ext/lua-cmsgpack/README.md +115 -0
  74. data/lua-hooks/ext/lua-cmsgpack/lua_cmsgpack.c +957 -0
  75. data/lua-hooks/ext/lua-cmsgpack/test.lua +570 -0
  76. data/lua-hooks/ext/lua-snapshot/LICENSE +7 -0
  77. data/lua-hooks/ext/lua-snapshot/Makefile +12 -0
  78. data/lua-hooks/ext/lua-snapshot/README.md +18 -0
  79. data/lua-hooks/ext/lua-snapshot/dump.lua +15 -0
  80. data/lua-hooks/ext/lua-snapshot/snapshot.c +455 -0
  81. data/lua-hooks/ext/lua/COPYRIGHT +34 -0
  82. data/lua-hooks/ext/lua/lapi.c +1087 -0
  83. data/lua-hooks/ext/lua/lapi.h +16 -0
  84. data/lua-hooks/ext/lua/lauxlib.c +652 -0
  85. data/lua-hooks/ext/lua/lauxlib.h +174 -0
  86. data/lua-hooks/ext/lua/lbaselib.c +659 -0
  87. data/lua-hooks/ext/lua/lcode.c +831 -0
  88. data/lua-hooks/ext/lua/lcode.h +76 -0
  89. data/lua-hooks/ext/lua/ldblib.c +398 -0
  90. data/lua-hooks/ext/lua/ldebug.c +638 -0
  91. data/lua-hooks/ext/lua/ldebug.h +33 -0
  92. data/lua-hooks/ext/lua/ldo.c +519 -0
  93. data/lua-hooks/ext/lua/ldo.h +57 -0
  94. data/lua-hooks/ext/lua/ldump.c +164 -0
  95. data/lua-hooks/ext/lua/lfunc.c +174 -0
  96. data/lua-hooks/ext/lua/lfunc.h +34 -0
  97. data/lua-hooks/ext/lua/lgc.c +710 -0
  98. data/lua-hooks/ext/lua/lgc.h +110 -0
  99. data/lua-hooks/ext/lua/linit.c +38 -0
  100. data/lua-hooks/ext/lua/liolib.c +556 -0
  101. data/lua-hooks/ext/lua/llex.c +463 -0
  102. data/lua-hooks/ext/lua/llex.h +81 -0
  103. data/lua-hooks/ext/lua/llimits.h +128 -0
  104. data/lua-hooks/ext/lua/lmathlib.c +263 -0
  105. data/lua-hooks/ext/lua/lmem.c +86 -0
  106. data/lua-hooks/ext/lua/lmem.h +49 -0
  107. data/lua-hooks/ext/lua/loadlib.c +705 -0
  108. data/lua-hooks/ext/lua/loadlib_rel.c +760 -0
  109. data/lua-hooks/ext/lua/lobject.c +214 -0
  110. data/lua-hooks/ext/lua/lobject.h +381 -0
  111. data/lua-hooks/ext/lua/lopcodes.c +102 -0
  112. data/lua-hooks/ext/lua/lopcodes.h +268 -0
  113. data/lua-hooks/ext/lua/loslib.c +243 -0
  114. data/lua-hooks/ext/lua/lparser.c +1339 -0
  115. data/lua-hooks/ext/lua/lparser.h +82 -0
  116. data/lua-hooks/ext/lua/lstate.c +214 -0
  117. data/lua-hooks/ext/lua/lstate.h +169 -0
  118. data/lua-hooks/ext/lua/lstring.c +111 -0
  119. data/lua-hooks/ext/lua/lstring.h +31 -0
  120. data/lua-hooks/ext/lua/lstrlib.c +871 -0
  121. data/lua-hooks/ext/lua/ltable.c +588 -0
  122. data/lua-hooks/ext/lua/ltable.h +40 -0
  123. data/lua-hooks/ext/lua/ltablib.c +287 -0
  124. data/lua-hooks/ext/lua/ltm.c +75 -0
  125. data/lua-hooks/ext/lua/ltm.h +54 -0
  126. data/lua-hooks/ext/lua/lua.c +392 -0
  127. data/lua-hooks/ext/lua/lua.def +131 -0
  128. data/lua-hooks/ext/lua/lua.h +388 -0
  129. data/lua-hooks/ext/lua/lua.rc +28 -0
  130. data/lua-hooks/ext/lua/lua_dll.rc +26 -0
  131. data/lua-hooks/ext/lua/luac.c +200 -0
  132. data/lua-hooks/ext/lua/luac.rc +1 -0
  133. data/lua-hooks/ext/lua/luaconf.h +763 -0
  134. data/lua-hooks/ext/lua/luaconf.h.in +724 -0
  135. data/lua-hooks/ext/lua/luaconf.h.orig +763 -0
  136. data/lua-hooks/ext/lua/lualib.h +53 -0
  137. data/lua-hooks/ext/lua/lundump.c +227 -0
  138. data/lua-hooks/ext/lua/lundump.h +36 -0
  139. data/lua-hooks/ext/lua/lvm.c +767 -0
  140. data/lua-hooks/ext/lua/lvm.h +36 -0
  141. data/lua-hooks/ext/lua/lzio.c +82 -0
  142. data/lua-hooks/ext/lua/lzio.h +67 -0
  143. data/lua-hooks/ext/lua/print.c +227 -0
  144. data/lua-hooks/ext/luautf8/README.md +152 -0
  145. data/lua-hooks/ext/luautf8/lutf8lib.c +1274 -0
  146. data/lua-hooks/ext/luautf8/unidata.h +3064 -0
  147. data/lua-hooks/lib/boot.lua +254 -0
  148. data/lua-hooks/lib/encode.lua +4 -0
  149. data/lua-hooks/lib/lexers/LICENSE +21 -0
  150. data/lua-hooks/lib/lexers/bash.lua +134 -0
  151. data/lua-hooks/lib/lexers/bash_dqstr.lua +62 -0
  152. data/lua-hooks/lib/lexers/css.lua +216 -0
  153. data/lua-hooks/lib/lexers/html.lua +106 -0
  154. data/lua-hooks/lib/lexers/javascript.lua +68 -0
  155. data/lua-hooks/lib/lexers/lexer.lua +1575 -0
  156. data/lua-hooks/lib/lexers/markers.lua +33 -0
  157. metadata +308 -0
@@ -0,0 +1,707 @@
1
+ require_relative "../context"
2
+ require_relative "active_record_relation.rb"
3
+
4
+ module Immunio
5
+ # Since every value that will be escaped is very likely to be param passed to a SQL query,
6
+ # we hook to the method escaping the values.
7
+ #
8
+ # Params are then sent to the QueryTracker which will take care of matching the params to the query.
9
+ module QuotingHooks
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ alias_method_chain :quote, :immunio if method_defined? :quote
14
+ end
15
+
16
+ IGNORED_TYPES = [TrueClass, FalseClass, NilClass, Fixnum, Bignum, Float].freeze
17
+
18
+ def quote_with_immunio(value, column = nil)
19
+ Request.time "plugin", "#{Module.nesting[0]}::#{__method__}" do
20
+ if column
21
+ column_name = column.name
22
+ else
23
+ column_name = nil
24
+ end
25
+
26
+ # Ignored empty strings and values that can't contain injections.
27
+ unless value.blank? || IGNORED_TYPES.include?(value.class)
28
+ QueryTracker.instance.add_param column_name, value.to_s, object_id
29
+ end
30
+
31
+ Request.pause "plugin", "#{Module.nesting[0]}::#{__method__}" do
32
+ quote_without_immunio(value, column)
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ # There is one place where a statement may be quoted without going through the
39
+ # quote method. This occurs when a where statement is given an array, like:
40
+ #
41
+ # Users.where(["email LIKE %s", "bob@example.com"])
42
+ #
43
+ # The first value is a sprintf format string and the rest are values
44
+ # interpolated into it. This triggers a call into sanitize_sql_array, which
45
+ # will pass the values through quote_string, but only if the first value is
46
+ # not a Hash and the statement does not include '?' placeholders. Otherwise,
47
+ # different interpolation and quoting mechanisms are used.
48
+ #
49
+ # The above has been verified to be the case from Rails 3.0 to 4.2.
50
+ module SanitizeHooks
51
+ extend ActiveSupport::Concern
52
+
53
+ included do |base|
54
+ base.class_eval do
55
+ def sanitize_sql_array_with_immunio(ary)
56
+ Request.time "plugin", "#{Module.nesting[0]}::#{__method__}" do
57
+ statement, *values = ary
58
+
59
+ # Check if rails will use some other mechanism for quoting
60
+ unless (values.first.is_a?(Hash) && statement =~ /:\w+/) ||
61
+ (statement.include?('?')) ||
62
+ (statement.blank?)
63
+ # Rails is going to use quote_string, so handle parameters
64
+ values.each { |value| QueryTracker.instance.add_param nil, value, connection.object_id }
65
+ end
66
+
67
+ Request.pause "plugin", "#{Module.nesting[0]}::#{__method__}" do
68
+ sanitize_sql_array_without_immunio ary
69
+ end
70
+ end
71
+ end
72
+ alias_method_chain :sanitize_sql_array, :immunio
73
+ end
74
+ end
75
+ end
76
+
77
+ module ArelToSqlHooks
78
+ extend ActiveSupport::Concern
79
+
80
+ included do
81
+ alias_method_chain :accept, :immunio if method_defined? :accept
82
+ end
83
+
84
+ def accept_with_immunio(object, *args)
85
+ Request.time "plugin", "#{Module.nesting[0]}::#{__method__}" do
86
+ visitor = ArelNodeVisitor.new(@connection.object_id)
87
+ visitor.accept(object)
88
+ end
89
+
90
+ accept_without_immunio(object, *args)
91
+ end
92
+ end
93
+
94
+ # Arel AST visitor to collect params and modifiers. Based on Arel::Visitors::DepthFirst.
95
+ #
96
+ # Whenever a query is built in Rails, a tree of Ruby objects (AST) is built to represent that query.
97
+ # Arel (the engine building this tree) uses a Visitor to build the SQL statement represented by tree.
98
+ # We use the same base class as Arel, but instead of building some SQL, we track the params and modifiers.
99
+ #
100
+ # See http://en.wikipedia.org/wiki/Visitor_pattern
101
+ class ArelNodeVisitor < Arel::Visitors::Visitor
102
+ # Only accepts statements to avoid duplicates in params if branches are visited multiple times.
103
+ VISITABLES = [Arel::Nodes::SelectStatement, Arel::Nodes::InsertStatement,
104
+ Arel::Nodes::UpdateStatement, Arel::Nodes::DeleteStatement].freeze
105
+
106
+ IGNORED_EXPRESSIONS = ["", "*", "1 AS one"].freeze
107
+
108
+ # Copied from Arel::Visitors::Visitor to ensure the cached dispatch table isn't shared with
109
+ # other Arel visitors under Rails 3.2.
110
+ DISPATCH = Hash.new do |hash, klass|
111
+ hash[klass] = "visit_#{(klass.name || '').gsub('::', '_')}"
112
+ end
113
+
114
+ def initialize(connection_id)
115
+ @connection_id = connection_id
116
+ super()
117
+ end
118
+
119
+ # Entry point into the visitor.
120
+ def accept(object)
121
+ if VISITABLES.include?(object.class)
122
+ visit object, {}
123
+ end
124
+ end
125
+
126
+ def dispatch
127
+ DISPATCH
128
+ end
129
+
130
+ private
131
+ # Backported from Arel 6 Visitor::Reduce, as Arel 3 (Used in Rails 3.2) doesn't have the Reduce visitor.
132
+ def visit object, collector, opts={}
133
+ send dispatch[object.class], object, collector, opts
134
+ rescue NoMethodError => e
135
+ raise e if respond_to?(dispatch[object.class], true)
136
+ superklass = object.class.ancestors.find { |klass|
137
+ respond_to?(dispatch[klass], true)
138
+ }
139
+ raise(TypeError, "Cannot visit #{object.class}") unless superklass
140
+ dispatch[object.class] = dispatch[superklass]
141
+ retry
142
+ end
143
+
144
+ # When an unsafe node is visited. Track the param or modifier.
145
+ def unsafe(o, context, _opts)
146
+ unless o.class == String
147
+ QueryTracker.instance.add_ast_data o.class.name, @connection_id
148
+ end
149
+
150
+ if IGNORED_EXPRESSIONS.include?(o)
151
+ # Ignore
152
+ elsif context[:modifier]
153
+ QueryTracker.instance.add_modifier context[:modifier], o.to_s, @connection_id
154
+ end
155
+ end
156
+ alias :visit_Arel_Nodes_SqlLiteral :unsafe
157
+ alias :visit_String :unsafe
158
+
159
+ # We use a context (second argument of visit) to keep track of where we are in the AST.
160
+ # The following methods update the contexts based on the branch of the AST being visited
161
+ # and recursively visit the children nodes.
162
+
163
+ def visit_Arel_Nodes_SelectCore(o, context, _opts)
164
+ QueryTracker.instance.add_ast_data o.class, @connection_id
165
+
166
+ visit o.projections, modifier: :select
167
+ visit o.source, modifier: :from
168
+ visit o.wheres, modifier: :where
169
+ visit o.groups, modifier: :group
170
+ visit o.windows, context
171
+ visit o.having, modifier: :having
172
+ end
173
+
174
+ def visit_Arel_Nodes_SelectStatement(o, context, _opts)
175
+ QueryTracker.instance.add_ast_data o.class, @connection_id
176
+
177
+ visit o.cores, context
178
+ visit o.orders, modifier: :order
179
+ visit o.limit, modifier: :limit
180
+ visit o.lock, modifier: :lock
181
+ visit o.offset, modifier: :offset
182
+ end
183
+
184
+ def visit_Arel_Nodes_UpdateStatement(o, context, _opts)
185
+ QueryTracker.instance.add_ast_data o.class, @connection_id
186
+
187
+ visit o.relation, context
188
+ visit o.values, context
189
+ visit o.wheres, modifier: :where
190
+ visit o.orders, modifier: :order
191
+ visit o.limit, modifier: :limit
192
+ end
193
+
194
+ def visit_Arel_Nodes_DeleteStatement(o, context, _opts)
195
+ QueryTracker.instance.add_ast_data o.class, @connection_id
196
+
197
+ visit o.relation, context
198
+ visit o.wheres, modifier: :where
199
+ end
200
+
201
+ # All other methods bellow are for visiting each node and their children.
202
+
203
+ def unary(o, context, _opts)
204
+ QueryTracker.instance.add_ast_data o.class, @connection_id
205
+
206
+ visit o.expr, context
207
+ end
208
+ alias :visit_Arel_Nodes_Group :unary
209
+ alias :visit_Arel_Nodes_Grouping :unary
210
+ alias :visit_Arel_Nodes_Having :unary
211
+ alias :visit_Arel_Nodes_Limit :unary
212
+ alias :visit_Arel_Nodes_Not :unary
213
+ alias :visit_Arel_Nodes_Offset :unary
214
+ alias :visit_Arel_Nodes_On :unary
215
+ alias :visit_Arel_Nodes_Ordering :unary
216
+ alias :visit_Arel_Nodes_Ascending :unary
217
+ alias :visit_Arel_Nodes_Descending :unary
218
+ alias :visit_Arel_Nodes_Top :unary
219
+ alias :visit_Arel_Nodes_UnqualifiedColumn :unary
220
+ alias :visit_Arel_Nodes_Lock :unary
221
+ alias :visit_Arel_Nodes_Quoted :unary
222
+
223
+ def function(o, context, _opts)
224
+ QueryTracker.instance.add_ast_data o.class, @connection_id
225
+
226
+ visit o.expressions, context
227
+ end
228
+ alias :visit_Arel_Nodes_Avg :function
229
+ alias :visit_Arel_Nodes_Exists :function
230
+ alias :visit_Arel_Nodes_Max :function
231
+ alias :visit_Arel_Nodes_Min :function
232
+ alias :visit_Arel_Nodes_Sum :function
233
+
234
+ def visit_Arel_Nodes_NamedFunction(o, context, _opts)
235
+ QueryTracker.instance.add_ast_data o.class, @connection_id
236
+
237
+ visit o.expressions, context
238
+ end
239
+
240
+ def visit_Arel_Nodes_Count(o, context, _opts)
241
+ QueryTracker.instance.add_ast_data o.class, @connection_id
242
+
243
+ visit o.expressions, context
244
+ end
245
+
246
+ def nary(o, context, _opts)
247
+ QueryTracker.instance.add_ast_data o.class, @connection_id
248
+
249
+ o.children.each { |child| visit child, context }
250
+ end
251
+ alias :visit_Arel_Nodes_And :nary
252
+
253
+ def binary(o, context, _opts)
254
+ QueryTracker.instance.add_ast_data o.class, @connection_id
255
+
256
+ visit o.left, context
257
+ visit o.right, context
258
+ end
259
+ alias :visit_Arel_Nodes_As :binary
260
+ alias :visit_Arel_Nodes_Assignment :binary
261
+ alias :visit_Arel_Nodes_Between :binary
262
+ alias :visit_Arel_Nodes_DoesNotMatch :binary
263
+ alias :visit_Arel_Nodes_Equality :binary
264
+ alias :visit_Arel_Nodes_FullOuterJoin :binary
265
+ alias :visit_Arel_Nodes_GreaterThan :binary
266
+ alias :visit_Arel_Nodes_GreaterThanOrEqual :binary
267
+ alias :visit_Arel_Nodes_InfixOperation :binary
268
+ alias :visit_Arel_Nodes_JoinSource :binary
269
+ alias :visit_Arel_Nodes_InnerJoin :binary
270
+ alias :visit_Arel_Nodes_LessThan :binary
271
+ alias :visit_Arel_Nodes_LessThanOrEqual :binary
272
+ alias :visit_Arel_Nodes_Matches :binary
273
+ alias :visit_Arel_Nodes_NotEqual :binary
274
+ alias :visit_Arel_Nodes_NotRegexp :binary
275
+ alias :visit_Arel_Nodes_Or :binary
276
+ alias :visit_Arel_Nodes_OuterJoin :binary
277
+ alias :visit_Arel_Nodes_Regexp :binary
278
+ alias :visit_Arel_Nodes_RightOuterJoin :binary
279
+ alias :visit_Arel_Nodes_TableAlias :binary
280
+ alias :visit_Arel_Nodes_Values :binary
281
+ alias :visit_Arel_Nodes_Union :binary
282
+
283
+ # Special case the IN clause node. We don't want to add info about AST
284
+ # nodes in the right side of an IN clause if they are terminal or an
285
+ # array of terminal nodes.
286
+ def visit_Arel_Nodes_In(o, context, _opts)
287
+ QueryTracker.instance.add_ast_data o.class, @connection_id
288
+
289
+ visit o.left, context
290
+ visit o.right, context, IN_children: true
291
+ end
292
+ alias :visit_Arel_Nodes_NotIn :visit_Arel_Nodes_In
293
+
294
+ def visit_Arel_Nodes_StringJoin(o, context, _opts)
295
+ QueryTracker.instance.add_ast_data o.class, @connection_id
296
+
297
+ visit o.left, context
298
+ end
299
+
300
+ def visit_Arel_Attribute(o, context, _opts)
301
+ QueryTracker.instance.add_ast_data o.class, @connection_id
302
+
303
+ visit o.relation, context
304
+ end
305
+ alias :visit_Arel_Attributes_Integer :visit_Arel_Attribute
306
+ alias :visit_Arel_Attributes_Float :visit_Arel_Attribute
307
+ alias :visit_Arel_Attributes_String :visit_Arel_Attribute
308
+ alias :visit_Arel_Attributes_Time :visit_Arel_Attribute
309
+ alias :visit_Arel_Attributes_Boolean :visit_Arel_Attribute
310
+ alias :visit_Arel_Attributes_Attribute :visit_Arel_Attribute
311
+ alias :visit_Arel_Attributes_Decimal :visit_Arel_Attribute
312
+
313
+ def visit_Arel_Table(o, _context, _opts)
314
+ QueryTracker.instance.add_ast_data o.class, @connection_id
315
+ end
316
+
317
+ def terminal(_o, _context, _opts)
318
+ end
319
+ alias :visit_Arel_Nodes_Node :terminal
320
+ alias :visit_ActiveSupport_Multibyte_Chars :terminal
321
+ alias :visit_ActiveSupport_StringInquirer :terminal
322
+ alias :visit_Symbol :terminal
323
+ alias :visit_Arel_Nodes_Window :terminal
324
+ alias :visit_Arel_Nodes_True :terminal
325
+ alias :visit_Arel_Nodes_False :terminal
326
+ alias :visit_BigDecimal :terminal
327
+ alias :visit_Bignum :terminal
328
+ alias :visit_Class :terminal
329
+ alias :visit_Date :terminal
330
+ alias :visit_DateTime :terminal
331
+ alias :visit_FalseClass :terminal
332
+ alias :visit_Fixnum :terminal
333
+ alias :visit_Float :terminal
334
+ alias :visit_Arel_Nodes_BindParam :terminal
335
+ alias :visit_NilClass :terminal
336
+ alias :visit_Time :terminal
337
+ alias :visit_TrueClass :terminal
338
+ alias :visit_Object :terminal
339
+
340
+ def visit_Arel_Nodes_InsertStatement(o, context, _opts)
341
+ QueryTracker.instance.add_ast_data o.class, @connection_id
342
+
343
+ visit o.relation, context
344
+ visit o.columns, context
345
+ visit o.values, context
346
+ end
347
+
348
+ def visit_Array(o, context, opts)
349
+ unless opts[:IN_children]
350
+ QueryTracker.instance.add_ast_data o.class, @connection_id
351
+ end
352
+
353
+ o.each { |i| visit i, context }
354
+ end
355
+ alias :visit_Set :visit_Array
356
+
357
+ def visit_Hash(o, context, _opts)
358
+ QueryTracker.instance.add_ast_data o.class, @connection_id
359
+
360
+ o.each { |k,v| visit(k, context); visit(v, context) }
361
+ end
362
+ end
363
+
364
+ class QueryTracker
365
+ include Singleton
366
+
367
+ def initialize
368
+ # The data in these hashes represent relations and connections whose
369
+ # lifecycle cannot be easily inferred. A relation could be kept around
370
+ # across multiple HTTP requests, for example. We defined finalizers to
371
+ # clean up data in the hashes when the objects they are linked to are
372
+ # released by the Ruby runtime.
373
+ #
374
+ # Note: Relations have an associated connection and are not accessed by
375
+ # other connections. Connections are associated with a thread and are not
376
+ # accessed by other threads at the same time. Thus, there is no need for
377
+ # thread safety in any of the logic in this class.
378
+
379
+ # Data about a relation. The data inside is stored at different times and
380
+ # must be reset properly:
381
+ #
382
+ # * params and relation_data: Added when ActiveRecord::Relation API calls
383
+ # are made, like #where. Should never be reset for a given relation.
384
+ # * ast_data and modifiers: Added when a relation is converted
385
+ # into a SQL query statement. Should be reset after every query
386
+ # execution.
387
+ @relation_data = Hash.new do |relation_data, relation_id|
388
+ # This should never happen, but if it does it's a sign of an impending
389
+ # memory leak. Log it, but just let it happen as it would be hard to
390
+ # handle elsewhere if we did not set up the data.
391
+ unless ObjectSpace._id2ref(relation_id).is_a? ActiveRecord::Relation
392
+ name = if ObjectSpace._id2ref(relation_id).is_a? Class
393
+ "#{ObjectSpace._id2ref(relation_id).name} Class"
394
+ else
395
+ "#{ObjectSpace._id2ref(relation_id).class.name} Instance"
396
+ end # rubocop:disable Lint/EndAlignment
397
+ Immunio.logger.warn {"Creating relation data for non-relation: #{name}"}
398
+ Immunio.logger.debug {"Call stack:\n#{caller.join "\n"}"}
399
+ end
400
+
401
+ # NOTE: If you hold a reference to the relation here, like say:
402
+ #
403
+ # relation = ObjectSpace._id2ref(relation_id)
404
+ #
405
+ # the scope for the block will hold the relation and it will never be
406
+ # released.
407
+ ObjectSpace.define_finalizer ObjectSpace._id2ref(relation_id),
408
+ self.class.finalize_relation(relation_id)
409
+
410
+ relation_data[relation_id] = {
411
+ params: {},
412
+ relation_data: [],
413
+ ast_data: [],
414
+ modifiers: Hash.new do |modifiers, type|
415
+ modifiers[type] = []
416
+ end
417
+ }
418
+ end
419
+
420
+ # Stacks of relations for each connection. Used to find the appropriate
421
+ # relation for a connection when a query is executed. A stack is used
422
+ # because some relation methods create new relations and call other
423
+ # relation methods on the new relations.
424
+ @relations = Hash.new do |relations, connection_id|
425
+ connection = ObjectSpace._id2ref(connection_id)
426
+ ObjectSpace.define_finalizer(connection, self.class.finalize_connection(connection_id))
427
+
428
+ relations[connection_id] = []
429
+ end
430
+
431
+ # Last spawned relations for connections. Used for a hack to propagate
432
+ # params to the right relation in Rails 3.
433
+ @last_spawned_relations = {}
434
+ end
435
+
436
+ # Delete a relation record when the relation object is released.
437
+ def self.finalize_relation(relation_id)
438
+ proc do
439
+ relation_data = instance.instance_variable_get(:@relation_data)
440
+
441
+ # Check if key exists, delete will call the default value block if not
442
+ relation_data.delete(relation_id) if relation_data.has_key? relation_id
443
+ end
444
+ end
445
+
446
+ # Delete a connection record when the connection object is released.
447
+ def self.finalize_connection(connection_id)
448
+ proc do
449
+ relations = instance.instance_variable_get(:@relations)
450
+
451
+ # Check if key exists, delete will call the default value block if not
452
+ relations.delete(connection_id) if relations.has_key? connection_id
453
+
454
+ instance.instance_variable_get(:@last_spawned_relations).delete connection_id
455
+ end
456
+ end
457
+
458
+ # Push a relation onto the stack for its connection
459
+ def push_relation(relation)
460
+ @relations[relation.connection.object_id] << relation.object_id
461
+ end
462
+
463
+ # Pop a relation off the stack for its connection
464
+ def pop_relation(relation)
465
+ popped = @relations[relation.connection.object_id].pop
466
+ unless popped == relation.object_id
467
+ Immunio.logger.warn {"Popped wrong relation, expected: #{relation}, popped: #{popped}"}
468
+ Immunio.logger.debug {"Call stack:\n#{caller.join "\n"}"}
469
+ end
470
+ end
471
+
472
+ # Called when a relation is cloned. The data for the new relation must also
473
+ # be copied from the old relation.
474
+ def spawn_relation(orig, new)
475
+ orig_id = orig.object_id
476
+ new_id = new.object_id
477
+
478
+ # If we weren't tracking the original relation, don't bother setting up
479
+ # the new relation yet.
480
+ if @relation_data.has_key? orig_id
481
+ # ast_data and modifiers should be empty, but we must clone modifiers to
482
+ # get the initializer.
483
+ @relation_data[new_id] = {
484
+ params: @relation_data[orig_id][:params].clone,
485
+ relation_data: @relation_data[orig_id][:relation_data].clone,
486
+ ast_data: @relation_data[orig_id][:ast_data] = [],
487
+ modifiers: @relation_data[orig_id][:modifiers].clone
488
+ }
489
+
490
+ # The default block for the @relation_data hash isn't called when
491
+ # assigning a value to a new key. We must set up the finalizer manually.
492
+ ObjectSpace.define_finalizer(new, self.class.finalize_relation(new_id))
493
+ end
494
+
495
+ # Save the last spawned relation for a hack for storing params from #where
496
+ # and #having.
497
+ @last_spawned_relations[new.connection.object_id] = new_id
498
+ end
499
+
500
+ # Retrieve the last spawned relation for the connection. This is an ugly
501
+ # hack for a poor implementation of the #where and #having methods in Rails
502
+ # 3.
503
+ def last_spawned_relation(connection)
504
+ ObjectSpace._id2ref @last_spawned_relations[connection.object_id]
505
+ end
506
+
507
+ # Called when two relations are merged. The data for the other relation must
508
+ # be copied into the current relation. AST data and modifiers should be
509
+ # empty and don't need to be copied.
510
+ def merge_relations(relation, other)
511
+ params = @relation_data[relation.object_id][:params]
512
+ other_params = @relation_data[other.object_id][:params]
513
+
514
+ other_params.each_pair do |name, value|
515
+ # Update numeric ID for current relation if name is an integer.
516
+ name = params.size.to_s if name.to_i.to_s == name
517
+
518
+ params[name] = value
519
+ end
520
+
521
+ other_data = @relation_data[other.object_id][:relation_data]
522
+ @relation_data[relation.object_id][:relation_data] += other_data
523
+ end
524
+
525
+ # Add relation API context data to the relation.
526
+ def add_relation_data(relation, data)
527
+ @relation_data[relation.object_id][:relation_data] << data
528
+ end
529
+
530
+ # Add a parameter to the current relation for the connection. If the
531
+ # relation is copied or merged into another relation, the param will also
532
+ # be copied.
533
+ def add_param(name, value, connection_id)
534
+ relation_id = @relations[connection_id].last
535
+
536
+ # This can occur if the query statement isn't generated by the app but by
537
+ # ActiveRecord itself.
538
+ return unless relation_id
539
+
540
+ params = @relation_data[relation_id][:params]
541
+
542
+ # If no name given, use index.
543
+ if name.nil?
544
+ name = params.size.to_s
545
+ end
546
+
547
+ Immunio.logger.debug "Adding ActiveRecord SQL param to relation #{relation_id} (name: #{name}, value: #{value})"
548
+
549
+ params[name] = value
550
+ end
551
+
552
+ # Add a modifier to the current relation for the connection. This only
553
+ # occurs during conversion of a relation to SQL statement, so modifiers are
554
+ # never copied from one relation to another.
555
+ def add_modifier(type, value, connection_id)
556
+ relation_id = @relations[connection_id].last
557
+
558
+ # This can occur if the query statement isn't generated by the app but by
559
+ # ActiveRecord itself.
560
+ return unless relation_id
561
+
562
+ @relation_data[relation_id][:modifiers][type] << value
563
+ end
564
+
565
+ # Add data about an Arel AST node to the context data for the connection.
566
+ # This only occurs during conversion of a relation to SQL statement, so AST
567
+ # context data is never copied from one relation to another.
568
+ def add_ast_data(ast_node_name, connection_id)
569
+ relation_id = @relations[connection_id].last
570
+
571
+ # This can occur if the query statement was cached and there's no relation
572
+ # associated with the connection. That's ok here, though, because there's
573
+ # such a limited number of cacheable statement structures that we don't
574
+ # need AST info to differentiate between queries with the same stack
575
+ # trace.
576
+ return unless relation_id
577
+
578
+ @relation_data[relation_id][:ast_data] << "Arel AST visited node: #{ast_node_name}"
579
+ end
580
+
581
+ # Evaluate a SQL call. This occurs after Arel AST conversion of a relation
582
+ # to a statement.
583
+ def call(payload)
584
+ Request.time "plugin", "#{Module.nesting[0]}::#{__method__}" do
585
+ Immunio.logger.debug "New ActiveRecord SQL query: #{payload}"
586
+
587
+ connection_id = payload[:connection_id]
588
+
589
+ relation_id = @relations[connection_id].last
590
+
591
+ if should_ignore? payload[:sql]
592
+ Immunio.logger.debug "Ignoring query as it was generated by ActiveRecord itself (#{payload[:sql]})"
593
+ return
594
+ end
595
+
596
+ if relation_id
597
+ # Note: If a relation is released between when it is converted to a
598
+ # SQL statement and now, we would lose the data and additionally leak
599
+ # an empty entry in the @relation_data hash. I don't believe this is
600
+ # possible due to how we wrap things, but there's no explicit
601
+ # guarantee.
602
+ relation_data = @relation_data[relation_id]
603
+ params = relation_data[:params].clone
604
+ context_data = (relation_data[:relation_data] + relation_data[:ast_data]).join "\n"
605
+
606
+ # modifiers must be cloned because it will be cleared when the
607
+ # relation is reset.
608
+ modifiers = relation_data[:modifiers].clone
609
+ else
610
+ params = {}
611
+ context_data = nil
612
+ modifiers = {}
613
+ end
614
+
615
+ # Merge bound values
616
+ question_marks = 0
617
+ payload[:binds].each do |(column, value)|
618
+ if column.nil?
619
+ params["?:#{question_marks}"] = value.to_s
620
+ question_marks = question_marks + 1
621
+ else
622
+ # When using the activerecord-sqlserver-adapter gem, the "column" is
623
+ # the actual param name.
624
+ name = column.respond_to?(:name) ? column.name : column.to_s
625
+ params[name] = value.to_s
626
+ end
627
+ end
628
+
629
+ strict_context, loose_context, stack = Immunio::Context.context context_data
630
+
631
+ # Send in additional_context_data for debugging purposes
632
+ Immunio.run_hook! "active_record", "sql_execute", sql: payload[:sql],
633
+ connection_uuid: connection_id.to_s,
634
+ params: params,
635
+ modifiers: modifiers,
636
+ context_key: strict_context,
637
+ loose_context_key: loose_context,
638
+ stack: stack,
639
+ additional_context_data: context_data
640
+
641
+ reset relation_id
642
+ end
643
+ end
644
+
645
+ # Reset per-execution data for a relation.
646
+ def reset(relation_id)
647
+ return unless relation_id
648
+
649
+ [:ast_data, :modifiers].each do |type|
650
+ @relation_data[relation_id][type].clear
651
+ end
652
+ end
653
+
654
+ private
655
+ IGNORE_START_WITH = ['PRAGMA', 'SHOW', 'SAVEPOINT', 'RELEASE', 'ROLLBACK']
656
+ IGNORE_SQL = /^(begin|commit|rollback)(?: transaction)$/i
657
+ def should_ignore?(sql)
658
+ # Ignore queries generated by ActiveRecord.
659
+ return sql.start_with?(*IGNORE_START_WITH) || IGNORE_SQL =~ sql
660
+ end
661
+ end
662
+
663
+ # Hook into the SQL query execution methods of Rails.
664
+ # Since all executed queries inside Rails are logged, we hook into the `log` method to catch them all.
665
+ module QueryExecutionHooks
666
+ extend ActiveSupport::Concern
667
+
668
+ included do
669
+ alias_method_chain :log, :immunio if method_defined? :log
670
+ end
671
+
672
+ def log_with_immunio(sql, name = "SQL", binds = [], *args)
673
+ QueryTracker.instance.call sql: sql,
674
+ connection_id: object_id,
675
+ binds: binds
676
+
677
+ # Log and execute the query
678
+ log_without_immunio(sql, name, binds, *args) { yield }
679
+ end
680
+ end
681
+ end
682
+
683
+ # Hook into quoting methods at the highest level possible in the ancestors chain.
684
+ # In case the quote methods were overridden in a child class.
685
+ module ActiveRecord::ConnectionAdapters
686
+ if defined? Mysql2Adapter
687
+ Mysql2Adapter.send :include, Immunio::QuotingHooks
688
+ elsif defined? MysqlAdapter
689
+ MysqlAdapter.send :include, Immunio::QuotingHooks
690
+ end
691
+ if defined? PostgreSQLAdapter
692
+ PostgreSQLAdapter.send :include, Immunio::QuotingHooks
693
+ end
694
+ if defined? SQLite3Adapter
695
+ SQLite3Adapter.send :include, Immunio::QuotingHooks
696
+ elsif defined? SQLiteAdapter
697
+ SQLiteAdapter.send :include, Immunio::QuotingHooks
698
+ end
699
+ end
700
+
701
+ module ActiveRecord::Sanitization
702
+ ClassMethods.send :include, Immunio::SanitizeHooks
703
+ end
704
+
705
+ Arel::Visitors::ToSql.send :include, Immunio::ArelToSqlHooks
706
+
707
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.send :include, Immunio::QueryExecutionHooks