rails_vitals 0.6.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82d62304d92259c9c248b8033948784ba6bb5075d7f7af627c445624a042901a
4
- data.tar.gz: 6285bf97528093389e861f96bb45259d434e336042c7d23e30fab02109e690ce
3
+ metadata.gz: ce21f0b9edbbef2e18452c51755bf980c067c92afb651a86ee99b78aac0cbe6d
4
+ data.tar.gz: 0ee7cddd129757762d01cdce2f049622bb6f449e44de05ff5d592bfd4933136d
5
5
  SHA512:
6
- metadata.gz: 0ae1ce76d1ac8c1bbf7559a119e1680f87b40faafe989f0e6c6afb9ec5f3cb8c374a1899a32ca28f04d39ee7ddb4e6f4b73251f16835c8a9587590ecc6ff6cc8
7
- data.tar.gz: 48245cadf704dc9bced9103cad91485d0b9b1c1173b11b50fd79b8964259cb41eaba8af42ff86ca95c1d6ba16ddc52e1f88120159317065e36fb2a4c53d10792
6
+ metadata.gz: 201b26e862cafbde3dfe07f1011ceb3d8241f362ff5386cbae8edbe5e0a250aa99ac800fd4c3d57d9984beec6f54348c78bd7a13f5265cae6a390d137494348e
7
+ data.tar.gz: de6f0ff8c15da14e155850368884cd14830dce374505ad098f8c3a902865d16ad7d42bd17a38ba51561b3e2fb829e73b03abf2530f16873f6249b1782da4ccc5
@@ -13,7 +13,7 @@ module RailsVitals
13
13
  access_associations = Array(params[:access_associations]).reject(&:blank?)
14
14
 
15
15
  result = Playground::Sandbox.run(
16
- expression,
16
+ clean_expr,
17
17
  access_associations: access_associations
18
18
  )
19
19
 
