safemode 1.6.0 → 2.0.0

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: 60d02e15fb3d2ae2d1447e72e0c92e7379c826393b64349ce3c2c71ca4d53da5
4
- data.tar.gz: 3cb324e0289157a132e5edd0535cc66d6ae351acf737b092d74a68859ebfe887
3
+ metadata.gz: 31ed45049398953b35fafe5ff837df2332712caa9534c5278e971d9031a61836
4
+ data.tar.gz: 49b507273a0ef6d03578be21a26f26388fb4cd54d253670a8932ae7891ae40f7
5
5
  SHA512:
6
- metadata.gz: 364354811cc8928d257da2d095e4aed8301eb5d26a7d2303d8f0be5e9d16a1c9172f04c967c385a31ea24af95dc6c2eb00ef2fd7eb47426872db0064a5035cd0
7
- data.tar.gz: 01f78d9d81dffa08d50068177840324c7d54db94249a3714b34d2e2301386836a061c85da2ce4d610c8890f60427b76bb2fa3bb2f7e90a917a6c2316ff58c13b
6
+ metadata.gz: eefb3d5121ad93910e1468cd635a5d77320dfbd23c341469af7acc1d90c80947ab1f8ec058bf1775b9269566d2503cfec0118bb6143e9d35ee6ba5afbb055caa
7
+ data.tar.gz: ab5f0385d055999f6a6463381d24494bab377fe12aebec639b3be5fc78faf4355c5485105ba6c73bbc0a0023c2db3dd063bc35abee19d4564748d15a925650d8
@@ -1,7 +1,5 @@
1
1
  module Safemode
2
2
  class Parser < Ruby2Ruby
3
- @@parser = 'RubyParser'
4
-
5
3
  class << self
6
4
  def jail(code, allowed_fcalls = [])
7
5
  @@allowed_fcalls = allowed_fcalls
@@ -10,16 +8,7 @@ module Safemode
10
8
  end
11
9
 
12
10
  def parse(code)
13
- case @@parser
14
- when 'RubyParser'
15
- RubyParser.new.parse(code)
16
- else
17
- raise "unknown parser #{@@parser}"
18
- end
19
- end
20
-
21
- def parser=(parser)
22
- @@parser = parser
11
+ Prism::Translation::RubyParser.parse(code)
23
12
  end
24
13
  end
25
14
 
@@ -86,9 +75,13 @@ module Safemode
86
75
  # :colon2 is used for module constants
87
76
  :colon2,
88
77
  # unnecessarily advanced?
89
- :argscat, :argspush, :splat,
78
+ :argscat, :argspush, :splat, :kwsplat,
79
+ # NOTE: op_asgn*/safe_op_asgn* do not insert .to_jail on the
80
+ # receiver. Setters are called directly on the real object, bypassing Jail.
90
81
  :op_asgn, :op_asgn1, :op_asgn2, :op_asgn_and, :op_asgn_or,
91
- :safe_op_asgn,
82
+ :safe_op_asgn, :safe_op_asgn2,
83
+ # pattern matching (Ruby 3.0+)
84
+ :in, :array_pat, :hash_pat, :find_pat, :kwrest,
92
85
  # needed for haml
93
86
  :block ]
94
87
 
@@ -96,18 +89,21 @@ module Safemode
96
89
  # see below for :const handling
97
90
  :defn, :defs, :alias, :valias, :undef, :class, :attrset,
98
91
  :module, :sclass, :colon3,
99
- :fbody, :scope, :block_arg, :postexe,
92
+ :fbody, :scope, :block_arg, :postexe, :preexe,
100
93
  :redo, :retry, :begin, :rescue, :resbody, :ensure,
101
94
  :defined, :super, :zsuper, :return,
102
95
  :dmethod, :bmethod, :to_ary, :svalue, :match,
103
- :attrasgn, :cdecl, :cvasgn, :cvdecl, :cvar, :gvar, :gasgn,
96
+ :attrasgn, :safe_attrasgn,
97
+ :cdecl, :cvasgn, :cvdecl, :cvar, :gvar, :gasgn,
104
98
  :xstr, :dxstr,
105
99
  # not sure how secure ruby regexp is, so leave it out for now
106
100
  :dregx, :dregx_once, :match2, :match3, :nth_ref, :back_ref,
107
101
  # block_pass represents &:method, which would bypass the whitelist e.g. by array.each(&:destroy)
108
102
  # at this point we don't know the receiver so we rather disable it completely,