@@ -0,0 +1,292 @@
1
+ require "strscan"
2
+
3
+ module RailsVitals
4
+ module Playground
5
+ class SafeChainBuilder
6
+ ALLOWED_METHODS = %w[
7
+ all where select limit offset order group
8
+ includes preload eager_load joins left_joins
9
+ find find_by first last count sum average
10
+ pluck distinct having references unscoped not
11
+ ].freeze
12
+
13
+ DISALLOWED_CLASS_METHODS = %w[
14
+ connection execute exec system eval send public_send __send__
15
+ instance_eval class_eval module_eval define_method method_missing
16
+ delete destroy delete_all destroy_all update_all
17
+ ].freeze
18
+
19
+ ParseError = Class.new(StandardError)
20
+
21
+ def self.build(chain_str, model)
22
+ relation = model.all
23
+ parse_chain(chain_str).each do |method_name, args|
24
+ if DISALLOWED_CLASS_METHODS.include?(method_name)
25
+ raise ParseError, "Method '#{method_name}' is not allowed for security reasons"
26
+ end
27
+ unless ALLOWED_METHODS.include?(method_name)
28
+ raise ParseError, "Method '#{method_name}' is not allowed. Allowed: #{ALLOWED_METHODS.join(', ')}"
29
+ end
30
+ relation = relation.public_send(method_name, *args)
31
+ end
32
+
33
+ raise ParseError, "Expression must return an ActiveRecord::Relation" unless relation.is_a?(ActiveRecord::Relation)
34
+ relation
35
+ end
36
+
37
+ private
38
+
39
+ def self.parse_chain(str)
40
+ scanner = StringScanner.new(str)
41
+ calls = []
42
+ scanner.skip(/\s+/)
43
+
44
+ until scanner.eos?
45
+ scanner.skip(/\.\s*/)
46
+
47
+ name = scanner.scan(/[a-z_][a-zA-Z0-9_!?]*/)
48
+ raise ParseError, "Expected method name at position #{scanner.pos}" unless name
49
+
50
+ scanner.skip(/\s+/)
51
+ if scanner.scan(/\(/)
52
+ args = parse_args(scanner)
53
+ scanner.skip(/\s*\)/)
54
+ calls << [ name, args ]
55
+ else
56
+ calls << [ name, [] ]
57
+ end
58
+ scanner.skip(/\s+/)
59
+ end
60
+
61
+ calls
62
+ end
63
+
64
+ def self.parse_args(scanner)
65
+ args = []
66
+ scanner.skip(/\s+/)
67
+ return args if scanner.eos? || scanner.peek(1) == ")"
68
+
69
+ loop do
70
+ scanner.skip(/\s+/)
71
+ break if scanner.eos? || scanner.peek(1) == ")"
72
+
73
+ if keyword_hash_start?(scanner)
74
+ args << scan_keyword_hash(scanner)
75
+ else
76
+ args << scan_value(scanner)
77
+ end
78
+
79
+ scanner.skip(/\s+/)
80
+ break unless scanner.scan(/,/)
81
+ end
82
+
83
+ args
84
+ end
85
+
86
+ def self.keyword_hash_start?(scanner)
87
+ pos = scanner.pos
88
+ ident = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
89
+ return false unless ident
90
+
91
+ scanner.skip(/\s*/)
92
+
93
+ if scanner.scan(/:/) && !scanner.scan(/:/)
94
+ scanner.pos = pos
95
+ return true
96
+ end
97
+
98
+ scanner.pos = pos
99
+ false
100
+ end
101
+
102
+ def self.scan_keyword_hash(scanner)
103
+ hash = {}
104
+ loop do
105
+ scanner.skip(/\s+/)
106
+ break if scanner.eos? || scanner.peek(1) == ")" || scanner.peek(1) == "}"
107
+
108
+ key = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
109
+ raise ParseError, "Expected hash key" unless key
110
+ scanner.skip(/\s*:\s*/)
111
+ value = scan_value(scanner)
112
+ hash[key.to_sym] = value
113
+ scanner.skip(/\s+/)
114
+ break unless scanner.scan(/,/)
115
+ end
116
+ hash
117
+ end
118
+
119
+ def self.scan_value(scanner)
120
+ scanner.skip(/\s+/)
121
+ ch = scanner.peek(1)
122
+ raise ParseError, "Unexpected end of expression" unless ch
123
+
124
+ case ch
125
+ when "'" then scan_single_quoted_string(scanner)
126
+ when '"' then scan_double_quoted_string(scanner)
127
+ when ":" then scan_symbol(scanner)
128
+ when "t"
129
+ if scanner.scan(/true\b/)
130
+ true
131
+ else
132
+ raise ParseError, "Unexpected token at position #{scanner.pos}"
133
+ end
134
+ when "f"
135
+ if scanner.scan(/false\b/)
136
+ false
137
+ else
138
+ raise ParseError, "Unexpected token at position #{scanner.pos}"
139
+ end
140
+ when "n"
141
+ if scanner.scan(/nil\b/)
142
+ nil
143
+ else
144
+ raise ParseError, "Unexpected token at position #{scanner.pos}"
145
+ end
146
+ when "[" then scan_array(scanner)
147
+ when "{" then scan_hash_literal(scanner)
148
+ when "-", "+", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9" then scan_number(scanner)
149
+ else
150
+ raise ParseError, "Unexpected token '#{ch}' at position #{scanner.pos}"
151
+ end
152
+ end
153
+
154
+ def self.scan_single_quoted_string(scanner)
155
+ scanner.pos += 1
156
+ result = +""
157
+ until scanner.eos?
158
+ case scanner.peek(1)
159
+ when "'"
160
+ scanner.pos += 1
161
+ return result
162
+ when "\\"
163
+ scanner.pos += 1
164
+ escaped = scanner.getch
165
+ case escaped
166
+ when "'" then result << "'"
167
+ when "\\" then result << "\\"
168
+ else result << "\\#{escaped}"
169
+ end
170
+ else
171
+ result << scanner.getch
172
+ end
173
+ end
174
+ raise ParseError, "Unterminated single-quoted string"
175
+ end
176
+
177
+ def self.scan_double_quoted_string(scanner)
178
+ scanner.pos += 1
179
+ result = +""
180
+ until scanner.eos?
181
+ case scanner.peek(1)
182
+ when '"'
183
+ scanner.pos += 1
184
+ return result
185
+ when "\\"
186
+ scanner.pos += 1
187
+ escaped = scanner.getch
188
+ case escaped
189
+ when '"' then result << '"'
190
+ when "\\" then result << "\\"
191
+ when "n" then result << "\n"
192
+ when "t" then result << "\t"
193
+ when "r" then result << "\r"
194
+ when "#" then result << "#"
195
+ else result << "\\#{escaped}"
196
+ end
197
+ else
198
+ result << scanner.getch
199
+ end
200
+ end
201
+ raise ParseError, "Unterminated double-quoted string"
202
+ end
203
+
204
+ def self.scan_symbol(scanner)
205
+ scanner.pos += 1
206
+ if scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
207
+ scanner.matched.to_sym
208
+ elsif (str = scanner.scan(/"[^"]*"/) || scanner.scan(/'[^']*'/))
209
+ str[1..-2].to_sym
210
+ else
211
+ raise ParseError, "Invalid symbol at position #{scanner.pos}"
212
+ end
213
+ end
214
+
215
+ def self.scan_number(scanner)
216
+ num_str = scanner.scan(/-?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?/)
217
+ raise ParseError, "Invalid number at position #{scanner.pos}" unless num_str
218
+ if num_str.include?(".") || num_str.match?(/[eE]/)
219
+ num_str.to_f
220
+ else
221
+ num_str.to_i
222
+ end
223
+ end
224
+
225
+ def self.scan_array(scanner)
226
+ scanner.pos += 1
227
+ arr = []
228
+ scanner.skip(/\s+/)
229
+ unless scanner.peek(1) == "]"
230
+ loop do
231
+ arr << scan_value(scanner)
232
+ scanner.skip(/\s+/)
233
+ break unless scanner.scan(/,/)
234
+ scanner.skip(/\s+/)
235
+ end
236
+ end
237
+ scanner.skip(/\s*\]/)
238
+ raise ParseError, "Unterminated array" unless scanner.matched
239
+ arr
240
+ end
241
+
242
+ def self.scan_hash_literal(scanner)
243
+ scanner.pos += 1
244
+ hash = {}
245
+ scanner.skip(/\s+/)
246
+ unless scanner.eos? || scanner.peek(1) == "}"
247
+ loop do
248
+ scanner.skip(/\s+/)
249
+ break if scanner.eos? || scanner.peek(1) == "}"
250
+
251
+ key = parse_hash_key(scanner)
252
+ scanner.skip(/\s+/)
253
+
254
+ if scanner.scan(/=>/)
255
+ scanner.skip(/\s+/)
256
+ hash[key] = scan_value(scanner)
257
+ elsif scanner.scan(/:\s*/)
258
+ hash[key.to_sym] = scan_value(scanner)
259
+ else
260
+ raise ParseError, "Expected '=>' or ':' after hash key"
261
+ end
262
+
263
+ scanner.skip(/\s+/)
264
+ break unless scanner.scan(/,/)
265
+ end
266
+ end
267
+ scanner.skip(/\s*}/)
268
+ raise ParseError, "Unterminated hash" unless scanner.matched
269
+ hash
270
+ end
271
+
272
+ def self.parse_hash_key(scanner)
273
+ case scanner.peek(1)
274
+ when '"', "'" then scan_string(scanner)
275
+ when ":" then scan_symbol(scanner)
276
+ else
277
+ ident = scanner.scan(/[a-zA-Z_][a-zA-Z0-9_]*/)
278
+ raise ParseError, "Expected hash key" unless ident
279
+ ident
280
+ end
281
+ end
282
+
283
+ def self.scan_string(scanner)
284
+ case scanner.peek(1)
285
+ when "'" then scan_single_quoted_string(scanner)
286
+ when '"' then scan_double_quoted_string(scanner)
287
+ else raise ParseError, "Expected string"
288
+ end
289
+ end
290
+ end
291
+ end
292
+ end
@@ -1,19 +1,16 @@
1
1
  module RailsVitals
2
2
  module Playground
3
3
  class Sandbox
4
- ALLOWED_METHODS = %w[
5
- all where select limit offset order group
6
- includes preload eager_load joins left_joins
7
- find find_by first last count sum average
8
- pluck distinct having references unscoped
9
- ].freeze
10
-
11
4
  BLOCKED_PATTERNS = [
12
5
  /\b(insert|update|delete|destroy|drop|truncate|create|alter)\b/i,
13
6
  /\.save/i, /\.save!/i, /\.update/i, /\.delete/i,
14
7
  /\.destroy/i, /`/
15
8
  ].freeze
16
9
 
10
+ SAFE_EXPRESSION_PATTERN = /\A[a-zA-Z0-9_\.\s\(\),:\[\]{}'"!?=<>|&*+\-\/\\%]+\z/
11
+
12
+ ASSOCIATION_NAME_PATTERN = /\A[a-zA-Z_][a-zA-Z0-9_]*\z/
13
+
17
14
  DEFAULT_LIMIT = 100
18
15
 
19
16
  Result = Struct.new(
@@ -26,6 +23,13 @@ module RailsVitals
26
23
  def self.run(expression, access_associations: [])
27
24
  return blocked_result("No expression provided") if expression.blank?
28
25
 
26
+ expression = expression.gsub(/#[^\n]*/, "").strip
27
+ return blocked_result("No expression provided") if expression.blank?
28
+
29
+ return blocked_result(
30
+ "Expression contains invalid characters."
31
+ ) unless expression.match?(SAFE_EXPRESSION_PATTERN)
32
+
29
33
  BLOCKED_PATTERNS.each do |pattern|
30
34
  return blocked_result(
31
35
  "Expression contains blocked operation. " \
@@ -33,6 +37,10 @@ module RailsVitals
33
37
  ) if expression.match?(pattern)
34
38
  end
35
39
 
40
+ access_associations = access_associations.select do |name|
41
+ name.to_s.match?(ASSOCIATION_NAME_PATTERN)
42
+ end
43
+
36
44
  model_name = extract_model_name(expression)
37
45
  return blocked_result(
38
46
  "Could not detect model from expression. " \
@@ -111,10 +119,7 @@ module RailsVitals
111
119
  end
112
120
 
113
121
  def self.extract_model_name(expression)
114
- # Strip comments first
115
- clean = expression.gsub(/#[^\n]*/, "").strip
116
- # First word before a dot or whitespace — must look like a constant (CamelCase)
117
- match = clean.match(/\A([A-Z][A-Za-z0-9]*)/)
122
+ match = expression.match(/\A([A-Z][A-Za-z0-9]*)/)
118
123
  match ? match[1] : nil
119
124
  end
120
125
 
@@ -132,31 +137,15 @@ module RailsVitals
132
137
  end
133
138
 
134
139
  def self.build_relation(expression, model)
135
- # Parse "Post.includes(:likes).where(published: true).limit(10)"
136
- # Strip the model name prefix if present
137
140
  chain_str = expression
138
141
  .sub(/\A#{Regexp.escape(model.name)}\s*\.?\s*/, "")
139
142
  .strip
140
143
 
141
144
  return model.all if chain_str.blank?
142
145
 
143
- # Build the chain by safe eval within a controlled binding
144
- # Only the model constant is exposed, no access to app globals
145
- sandbox_binding = build_binding(model)
146
- relation = eval(chain_str, sandbox_binding) # rubocop:disable Security/Eval
147
-
148
- unless relation.is_a?(ActiveRecord::Relation)
149
- raise "Expression must return an ActiveRecord::Relation"
150
- end
151
-
152
- relation
153
- end
154
-
155
- def self.build_binding(model)
156
- # Create a minimal binding with only the model exposed
157
- ctx = Object.new
158
- ctx.define_singleton_method(:relation) { model.all }
159
- ctx.instance_eval { binding }
146
+ SafeChainBuilder.build(chain_str, model)
147
+ rescue SafeChainBuilder::ParseError => e
148
+ raise "Expression error: #{e.message}"
160
149
  end
161
150
 
162
151
  def self.apply_limit(relation)
@@ -1,3 +1,3 @@
1
1
  module RailsVitals
2
- VERSION = "0.6.0"
2
+ VERSION = "0.6.1"
3
3
  end
data/lib/rails_vitals.rb CHANGED
@@ -14,6 +14,7 @@ require "rails_vitals/scorers/base_scorer"
14
14
  require "rails_vitals/scorers/query_scorer"
15
15
  require "rails_vitals/scorers/n_plus_one_scorer"
16
16
  require "rails_vitals/scorers/composite_scorer"
17
+ require "rails_vitals/playground/safe_chain_builder"
17
18
  require "rails_vitals/playground/sandbox"
18
19
  require "rails_vitals/panel_renderer"
19
20
  require "rails_vitals/middleware/panel_injector"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_vitals
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Sanchez
@@ -95,6 +95,7 @@ files:
95
95
  - lib/rails_vitals/middleware/panel_injector.rb
96
96
  - lib/rails_vitals/notifications/subscriber.rb
97
97
  - lib/rails_vitals/panel_renderer.rb
98
+ - lib/rails_vitals/playground/safe_chain_builder.rb
98
99
  - lib/rails_vitals/playground/sandbox.rb
99
100
  - lib/rails_vitals/request_record.rb
100
101
  - lib/rails_vitals/scorers/base_scorer.rb