109
103
  # use array.each { |item| item.destroy } instead
110
- :block_pass ]
104
+ :block_pass,
105
+ # lambda creates Proc objects
106
+ :lambda ]
111
107
 
112
108
  # SexpProcessor bails when we overwrite these ... but they are listed as
113
109
  # "internal nodes that you can't get to" in sexp_processor.rb
@@ -205,6 +201,49 @@ module Safemode
205
201
  end
206
202
  end
207
203
 
204
+ # Ruby2Ruby bug: __var adds ^ (pin) to all lvars inside :in context,
205
+ # including the body after "then" where ^ is invalid syntax.
206
+ # Fix: process body outside the :in context.
207
+ def process_in(exp)
208
+ _, pattern, *body = exp
209
+
210
+ guard = extract_guard(pattern)
211
+ cond = process pattern
212
+ body = body.compact.map { |sexp|
213
+ in_context :in_body do
214
+ indent process sexp
215
+ end
216
+ }
217
+
218
+ body << indent("# do nothing") if body.empty?
219
+ body = body.join "\n"
220
+
221
+ header = "in #{cond}"
222
+ header << " #{guard}" if guard
223
+ "#{header} then\n#{body.chomp}"
224
+ end
225
+
226
+ # Prism translates guard clauses (in pattern if cond / in pattern unless cond)
227
+ # as s(:if, guard, pattern, nil) / s(:if, guard, nil, pattern). We detect this
228
+ # and rewrite the sexp in-place so process_if renders only the pattern, then
229
+ # return the guard string for process_in to append.
230
+ def extract_guard(pattern)
231
+ return unless pattern.sexp_type == :if
232
+
233
+ _, guard_sexp, if_body, else_body = pattern
234
+ if if_body && !else_body
235
+ guard_str = in_context(:in_body) { "if #{process guard_sexp.deep_clone}" }
236
+ pattern.clear
237
+ if_body.each { |node| pattern << node }
238
+ elsif else_body && !if_body
239
+ guard_str = in_context(:in_body) { "unless #{process guard_sexp.deep_clone}" }
240
+ pattern.clear
241
+ else_body.each { |node| pattern << node }
242
+ end
243
+
244
+ guard_str
245
+ end
246
+
208
247
  # Ruby2Ruby process_if rewrites if and unless statements in a way that
209
248
  # makes the result unusable for evaluation in, e.g. ERB which appends a
210
249
  # call to to_s when using <%= %> tags. We'd need to either enclose the
data/lib/safemode.rb CHANGED
@@ -1,10 +1,8 @@
1
1
  require 'rubygems'
2
2
 
3
3
  require 'ruby2ruby'
4
- begin
5
- require 'ruby_parser' # try to load RubyParser and use it if present
6
- rescue LoadError => e
7
- end
4
+ require 'prism'
5
+ require 'prism/translation/ruby_parser'
8
6
 
9
7
  require 'safemode/core_ext'
10
8
  require 'safemode/blankslate'
data/safemode.gemspec CHANGED
@@ -4,10 +4,10 @@ require 'date'
4
4
 
5
5
  Gem::Specification.new do |s|
6
6
  s.name = "safemode".freeze
7
- s.version = "1.6.0"
7
+ s.version = "2.0.0"
8
8
  s.date = Date.today
9
9
 
10
- s.summary = "A library for safe evaluation of Ruby code based on RubyParser and Ruby2Ruby"
10
+ s.summary = "A library for safe evaluation of Ruby code based on Prism and Ruby2Ruby"
11
11
  s.description = "A library for safe evaluation of Ruby code based on RubyParser and Ruby2Ruby. Provides Rails ActionView template handlers for ERB and Haml."
12
12
  s.homepage = "https://github.com/svenfuchs/safemode"
13
13
  s.licenses = ["MIT"]
@@ -52,10 +52,10 @@ Gem::Specification.new do |s|
52
52
  "test/test_safemode_parser.rb"
53
53
  ]
54
54
 
55
- s.required_ruby_version = ">= 2.7", "< 3.2"
55
+ s.required_ruby_version = ">= 3.0", "< 3.4"
56
56
 
57
57
  s.add_runtime_dependency "ruby2ruby", "~> 2.4"
58
- s.add_runtime_dependency "ruby_parser", "~> 3.10"
58
+ s.add_runtime_dependency "prism", "~> 1.0"
59
59
  s.add_runtime_dependency "sexp_processor", "~> 4.10"
60
60
 
61
61
  s.add_development_dependency "rake", "~> 13.4"
data/test/test_helper.rb CHANGED
@@ -112,6 +112,8 @@ class Article
112
112
  Comment
113
113
  end
114
114
 
115
+ attr_accessor :status
116
+
115
117
  def method_with_kwargs(a_keyword: false)
116
118
  a_keyword
117
119
  end
@@ -150,7 +152,7 @@ class Comment
150
152
  end
151
153
 
152
154
  class Article::Jail < Safemode::Jail
153
- allow :title, :comments, :is_article?, :comment_class, :method_with_kwargs
155
+ allow :title, :comments, :is_article?, :comment_class, :method_with_kwargs, :status
154
156
 
155
157
  def author_name
156
158
  "this article's author name"
@@ -94,6 +94,111 @@ class TestSafemodeEval < Test::Unit::TestCase
94
94
  assert @box.eval("@article.method_with_kwargs(a_keyword: true)", @assigns)
95
95
  end
96
96
 
97
+ def test_pattern_matching_with_literal
98
+ assert_equal "matched", @box.eval('case 1; in 1; "matched"; in 2; "nope"; end')
99
+ end
100
+
101
+ def test_pattern_matching_with_array_destructuring
102
+ assert_equal 1, @box.eval('case [1, 2, 3]; in [a, b, c]; a; end')
103
+ end
104
+
105
+ def test_pattern_matching_with_hash_destructuring
106
+ assert_equal 1, @box.eval('case({a: 1, b: 2}); in {a:}; a; end')
107
+ end
108
+
109
+ def test_pattern_matching_with_assign
110
+ assert_equal "two", @box.eval('case @val; in 1; "one"; in 2; "two"; end', { val: 2 })
111
+ end
112
+
113
+ def test_pattern_matching_with_find_pattern
114
+ assert_equal "found", @box.eval('case [1, 2, 3]; in [*, 2, *]; "found"; end')
115
+ end
116
+
117
+ def test_pattern_matching_with_pin_operator
118
+ assert_equal "matched", @box.eval('y = 1; case 1; in ^y; "matched"; end')
119
+ end
120
+
121
+ def test_pattern_matching_with_multiple_clauses
122
+ assert_equal "second", @box.eval('case [3, 4]; in [1, 2]; "first"; in [3, 4]; "second"; end')
123
+ end
124
+
125
+ def test_pattern_matching_no_match_returns_nil
126
+ assert_nil @box.eval('case 3; in 1; "one"; in 2; "two"; else; nil; end')
127
+ end
128
+
129
+ def test_pattern_matching_with_if_guard
130
+ assert_equal "positive", @box.eval('case 1; in x if x > 0; "positive"; end')
131
+ end
132
+
133
+ def test_pattern_matching_with_unless_guard
134
+ assert_equal "not negative", @box.eval('case 1; in x unless x < 0; "not negative"; end')
135
+ end
136
+
137
+ def test_pattern_matching_guard_no_match
138
+ assert_nil @box.eval('case 1; in x if x < 0; "negative"; else; nil; end')
139
+ end
140
+
141
+ def test_pattern_matching_guard_with_array_pattern
142
+ assert_equal "yes", @box.eval('case [1, 2]; in [a, b] if a > 0; "yes"; end')
143
+ end
144
+
145
+ def test_pattern_matching_guard_with_hash_pattern
146
+ assert_equal "alice", @box.eval('case({name: "alice"}); in {name:} if name.start_with?("a"); name; end')
147
+ end
148
+
149
+ def test_rightward_assignment
150
+ assert_equal 1, @box.eval('[1, 2] => [a, b]; a')
151
+ end
152
+
153
+ def test_pattern_matching_with_jailed_hash
154
+ assert_equal "an article title", @box.eval('case @data; in {title:}; title; end', { data: { title: "an article title" } })
155
+ end
156
+
157
+ def test_hash_shorthand
158
+ # TODO: Remove the check once Ruby 3.1 is the minimum
159
+ if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.1')
160
+ assert_equal({ a: 1, b: 2 }, @box.eval('a = 1; b = 2; { a:, b: }'))
161
+ end
162
+ end
163
+
164
+ def test_endless_range
165
+ assert_equal [3, 4, 5], @box.eval('[1,2,3,4,5][2..]')
166
+ end
167
+
168
+ def test_beginless_range
169
+ assert_equal [1, 2, 3], @box.eval('[1,2,3,4,5][..2]')
170
+ end
171
+
172
+ # attrasgn (@article.status = val) is blocked at parse time
173
+ def test_attrasgn_is_blocked
174
+ assert_raise(Safemode::SecurityError) { @box.eval('@article.status = "new_value"', @assigns) }
175
+ end
176
+
177
+ def test_safe_attrasgn_is_blocked
178
+ assert_raise(Safemode::SecurityError) { @box.eval('@article&.status = "new_value"', @assigns) }
179
+ end
180
+
181
+ # op_asgn2 (@article.status ||= val) is NOT blocked at parse time.
182
+ # NOTE: .to_jail is not inserted on the receiver, so the setter is called
183
+ # directly on the real object, bypassing the Jail whitelist.
184
+ def test_op_asgn2_bypasses_jail
185
+ article = Article.new
186
+ assert_nil article.status
187
+ @box.eval('@article.status ||= "new_value"', { article: article })
188
+ assert_equal "new_value", article.status
189
+ end
190
+
191
+ def test_safe_op_asgn2_bypasses_jail
192
+ article = Article.new
193
+ assert_nil article.status
194
+ @box.eval('@article&.status ||= "new_value"', { article: article })
195
+ assert_equal "new_value", article.status
196
+ end
197
+
198
+ def test_lambda_is_blocked
199
+ assert_raise(Safemode::SecurityError) { @box.eval('-> { 1 }') }
200
+ end
201
+
97
202
  def test_sending_to_method_missing
98
203
  assert_raise_with_message(Safemode::NoMethodError, /#no_such_method/) do
99
204
  @box.eval("@article.no_such_method('arg', key: 'value')", @assigns)
@@ -67,6 +67,107 @@ class TestSafemodeParser < Test::Unit::TestCase
67
67
  end
68
68
  end
69
69
 
70
+ def test_safe_attrasgn_is_disabled
71
+ assert_raise Safemode::SecurityError do
72
+ jail('@article&.title = "new_value"')
73
+ end
74
+ end
75
+
76
+ def test_safe_op_asgn2_is_allowed
77
+ assert_nothing_raised do
78
+ jail('@article&.title ||= "new_value"')
79
+ end
80
+ end
81
+
82
+ def test_lambda_is_disabled
83
+ assert_raise Safemode::SecurityError do
84
+ jail('-> { 1 }')
85
+ end
86
+ end
87
+
88
+ def test_case_in_with_literal
89
+ jailed = jail("case x; in 1; \"one\"; in 2; \"two\"; end")
90
+ assert_match(/in 1 then/, jailed)
91
+ assert_match(/in 2 then/, jailed)
92
+ assert_match(/to_jail\.x/, jailed)
93
+ end
94
+
95
+ def test_case_in_with_array_pattern
96
+ jailed = jail("case x; in [a, b]; a; end")
97
+ assert_match(/in \[a, b\] then/, jailed)
98
+ assert_no_match(/\^a/, jailed)
99
+ end
100
+
101
+ def test_case_in_with_hash_pattern
102
+ jailed = jail("case x; in {name:}; name; end")
103
+ assert_match(/in \{ name: \} then/, jailed)
104
+ assert_no_match(/\^name/, jailed)
105
+ end
106
+
107
+ def test_case_in_with_find_pattern
108
+ jailed = jail("case x; in [*, 2, *]; \"found\"; end")
109
+ assert_match(/in \[\*, 2, \*\] then/, jailed)
110
+ end
111
+
112
+ def test_case_in_pin_operator
113
+ jailed = jail("y = 1; case x; in ^y; true; end")
114
+ assert_match(/in \^y then/, jailed)
115
+ end
116
+
117
+ def test_case_in_body_does_not_pin_variables
118
+ jailed = jail("case x; in [a, b]; a; end")
119
+ lines = jailed.lines
120
+ body_start = lines.index { |l| l.match?(/^in \[/) } + 1
121
+ lines[body_start..].each { |line| assert_no_match(/\^[a-z]/, line) }
122
+ end
123
+
124
+ def test_case_in_multiple_clauses
125
+ jailed = jail("case x; in [a, b]; a; in {c:}; c; end")
126
+ assert_match(/in \[a, b\] then/, jailed)
127
+ assert_match(/in \{ c: \} then/, jailed)
128
+ end
129
+
130
+ def test_case_in_with_if_guard
131
+ jailed = jail("case x; in 1 if true; \"matched\"; end")
132
+ assert_match(/in 1 if true then/, jailed)
133
+ end
134
+
135
+ def test_case_in_with_unless_guard
136
+ jailed = jail("case x; in 1 unless false; \"matched\"; end")
137
+ assert_match(/in 1 unless false then/, jailed)
138
+ end
139
+
140
+ def test_case_in_guard_does_not_pin_variables
141
+ jailed = jail("case x; in [a, b] if a; a; end")
142
+ guard_line = jailed.lines.find { |l| l.match?(/^in \[/) }
143
+ assert_match(/if a then/, guard_line)
144
+ assert_no_match(/\^a/, guard_line)
145
+ end
146
+
147
+ def test_case_in_guard_jails_method_calls
148
+ jailed = jail('case x; in {name: n} if n.start_with?("a"); n; end')
149
+ assert_match(/if n\.to_jail\.start_with\?/, jailed)
150
+ end
151
+
152
+ def test_rightward_assignment
153
+ jailed = jail("x => a")
154
+ assert_match(/case to_jail\.x/, jailed)
155
+ assert_match(/in a then/, jailed)
156
+ end
157
+
158
+ def test_hash_shorthand_in_literal
159
+ jailed = jail("a = 1; b = 2; { a:, b: }")
160
+ assert_match(/a:,/, jailed)
161
+ end
162
+
163
+ def test_endless_range
164
+ assert_jailed "(1..)", "(1..)"
165
+ end
166
+
167
+ def test_beginless_range
168
+ assert_jailed "(..5)", "(..5)"
169
+ end
170
+
70
171
  private
71
172
 
72
173
  def assert_jailed(expected, code)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safemode
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sven Fuchs
@@ -10,7 +10,6 @@ authors:
10
10
  - Kingsley Hendrickse
11
11
  - Ohad Levy
12
12
  - Dmitri Dolguikh
13
- autorequire:
14
13
  bindir: bin
15
14
  cert_chain: []
16
15
  date: 2026-05-21 00:00:00.000000000 Z
@@ -30,19 +29,19 @@ dependencies:
30
29
  - !ruby/object:Gem::Version
31
30
  version: '2.4'
32
31
  - !ruby/object:Gem::Dependency
33
- name: ruby_parser
32
+ name: prism
34
33
  requirement: !ruby/object:Gem::Requirement
35
34
  requirements:
36
35
  - - "~>"
37
36
  - !ruby/object:Gem::Version
38
- version: '3.10'
37
+ version: '1.0'
39
38
  type: :runtime
40
39
  prerelease: false
41
40
  version_requirements: !ruby/object:Gem::Requirement
42
41
  requirements:
43
42
  - - "~>"
44
43
  - !ruby/object:Gem::Version
45
- version: '3.10'
44
+ version: '1.0'
46
45
  - !ruby/object:Gem::Dependency
47
46
  name: sexp_processor
48
47
  requirement: !ruby/object:Gem::Requirement
@@ -115,7 +114,6 @@ dependencies:
115
114
  version: '3.7'
116
115
  description: A library for safe evaluation of Ruby code based on RubyParser and Ruby2Ruby.
117
116
  Provides Rails ActionView template handlers for ERB and Haml.
118
- email:
119
117
  executables: []
120
118
  extensions: []
121
119
  extra_rdoc_files:
@@ -150,7 +148,6 @@ homepage: https://github.com/svenfuchs/safemode
150
148
  licenses:
151
149
  - MIT
152
150
  metadata: {}
153
- post_install_message:
154
151
  rdoc_options: []
155
152
  require_paths:
156
153
  - lib
@@ -158,18 +155,17 @@ required_ruby_version: !ruby/object:Gem::Requirement
158
155
  requirements:
159
156
  - - ">="
160
157
  - !ruby/object:Gem::Version
161
- version: '2.7'
158
+ version: '3.0'
162
159
  - - "<"
163
160
  - !ruby/object:Gem::Version
164
- version: '3.2'
161
+ version: '3.4'
165
162
  required_rubygems_version: !ruby/object:Gem::Requirement
166
163
  requirements:
167
164
  - - ">="
168
165
  - !ruby/object:Gem::Version
169
166
  version: '0'
170
167
  requirements: []
171
- rubygems_version: 3.2.33
172
- signing_key:
168
+ rubygems_version: 4.0.10
173
169
  specification_version: 4
174
- summary: A library for safe evaluation of Ruby code based on RubyParser and Ruby2Ruby
170
+ summary: A library for safe evaluation of Ruby code based on Prism and Ruby2Ruby
175
171
  test_files: []