audition 0.2.4 → 0.4.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 +4 -4
- data/README.md +175 -39
- data/lib/audition/bundle_sweep.rb +32 -15
- data/lib/audition/cli.rb +181 -100
- data/lib/audition/config.rb +33 -7
- data/lib/audition/dynamic/harness.rb +323 -31
- data/lib/audition/dynamic/prober.rb +190 -43
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +69 -0
- data/lib/audition/report/json.rb +68 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +160 -0
- data/lib/audition/report.rb +16 -295
- data/lib/audition/rewriters.rb +8 -5
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/mutable_constants.rb +162 -28
- data/lib/audition/static/checks/runtime_require.rb +4 -1
- data/lib/audition/static/checks/unsafe_calls.rb +7 -6
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +5 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1053 -12
- data/lib/audition/static/literal_classifier.rb +191 -20
- data/lib/audition/static/native_extensions.rb +175 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +147 -14
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +15 -4
|
@@ -0,0 +1,1770 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Audition
|
|
6
|
+
module Static
|
|
7
|
+
# Call sites into bundled gems whose compiled extensions do
|
|
8
|
+
# not declare Ractor safety (rb_ext_ractor_safe), so calling
|
|
9
|
+
# into them from a non-main Ractor raises Ractor::UnsafeError.
|
|
10
|
+
# The extension's code is outside the scanned tree; the call
|
|
11
|
+
# site is the only place a static pass can flag.
|
|
12
|
+
#
|
|
13
|
+
# Rules are derived from the target's own bundle, never from a
|
|
14
|
+
# gem list: a pinned gem whose installed extension lacks the
|
|
15
|
+
# declaration—or that pins a platform-specific build Audition
|
|
16
|
+
# cannot inspect (the running Ruby's own copy of the extension
|
|
17
|
+
# settles it when one is shipped)—gets its call sites
|
|
18
|
+
# flagged, anchored to the
|
|
19
|
+
# gem's own namespace (read from its entry file's module
|
|
20
|
+
# nesting, or the require-path convention when the gem is not
|
|
21
|
+
# installed). Matched calls taint: the value handed back is
|
|
22
|
+
# presumed to still live in the extension, so calls chained
|
|
23
|
+
# onto it, or on the local or ivar it is assigned to, are
|
|
24
|
+
# flagged and keep the taint moving; a predicate ends the
|
|
25
|
+
# chain, and freeze, tap, itself, and class pass it along
|
|
26
|
+
# silently. Sorbet
|
|
27
|
+
# annotations extend the reach—a sig param, T.let, or T.cast
|
|
28
|
+
# typed with a constant under a flagged gem's namespace taints
|
|
29
|
+
# the annotated variable the same way, and a plain type clears
|
|
30
|
+
# the guess. A per-class pre-pass makes the body order-free:
|
|
31
|
+
# a method whose return expression or sig return type is
|
|
32
|
+
# rooted in a flagged namespace hands the taint to callers on
|
|
33
|
+
# self, self.class, or the class's own constant, and an ivar
|
|
34
|
+
# assigned such a value anywhere in the body—directly or
|
|
35
|
+
# through its attr writer—is tainted throughout. A pass over
|
|
36
|
+
# the whole tree first promotes classes that hold extension
|
|
37
|
+
# values in their instances—or subclass one that does—to
|
|
38
|
+
# rules of their own, so constructing or receiving one in
|
|
39
|
+
# another file carries the taint across files.
|
|
40
|
+
class GemCalls
|
|
41
|
+
UNSAFE_WHY =
|
|
42
|
+
"%{gem}'s compiled extension does not declare Ractor " \
|
|
43
|
+
"safety (rb_ext_ractor_safe), so its methods raise " \
|
|
44
|
+
"Ractor::UnsafeError (\"ractor unsafe method called from " \
|
|
45
|
+
"not main ractor\") from any non-main Ractor."
|
|
46
|
+
|
|
47
|
+
UNVERIFIED_WHY =
|
|
48
|
+
"%{gem} pins a compiled extension Audition could not " \
|
|
49
|
+
"inspect (the gem is not installed here). An extension " \
|
|
50
|
+
"that does not declare Ractor safety " \
|
|
51
|
+
"(rb_ext_ractor_safe) raises Ractor::UnsafeError from " \
|
|
52
|
+
"any non-main Ractor; install the bundle to verify."
|
|
53
|
+
|
|
54
|
+
MESSAGE =
|
|
55
|
+
"%{receiver}.%{method} calls into %{gem}, whose compiled " \
|
|
56
|
+
"extension does not declare Ractor safety"
|
|
57
|
+
|
|
58
|
+
UNVERIFIED_MESSAGE =
|
|
59
|
+
"%{receiver}.%{method} calls into %{gem}, which pins a " \
|
|
60
|
+
"compiled extension Audition could not inspect"
|
|
61
|
+
|
|
62
|
+
DERIVED_MESSAGE =
|
|
63
|
+
"%{method} called on a value handed out by %{gem}'s " \
|
|
64
|
+
"native extension"
|
|
65
|
+
|
|
66
|
+
FIX =
|
|
67
|
+
"Keep calls into %{gem} on the main Ractor and share " \
|
|
68
|
+
"only extracted plain data (frozen strings, numbers) " \
|
|
69
|
+
"between Ractors, or get the extension to declare " \
|
|
70
|
+
"rb_ext_ractor_safe(true)."
|
|
71
|
+
|
|
72
|
+
# Object/Kernel identity methods that never enter the
|
|
73
|
+
# extension, even on a gem object.
|
|
74
|
+
CORE_METHODS = Ractor.make_shareable(
|
|
75
|
+
Set.new(
|
|
76
|
+
%i[nil? is_a? kind_of? instance_of? respond_to? frozen?
|
|
77
|
+
equal? class object_id itself hash tap then freeze]
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Core methods that hand back the receiver—or, for class,
|
|
82
|
+
# the extension's own class object: no finding, but a
|
|
83
|
+
# tainted receiver's taint passes through.
|
|
84
|
+
CHAIN_METHODS = Ractor.make_shareable(
|
|
85
|
+
Set.new(%i[class itself tap freeze])
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
CHECK = "native-gem-calls"
|
|
89
|
+
|
|
90
|
+
EMPTY_NESTING = Ractor.make_shareable([])
|
|
91
|
+
|
|
92
|
+
# Predicates whose constant argument proves the receiver's
|
|
93
|
+
# class when they gate a branch.
|
|
94
|
+
TYPE_CHECKS = Ractor.make_shareable(
|
|
95
|
+
Set.new(%i[is_a? kind_of? instance_of?])
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
EVAL_REOPENINGS = Ractor.make_shareable(
|
|
99
|
+
Set.new(%i[class_eval module_eval])
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Methods that hand the receiver itself to their block.
|
|
103
|
+
YIELD_SELF = Ractor.make_shareable(
|
|
104
|
+
Set.new(%i[then yield_self])
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
EMPTY_SET = Ractor.make_shareable(Set.new)
|
|
108
|
+
|
|
109
|
+
# What a stub has to show before its gem counts as compiled:
|
|
110
|
+
# one source location holding this share of the gem's located
|
|
111
|
+
# methods, across at least this many classes.
|
|
112
|
+
STUB_SHARE = 0.6
|
|
113
|
+
STUB_OWNERS = 2
|
|
114
|
+
STUB_SOURCE = %r{# source://(\S+)}
|
|
115
|
+
STUB_DEF = /\A\s*def [\w\[\]<>=!+\-*\/%~^&|?]/
|
|
116
|
+
STUB_SCOPE = /\A\s*(?:class|module)\s+([A-Za-z0-9_:]+)/
|
|
117
|
+
|
|
118
|
+
# Everything an object answers before any extension gets
|
|
119
|
+
# involved: a bare call to anything else inside a reopened
|
|
120
|
+
# bound class lands in the extension.
|
|
121
|
+
RUBY_METHODS = Ractor.make_shareable(
|
|
122
|
+
Set.new(
|
|
123
|
+
[Object, Kernel, Module, Class].flat_map do |mod|
|
|
124
|
+
mod.instance_methods + mod.private_instance_methods
|
|
125
|
+
end
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Core iterators hand elements to their block; these
|
|
130
|
+
# positions carry the memo or index instead.
|
|
131
|
+
BLOCK_MEMO_POSITIONS = Ractor.make_shareable(
|
|
132
|
+
{each_with_object: 1, with_object: 1, each_with_index: 1,
|
|
133
|
+
with_index: 1, inject: 0, reduce: 0}
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# One flagged gem: verified means its installed extension
|
|
137
|
+
# binary was read and lacks the declaration; unverified
|
|
138
|
+
# means the lockfile proves compiled code exists but no
|
|
139
|
+
# binary was available to read. A bound rule's namespace IS
|
|
140
|
+
# the extension's own class or module—the gem namespace,
|
|
141
|
+
# a constant assigned an extension-rooted value, or a
|
|
142
|
+
# subclass of either—so reopening it defines methods on
|
|
143
|
+
# extension instances; an unbound rule marks app code that
|
|
144
|
+
# merely holds extension values.
|
|
145
|
+
Rule = Data.define(:gem, :namespace, :verified, :bound) do
|
|
146
|
+
def initialize(gem:, namespace:, verified:, bound: false)
|
|
147
|
+
super
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @param root [String] target root, where Gemfile.lock lives
|
|
152
|
+
# @param stubs [Array<String>] the target's .rbi stubs
|
|
153
|
+
# @param rules [Array<Rule>, nil] override bundle resolution
|
|
154
|
+
def initialize(root:, stubs: [], rules: nil)
|
|
155
|
+
@stubs = stubs
|
|
156
|
+
@rules = rules || resolve(root)
|
|
157
|
+
@nesting = EMPTY_NESTING
|
|
158
|
+
@self_rule = nil
|
|
159
|
+
@self_defs = EMPTY_SET
|
|
160
|
+
@param_seeds = {}
|
|
161
|
+
@return_taints = {}
|
|
162
|
+
reset_seed_ledger
|
|
163
|
+
index_rules
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @param paths [Array<String>] files to scan
|
|
167
|
+
# @param progress [Progress] narrates the
|
|
168
|
+
# three passes this phase makes over the tree
|
|
169
|
+
# @return [Array<Finding>]
|
|
170
|
+
def analyze_paths(paths, progress: Progress::SILENT)
|
|
171
|
+
return [] if @rules.empty?
|
|
172
|
+
|
|
173
|
+
progress.stage("subclasses", total: paths.size)
|
|
174
|
+
@rules += derived_class_rules(paths, progress)
|
|
175
|
+
index_rules
|
|
176
|
+
@param_seeds = {}
|
|
177
|
+
reset_seed_ledger
|
|
178
|
+
findings = {}
|
|
179
|
+
progress.stage("scanning", total: paths.size)
|
|
180
|
+
paths.each do |path|
|
|
181
|
+
progress.tick
|
|
182
|
+
source = read_source(path)
|
|
183
|
+
findings[path] = analyze_file(path, source) if source
|
|
184
|
+
end
|
|
185
|
+
settle_seeds(findings, progress)
|
|
186
|
+
findings.values.flatten(1)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
# A call in one file seeds a parameter of a method defined
|
|
192
|
+
# in another, so a file walked before the call was seen has
|
|
193
|
+
# to walk again. Rounds are capped: each one costs a walk of
|
|
194
|
+
# every file the round before it taught something new.
|
|
195
|
+
SEED_ROUNDS = 3
|
|
196
|
+
|
|
197
|
+
def reset_seed_ledger
|
|
198
|
+
@seed_clock = 0
|
|
199
|
+
@seed_at = {}
|
|
200
|
+
@def_keys = {}
|
|
201
|
+
@walked_at = {}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def settle_seeds(findings, progress = Progress::SILENT)
|
|
205
|
+
SEED_ROUNDS.times do |round|
|
|
206
|
+
stale = findings.keys.select { |path| stale?(path) }
|
|
207
|
+
break if stale.empty?
|
|
208
|
+
|
|
209
|
+
progress.stage("settling #{round + 1}", total: stale.size)
|
|
210
|
+
stale.each do |path|
|
|
211
|
+
progress.tick
|
|
212
|
+
source = read_source(path)
|
|
213
|
+
findings[path] = analyze_file(path, source) if source
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def stale?(path)
|
|
219
|
+
walked = @walked_at[path]
|
|
220
|
+
return false unless walked
|
|
221
|
+
|
|
222
|
+
@def_keys[path].any? do |key|
|
|
223
|
+
(@seed_at[key] || -1) > walked
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def read_source(path)
|
|
228
|
+
File.read(path)
|
|
229
|
+
rescue SystemCallError
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def resolve(root)
|
|
234
|
+
lockfile = File.join(root, "Gemfile.lock")
|
|
235
|
+
return [] unless File.file?(lockfile)
|
|
236
|
+
|
|
237
|
+
pinned(lockfile).flat_map do |name, version, platform|
|
|
238
|
+
rules_for(name, version, platform)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Lockfile rows collapse platform variants into one gem with
|
|
243
|
+
# a platform mark: a platform suffix on any variant proves
|
|
244
|
+
# the gem ships compiled code.
|
|
245
|
+
def pinned(lockfile)
|
|
246
|
+
rows = {}
|
|
247
|
+
File.foreach(lockfile) do |line|
|
|
248
|
+
match = line.match(/\A ([A-Za-z0-9_-]+) \(([^)\s]+)\)/)
|
|
249
|
+
next unless match
|
|
250
|
+
|
|
251
|
+
version, platform =
|
|
252
|
+
match[2].match(/\A([0-9][\w.]*?)(?:-(.+))?\z/)&.captures
|
|
253
|
+
next unless version
|
|
254
|
+
|
|
255
|
+
row = rows[match[1]] ||= [match[1], version, nil]
|
|
256
|
+
row[2] ||= platform
|
|
257
|
+
end
|
|
258
|
+
rows.values
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# An installed copy is the best evidence. Without one, the
|
|
262
|
+
# generated type stubs in the target's own tree name the
|
|
263
|
+
# classes whose methods are not Ruby-defined, and a
|
|
264
|
+
# platform pin proves compiled code ships under the gem's
|
|
265
|
+
# own name: both hold, so both are flagged.
|
|
266
|
+
def rules_for(name, version, platform)
|
|
267
|
+
spec = installed_spec(name, version)
|
|
268
|
+
return Array(installed_rule(name, spec)) if spec
|
|
269
|
+
|
|
270
|
+
namespaces = stub_namespaces(name, version)
|
|
271
|
+
namespaces += [convention_namespace(name)] if platform
|
|
272
|
+
namespaces.uniq.filter_map { |ns| unverified_rule(name, ns) }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def installed_rule(name, spec)
|
|
276
|
+
compiled = Target.compiled_for(spec)
|
|
277
|
+
if compiled.any?
|
|
278
|
+
return nil if compiled.all? { |path| declares_safety?(path) }
|
|
279
|
+
|
|
280
|
+
Rule.new(gem: name, namespace: namespace_for(name, spec),
|
|
281
|
+
verified: true, bound: true)
|
|
282
|
+
elsif spec.extensions.any?
|
|
283
|
+
unverified_rule(name, namespace_for(name, spec))
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# A pin with no readable binary—a platform build the
|
|
288
|
+
# bundle did not install, or a default gem whose extension
|
|
289
|
+
# lives outside its require paths—can still be judged when
|
|
290
|
+
# the running Ruby ships the same extension: Ruby's own
|
|
291
|
+
# binary is the evidence.
|
|
292
|
+
def unverified_rule(name, namespace)
|
|
293
|
+
shipped = ruby_shipped_extension(name)
|
|
294
|
+
unless shipped
|
|
295
|
+
return Rule.new(gem: name, namespace: namespace,
|
|
296
|
+
verified: false, bound: true)
|
|
297
|
+
end
|
|
298
|
+
return nil if declares_safety?(shipped)
|
|
299
|
+
|
|
300
|
+
Rule.new(gem: name, namespace: namespace, verified: true,
|
|
301
|
+
bound: true)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def ruby_shipped_extension(name)
|
|
305
|
+
base = File.join(RbConfig::CONFIG["archdir"],
|
|
306
|
+
name.tr("-", "/"))
|
|
307
|
+
Dir["#{base}.{so,bundle}"].first
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Bundler hides gems outside the current bundle from
|
|
311
|
+
# find_by_name, so fall back to reading gemspecs straight
|
|
312
|
+
# from the installed specification directories.
|
|
313
|
+
def installed_spec(name, version)
|
|
314
|
+
Gem::Specification.find_by_name(name, version)
|
|
315
|
+
rescue Gem::LoadError
|
|
316
|
+
Gem.path.each do |base|
|
|
317
|
+
pattern = File.join(base, "specifications",
|
|
318
|
+
"#{name}-#{version}{,-*}.gemspec")
|
|
319
|
+
Dir[pattern].each do |path|
|
|
320
|
+
spec = Gem::Specification.load(path)
|
|
321
|
+
return spec if spec&.name == name
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
nil
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def declares_safety?(path)
|
|
328
|
+
File.binread(path).include?(NativeExtensions::SYMBOL)
|
|
329
|
+
rescue SystemCallError
|
|
330
|
+
false
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# A generated type stub is the target's own record of what a
|
|
334
|
+
# gem's methods look like. The generator writes each method's
|
|
335
|
+
# Ruby source location, and methods a compiled extension
|
|
336
|
+
# defines all land on the one line that loads it: a single
|
|
337
|
+
# location holding most of a gem's methods, spread over
|
|
338
|
+
# several classes, is that shape. A metaprogramming block
|
|
339
|
+
# defines its methods on the class it sits in, so one owner
|
|
340
|
+
# is not evidence. The owners of that cluster are the
|
|
341
|
+
# extension's own classes, which name it better than any
|
|
342
|
+
# convention can.
|
|
343
|
+
# Generators name a gem's stub for the gem and version it
|
|
344
|
+
# documents, which identifies it wherever the generator was
|
|
345
|
+
# told to write.
|
|
346
|
+
def stub_namespaces(name, version)
|
|
347
|
+
wanted = "#{name}@#{version}.rbi"
|
|
348
|
+
path = @stubs.find { |s| File.basename(s) == wanted }
|
|
349
|
+
return [] unless path
|
|
350
|
+
|
|
351
|
+
clusters = stub_clusters(path)
|
|
352
|
+
located = clusters.sum { |_, owners| owners.values.sum }
|
|
353
|
+
return [] if located.zero?
|
|
354
|
+
|
|
355
|
+
owners = clusters.each_value.max_by { |o| o.values.sum }
|
|
356
|
+
return [] if owners.size < STUB_OWNERS ||
|
|
357
|
+
owners.values.sum < located * STUB_SHARE
|
|
358
|
+
|
|
359
|
+
owners.keys
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def stub_clusters(path)
|
|
363
|
+
clusters = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
364
|
+
scope = []
|
|
365
|
+
indents = []
|
|
366
|
+
source = nil
|
|
367
|
+
File.foreach(path) do |raw|
|
|
368
|
+
line = raw.rstrip
|
|
369
|
+
next if line.empty?
|
|
370
|
+
|
|
371
|
+
indent = line[/\A */].size
|
|
372
|
+
while indents.any? && indent <= indents.last
|
|
373
|
+
indents.pop
|
|
374
|
+
scope.pop
|
|
375
|
+
end
|
|
376
|
+
if (match = line.match(STUB_SCOPE))
|
|
377
|
+
scope.push(stub_scope_name(scope, match[1]))
|
|
378
|
+
indents.push(indent)
|
|
379
|
+
source = nil
|
|
380
|
+
elsif (match = line.match(STUB_SOURCE))
|
|
381
|
+
source = match[1]
|
|
382
|
+
else
|
|
383
|
+
if source && scope.last && line.match?(STUB_DEF)
|
|
384
|
+
clusters[source][scope.last] += 1
|
|
385
|
+
end
|
|
386
|
+
source = nil
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
clusters
|
|
390
|
+
rescue SystemCallError
|
|
391
|
+
{}
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Stubs write top-level definitions under their full path and
|
|
395
|
+
# nested ones relative to the enclosing scope.
|
|
396
|
+
def stub_scope_name(scope, written)
|
|
397
|
+
scope.empty? ? written : "#{scope.last}::#{written}"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# The entry file's module nesting names the gem's namespace
|
|
401
|
+
# more reliably than name conventions do.
|
|
402
|
+
def namespace_for(name, spec)
|
|
403
|
+
entry = spec.full_require_paths
|
|
404
|
+
.map { |rp| File.join(rp, "#{name.tr("-", "/")}.rb") }
|
|
405
|
+
.find { |path| File.file?(path) }
|
|
406
|
+
(entry && nesting_namespace(entry)) ||
|
|
407
|
+
convention_namespace(name)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def nesting_namespace(path)
|
|
411
|
+
result = Prism.parse(File.read(path))
|
|
412
|
+
return nil unless result.success?
|
|
413
|
+
|
|
414
|
+
names = []
|
|
415
|
+
body = result.value.statements.body
|
|
416
|
+
while (mod = sole_module(body))
|
|
417
|
+
names << mod.constant_path.location.slice
|
|
418
|
+
.delete_prefix("::")
|
|
419
|
+
body =
|
|
420
|
+
mod.body.is_a?(Prism::StatementsNode) ? mod.body.body : []
|
|
421
|
+
end
|
|
422
|
+
names.join("::") unless names.empty?
|
|
423
|
+
rescue SystemCallError
|
|
424
|
+
nil
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def sole_module(statements)
|
|
428
|
+
mods = statements.select do |node|
|
|
429
|
+
node.is_a?(Prism::ModuleNode) || node.is_a?(Prism::ClassNode)
|
|
430
|
+
end
|
|
431
|
+
mods.first if mods.size == 1
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def convention_namespace(name)
|
|
435
|
+
name.split("-").map do |seg|
|
|
436
|
+
seg.split("_").map(&:capitalize).join
|
|
437
|
+
end.join("::")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Each pass may learn argument seeds and computed return
|
|
441
|
+
# taints that change what an earlier line would flag, so
|
|
442
|
+
# the file re-walks until a pass learns nothing new and
|
|
443
|
+
# only that pass's findings stand.
|
|
444
|
+
def analyze_file(path, source)
|
|
445
|
+
file = SourceFile.new(source: source, path: path)
|
|
446
|
+
return [] unless file.valid_syntax?
|
|
447
|
+
|
|
448
|
+
@path = path
|
|
449
|
+
|
|
450
|
+
@return_taints = {}
|
|
451
|
+
@def_keys[path] = Set.new
|
|
452
|
+
scan = nil
|
|
453
|
+
loop do
|
|
454
|
+
before = knowledge_size
|
|
455
|
+
@registry = nil
|
|
456
|
+
@singleton = false
|
|
457
|
+
@nesting = EMPTY_NESTING
|
|
458
|
+
@self_rule = nil
|
|
459
|
+
@self_defs = EMPTY_SET
|
|
460
|
+
scan = Scan.new(path: path, findings: [], producers: {})
|
|
461
|
+
walk(file.root, {}, {}, scan)
|
|
462
|
+
break if knowledge_size == before
|
|
463
|
+
end
|
|
464
|
+
@walked_at[path] = @seed_clock
|
|
465
|
+
scan.findings
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Seeds and return taints only grow, so the loop stops at
|
|
469
|
+
# the first pass that learns nothing.
|
|
470
|
+
def knowledge_size
|
|
471
|
+
@param_seeds.sum { |_, seeds| seeds.size } +
|
|
472
|
+
@return_taints.size
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
Scan = Data.define(:path, :findings, :producers)
|
|
476
|
+
|
|
477
|
+
# Ordered walk carrying the taint state: tainted maps a local
|
|
478
|
+
# name to the rule whose call produced its value, ivars does
|
|
479
|
+
# the same for instance variables; producers remembers which
|
|
480
|
+
# call nodes are rooted in a flagged namespace so a chained
|
|
481
|
+
# call or an assignment can pick the taint up. Defs open
|
|
482
|
+
# fresh local scopes (seeded from the preceding sig); classes
|
|
483
|
+
# and modules open fresh ivar scopes; blocks close over the
|
|
484
|
+
# enclosing.
|
|
485
|
+
def walk(node, tainted, ivars, scan)
|
|
486
|
+
case node
|
|
487
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
488
|
+
registry, singleton, nesting = @registry, @singleton, @nesting
|
|
489
|
+
self_rule, self_defs = @self_rule, @self_defs
|
|
490
|
+
bound = source_rule(node.constant_path.location.slice)
|
|
491
|
+
@self_rule = bound&.bound ? bound : nil
|
|
492
|
+
@self_defs = @self_rule ? body_defs(node.body) : EMPTY_SET
|
|
493
|
+
@nesting = nested_scope(node)
|
|
494
|
+
@registry, @singleton = build_registry(node), false
|
|
495
|
+
each_child(node) do |child|
|
|
496
|
+
walk(child, {}, @registry.ivars[:instance].dup, scan)
|
|
497
|
+
end
|
|
498
|
+
@registry, @singleton, @nesting = registry, singleton, nesting
|
|
499
|
+
@self_rule, @self_defs = self_rule, self_defs
|
|
500
|
+
when Prism::SingletonClassNode
|
|
501
|
+
if @registry && node.expression.is_a?(Prism::SelfNode)
|
|
502
|
+
singleton = @singleton
|
|
503
|
+
@singleton = true
|
|
504
|
+
each_child(node) do |child|
|
|
505
|
+
walk(child, {}, @registry.ivars[:singleton].dup, scan)
|
|
506
|
+
end
|
|
507
|
+
@singleton = singleton
|
|
508
|
+
else
|
|
509
|
+
registry = @registry
|
|
510
|
+
@registry = nil
|
|
511
|
+
each_child(node) { |child| walk(child, {}, {}, scan) }
|
|
512
|
+
@registry = registry
|
|
513
|
+
end
|
|
514
|
+
when Prism::DefNode
|
|
515
|
+
walk_def(node, nil, ivars, scan)
|
|
516
|
+
when Prism::StatementsNode
|
|
517
|
+
sig = nil
|
|
518
|
+
node.body.each do |child|
|
|
519
|
+
if child.is_a?(Prism::DefNode)
|
|
520
|
+
walk_def(child, sig, ivars, scan)
|
|
521
|
+
else
|
|
522
|
+
walk(child, tainted, ivars, scan)
|
|
523
|
+
guard_taint(child, tainted, ivars)
|
|
524
|
+
end
|
|
525
|
+
sig = sig_node?(child) ? child : nil
|
|
526
|
+
end
|
|
527
|
+
when Prism::CaseNode
|
|
528
|
+
walk_case(node, tainted, ivars, scan)
|
|
529
|
+
when Prism::IfNode
|
|
530
|
+
walk_if(node, tainted, ivars, scan)
|
|
531
|
+
when Prism::CallNode
|
|
532
|
+
if (rule = reopening_rule(node))
|
|
533
|
+
walk_reopening(node, rule, tainted, ivars, scan)
|
|
534
|
+
else
|
|
535
|
+
walk_call(node, tainted, ivars, scan)
|
|
536
|
+
end
|
|
537
|
+
when Prism::LocalVariableWriteNode,
|
|
538
|
+
Prism::LocalVariableOrWriteNode,
|
|
539
|
+
Prism::LocalVariableAndWriteNode,
|
|
540
|
+
Prism::LocalVariableOperatorWriteNode
|
|
541
|
+
walk(node.value, tainted, ivars, scan)
|
|
542
|
+
rule = value_rule(node.value, tainted, ivars, scan)
|
|
543
|
+
rule ? tainted[node.name] = rule : tainted.delete(node.name)
|
|
544
|
+
when Prism::InstanceVariableWriteNode,
|
|
545
|
+
Prism::InstanceVariableOrWriteNode,
|
|
546
|
+
Prism::InstanceVariableAndWriteNode,
|
|
547
|
+
Prism::InstanceVariableOperatorWriteNode
|
|
548
|
+
walk(node.value, tainted, ivars, scan)
|
|
549
|
+
rule = value_rule(node.value, tainted, ivars, scan)
|
|
550
|
+
rule ? ivars[node.name] = rule : ivars.delete(node.name)
|
|
551
|
+
when Prism::MultiWriteNode
|
|
552
|
+
each_child(node) { |child| walk(child, tainted, ivars, scan) }
|
|
553
|
+
multi_targets(node).each do |target|
|
|
554
|
+
case target
|
|
555
|
+
when Prism::LocalVariableTargetNode
|
|
556
|
+
tainted.delete(target.name)
|
|
557
|
+
when Prism::InstanceVariableTargetNode
|
|
558
|
+
ivars.delete(target.name)
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
else
|
|
562
|
+
each_child(node) { |child| walk(child, tainted, ivars, scan) }
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Branches run on their own copy: a value one branch
|
|
567
|
+
# assigns may still be live after the branch, and one
|
|
568
|
+
# every branch replaces is not.
|
|
569
|
+
def walk_if(node, tainted, ivars, scan)
|
|
570
|
+
walk(node.predicate, tainted, ivars, scan)
|
|
571
|
+
key, rule = type_check(node.predicate)
|
|
572
|
+
branches = []
|
|
573
|
+
if node.statements
|
|
574
|
+
copies = [tainted.dup, ivars.dup]
|
|
575
|
+
with_taint(key, rule, *copies) do
|
|
576
|
+
walk(node.statements, *copies, scan)
|
|
577
|
+
end
|
|
578
|
+
branches << copies
|
|
579
|
+
end
|
|
580
|
+
if node.subsequent
|
|
581
|
+
copies = [tainted.dup, ivars.dup]
|
|
582
|
+
walk(node.subsequent, *copies, scan)
|
|
583
|
+
branches << copies
|
|
584
|
+
end
|
|
585
|
+
merge_branches(tainted, ivars, branches,
|
|
586
|
+
node.statements && node.subsequent)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def merge_branches(tainted, ivars, branches, total)
|
|
590
|
+
branches.each do |branch_tainted, branch_ivars|
|
|
591
|
+
branch_tainted.each { |k, v| tainted[k] ||= v }
|
|
592
|
+
branch_ivars.each { |k, v| ivars[k] ||= v }
|
|
593
|
+
end
|
|
594
|
+
return unless total
|
|
595
|
+
|
|
596
|
+
tainted.delete_if do |k, _|
|
|
597
|
+
branches.none? { |t, _| t.key?(k) }
|
|
598
|
+
end
|
|
599
|
+
ivars.delete_if do |k, _|
|
|
600
|
+
branches.none? { |_, i| i.key?(k) }
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def walk_def(node, sig, ivars, scan)
|
|
605
|
+
singleton_def = node.receiver.is_a?(Prism::SelfNode)
|
|
606
|
+
side = (@singleton || singleton_def) ? :singleton : :instance
|
|
607
|
+
tainted = param_taints(node, sig, side)
|
|
608
|
+
if singleton_def && !@singleton
|
|
609
|
+
singleton = @singleton
|
|
610
|
+
@singleton = true
|
|
611
|
+
ivars = @registry ? @registry.ivars[:singleton].dup : {}
|
|
612
|
+
each_child(node) { |child| walk(child, tainted, ivars, scan) }
|
|
613
|
+
@singleton = singleton
|
|
614
|
+
else
|
|
615
|
+
each_child(node) { |child| walk(child, tainted, ivars, scan) }
|
|
616
|
+
end
|
|
617
|
+
note_return_taint(node, side, scan)
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# Sig taints seed the def's scope, then call sites already
|
|
621
|
+
# walked add argument taints for params the sig leaves
|
|
622
|
+
# untyped or erased; a plain constant type keeps the param
|
|
623
|
+
# clean.
|
|
624
|
+
def param_taints(node, sig, side)
|
|
625
|
+
taints = sig ? sig_taints(sig) : {}
|
|
626
|
+
seeds = seeds_for(side, node.name)
|
|
627
|
+
return taints unless seeds
|
|
628
|
+
|
|
629
|
+
plain = sig ? plain_params(sig) : EMPTY_SET
|
|
630
|
+
seeds.each do |key, rule|
|
|
631
|
+
name = seed_param_name(node.parameters, key)
|
|
632
|
+
next if name.nil? || taints.key?(name) ||
|
|
633
|
+
plain.include?(name)
|
|
634
|
+
|
|
635
|
+
taints[name] = rule
|
|
636
|
+
end
|
|
637
|
+
taints
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# A call through the module's own name reaches an instance
|
|
641
|
+
# method the module extended itself with.
|
|
642
|
+
def seeds_for(side, name)
|
|
643
|
+
key = [seed_scope, side, name]
|
|
644
|
+
note_def_key(key)
|
|
645
|
+
seeds = @param_seeds[key]
|
|
646
|
+
return seeds if seeds || side != :instance ||
|
|
647
|
+
!@registry&.self_extended
|
|
648
|
+
|
|
649
|
+
fallback = [seed_scope, :singleton, name]
|
|
650
|
+
note_def_key(fallback)
|
|
651
|
+
@param_seeds[fallback]
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# What this file's definitions would consume, so a seed
|
|
655
|
+
# learned later can call the file back.
|
|
656
|
+
def note_def_key(key)
|
|
657
|
+
@def_keys[@path]&.add(key)
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def seed_param_name(params, key)
|
|
661
|
+
return nil unless params
|
|
662
|
+
|
|
663
|
+
if key.is_a?(Integer)
|
|
664
|
+
requireds = params.requireds
|
|
665
|
+
param = if key < requireds.size
|
|
666
|
+
requireds[key]
|
|
667
|
+
else
|
|
668
|
+
params.optionals[key - requireds.size]
|
|
669
|
+
end
|
|
670
|
+
case param
|
|
671
|
+
when Prism::RequiredParameterNode,
|
|
672
|
+
Prism::OptionalParameterNode
|
|
673
|
+
param.name
|
|
674
|
+
end
|
|
675
|
+
else
|
|
676
|
+
params.keywords.find do |kw|
|
|
677
|
+
kw.respond_to?(:name) && kw.name == key
|
|
678
|
+
end&.name
|
|
679
|
+
end
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# A def whose final expression produces taint hands it to
|
|
683
|
+
# callers even when its sig says nothing; predicates stay
|
|
684
|
+
# plain. Learned during one pass, applied on the next.
|
|
685
|
+
def note_return_taint(node, side, scan)
|
|
686
|
+
return if node.name.end_with?("?")
|
|
687
|
+
|
|
688
|
+
expr = def_return_expr(node)
|
|
689
|
+
rule = expr && scan.producers[expr.object_id]
|
|
690
|
+
@return_taints[[seed_scope, side, node.name]] = rule if rule
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def seed_scope
|
|
694
|
+
@registry&.class_name
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# A bare or self call names a method in the scope being
|
|
698
|
+
# walked. A constant receiver names the scope itself, by its
|
|
699
|
+
# last segment: a call site and a definition rarely spell
|
|
700
|
+
# the path the same way.
|
|
701
|
+
def seed_key(node)
|
|
702
|
+
case node.receiver
|
|
703
|
+
when nil, Prism::SelfNode
|
|
704
|
+
[seed_scope, @singleton ? :singleton : :instance,
|
|
705
|
+
node.name]
|
|
706
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
707
|
+
name = node.receiver.location.slice
|
|
708
|
+
.delete_prefix("::").split("::").last
|
|
709
|
+
[name, :singleton, node.name]
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
# The block walks after the call resolves: a flagged call's
|
|
714
|
+
# block iterates values living in the same extension, so its
|
|
715
|
+
# element parameters carry the taint.
|
|
716
|
+
def walk_call(node, tainted, ivars, scan)
|
|
717
|
+
block = node.block
|
|
718
|
+
each_child(node) do |child|
|
|
719
|
+
walk(child, tainted, ivars, scan) unless child.equal?(block)
|
|
720
|
+
end
|
|
721
|
+
rule = visit_call(node, tainted, ivars, scan) ||
|
|
722
|
+
yielded_self_rule(node, tainted, ivars, scan)
|
|
723
|
+
note_argument_taints(node, tainted, ivars, scan)
|
|
724
|
+
return unless block
|
|
725
|
+
|
|
726
|
+
if rule && block.is_a?(Prism::BlockNode)
|
|
727
|
+
walk_block(node, rule, tainted, ivars, scan)
|
|
728
|
+
else
|
|
729
|
+
walk(block, tainted, ivars, scan)
|
|
730
|
+
end
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
# A tainted argument seeds the named method's parameter for
|
|
734
|
+
# the passes that follow.
|
|
735
|
+
def note_argument_taints(node, tainted, ivars, scan)
|
|
736
|
+
key = seed_key(node)
|
|
737
|
+
return unless key
|
|
738
|
+
|
|
739
|
+
args = node.arguments&.arguments
|
|
740
|
+
return unless args
|
|
741
|
+
|
|
742
|
+
args.each_with_index do |arg, i|
|
|
743
|
+
if arg.is_a?(Prism::KeywordHashNode)
|
|
744
|
+
arg.elements.each do |assoc|
|
|
745
|
+
next unless assoc.is_a?(Prism::AssocNode) &&
|
|
746
|
+
assoc.key.is_a?(Prism::SymbolNode)
|
|
747
|
+
|
|
748
|
+
note_seed(key, assoc.key.unescaped.to_sym,
|
|
749
|
+
value_rule(assoc.value, tainted, ivars, scan))
|
|
750
|
+
end
|
|
751
|
+
else
|
|
752
|
+
note_seed(key, i,
|
|
753
|
+
value_rule(arg, tainted, ivars, scan))
|
|
754
|
+
end
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def note_seed(key, param, rule)
|
|
759
|
+
return unless rule
|
|
760
|
+
|
|
761
|
+
seeds = (@param_seeds[key] ||= {})
|
|
762
|
+
fresh = !seeds.key?(param)
|
|
763
|
+
seeds[param] = rule
|
|
764
|
+
@seed_at[key] = (@seed_clock += 1) if fresh
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
# then and yield_self pass the receiver straight in, so
|
|
768
|
+
# the block parameter stands for the receiver.
|
|
769
|
+
def yielded_self_rule(node, tainted, ivars, scan)
|
|
770
|
+
return nil unless YIELD_SELF.include?(node.name)
|
|
771
|
+
|
|
772
|
+
taint_source(node, tainted, ivars, scan) ||
|
|
773
|
+
receiver_rule(node.receiver)
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# What an expression hands over: a variable read carries
|
|
777
|
+
# whatever the variable holds, self carries the body's own
|
|
778
|
+
# binding, anything else carries what the walk recorded.
|
|
779
|
+
def value_rule(node, tainted, ivars, scan)
|
|
780
|
+
case node
|
|
781
|
+
when Prism::LocalVariableReadNode then tainted[node.name]
|
|
782
|
+
when Prism::InstanceVariableReadNode then ivars[node.name]
|
|
783
|
+
when Prism::SelfNode then @singleton ? nil : @self_rule
|
|
784
|
+
else scan.producers[node.object_id]
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def walk_block(node, rule, tainted, ivars, scan)
|
|
789
|
+
names = element_params(node)
|
|
790
|
+
saved = names.map { |n| [n, tainted.key?(n), tainted[n]] }
|
|
791
|
+
names.each { |n| tainted[n] = rule }
|
|
792
|
+
each_child(node.block) do |child|
|
|
793
|
+
walk(child, tainted, ivars, scan)
|
|
794
|
+
end
|
|
795
|
+
saved.each do |name, had, prev|
|
|
796
|
+
had ? tainted[name] = prev : tainted.delete(name)
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
def element_params(node)
|
|
801
|
+
params = node.block.parameters
|
|
802
|
+
skip = BLOCK_MEMO_POSITIONS[node.name]
|
|
803
|
+
case params
|
|
804
|
+
when Prism::NumberedParametersNode
|
|
805
|
+
(1..params.maximum).filter_map do |i|
|
|
806
|
+
:"_#{i}" unless skip == i - 1
|
|
807
|
+
end
|
|
808
|
+
when Prism::BlockParametersNode
|
|
809
|
+
names = []
|
|
810
|
+
(params.parameters&.requireds || [])
|
|
811
|
+
.each_with_index do |param, i|
|
|
812
|
+
collect_param_names(param, names) unless skip == i
|
|
813
|
+
end
|
|
814
|
+
names
|
|
815
|
+
else
|
|
816
|
+
[]
|
|
817
|
+
end
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def collect_param_names(param, names)
|
|
821
|
+
case param
|
|
822
|
+
when Prism::RequiredParameterNode,
|
|
823
|
+
Prism::LocalVariableTargetNode
|
|
824
|
+
names << param.name
|
|
825
|
+
when Prism::MultiTargetNode
|
|
826
|
+
[*param.lefts, param.rest, *param.rights].compact
|
|
827
|
+
.each { |part| collect_param_names(part, names) }
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
def each_child(node, &block)
|
|
832
|
+
node.child_nodes.compact.each(&block)
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
def nested_scope(node)
|
|
836
|
+
slice = node.constant_path.location.slice
|
|
837
|
+
segments = slice.delete_prefix("::").split("::")
|
|
838
|
+
slice.start_with?("::") ? segments : [*@nesting, *segments]
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
def multi_targets(node)
|
|
842
|
+
[*node.lefts, node.rest, *node.rights].compact
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# A when clause matching the subject against a flagged
|
|
846
|
+
# constant proves its type inside the branch; if every
|
|
847
|
+
# branch proves the same rule, the else clause sees that
|
|
848
|
+
# value too, and a branch handing the value back makes the
|
|
849
|
+
# whole case expression a producer.
|
|
850
|
+
def walk_case(node, tainted, ivars, scan)
|
|
851
|
+
walk(node.predicate, tainted, ivars, scan) if node.predicate
|
|
852
|
+
key = node.predicate && taint_key(node.predicate)
|
|
853
|
+
rules = node.conditions.map { |c| key ? when_rule(c) : nil }
|
|
854
|
+
produced = nil
|
|
855
|
+
node.conditions.each_with_index do |clause, i|
|
|
856
|
+
clause.conditions.each do |cond|
|
|
857
|
+
walk(cond, tainted, ivars, scan) unless constant_type?(cond)
|
|
858
|
+
end
|
|
859
|
+
next unless clause.statements
|
|
860
|
+
|
|
861
|
+
with_taint(key, rules[i], tainted, ivars) do
|
|
862
|
+
walk(clause.statements, tainted, ivars, scan)
|
|
863
|
+
produced ||= branch_rule(clause.statements, key, rules[i], scan)
|
|
864
|
+
end
|
|
865
|
+
end
|
|
866
|
+
if node.else_clause
|
|
867
|
+
shared = (rules.uniq.size == 1) ? rules.first : nil
|
|
868
|
+
with_taint(key, shared, tainted, ivars) do
|
|
869
|
+
walk(node.else_clause, tainted, ivars, scan)
|
|
870
|
+
produced ||=
|
|
871
|
+
branch_rule(node.else_clause.statements, key, shared, scan)
|
|
872
|
+
end
|
|
873
|
+
end
|
|
874
|
+
scan.producers[node.object_id] = produced if produced
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# One rule only when every condition in the clause is a
|
|
878
|
+
# constant resolving to it: mixed or non-constant
|
|
879
|
+
# conditions are not a class match.
|
|
880
|
+
def when_rule(clause)
|
|
881
|
+
rules = clause.conditions.map do |cond|
|
|
882
|
+
constant_type?(cond) ? source_rule(cond.location.slice) : nil
|
|
883
|
+
end
|
|
884
|
+
(rules.uniq.size == 1) ? rules.first : nil
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# The value a branch hands back, seen through trailing
|
|
888
|
+
# modifier conditionals.
|
|
889
|
+
def branch_rule(statements, key, rule, scan)
|
|
890
|
+
tail = statements&.body&.last
|
|
891
|
+
while tail.is_a?(Prism::IfNode) || tail.is_a?(Prism::UnlessNode)
|
|
892
|
+
tail = tail.statements&.body&.last
|
|
893
|
+
end
|
|
894
|
+
return nil unless tail
|
|
895
|
+
|
|
896
|
+
scan.producers[tail.object_id] ||
|
|
897
|
+
(rule if key && taint_key(tail) == key)
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
# Reopening a bound name through class_eval defines methods
|
|
901
|
+
# on the extension's own class; the eval call itself never
|
|
902
|
+
# enters the extension.
|
|
903
|
+
def reopening_rule(node)
|
|
904
|
+
return nil unless EVAL_REOPENINGS.include?(node.name) &&
|
|
905
|
+
node.block.is_a?(Prism::BlockNode)
|
|
906
|
+
|
|
907
|
+
rule = receiver_rule(node.receiver)
|
|
908
|
+
rule&.bound ? rule : nil
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
def walk_reopening(node, rule, tainted, ivars, scan)
|
|
912
|
+
self_rule, self_defs = @self_rule, @self_defs
|
|
913
|
+
@self_rule = rule
|
|
914
|
+
@self_defs = body_defs(node.block.body)
|
|
915
|
+
each_child(node) { |child| walk(child, tainted, ivars, scan) }
|
|
916
|
+
@self_rule, @self_defs = self_rule, self_defs
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
# Names the reopened body itself defines: self-calls on
|
|
920
|
+
# these stay ordinary Ruby.
|
|
921
|
+
def body_defs(body)
|
|
922
|
+
defs = Set.new
|
|
923
|
+
statements =
|
|
924
|
+
body.is_a?(Prism::StatementsNode) ? body.body : []
|
|
925
|
+
statements.each do |stmt|
|
|
926
|
+
case stmt
|
|
927
|
+
when Prism::DefNode
|
|
928
|
+
defs << stmt.name
|
|
929
|
+
when Prism::CallNode
|
|
930
|
+
roles = ATTR_ROLES[stmt.name]
|
|
931
|
+
next unless roles && stmt.receiver.nil?
|
|
932
|
+
|
|
933
|
+
args = stmt.arguments&.arguments || []
|
|
934
|
+
args.grep(Prism::SymbolNode).each do |sym|
|
|
935
|
+
defs << sym.unescaped.to_sym
|
|
936
|
+
if roles.include?(:writer)
|
|
937
|
+
defs << :"#{sym.unescaped}="
|
|
938
|
+
end
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
end
|
|
942
|
+
defs
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# The store slot a subject narrows to: a local or an
|
|
946
|
+
# instance variable; anything else cannot hold taint.
|
|
947
|
+
def taint_key(node)
|
|
948
|
+
case node
|
|
949
|
+
when Prism::LocalVariableReadNode then [:local, node.name]
|
|
950
|
+
when Prism::InstanceVariableReadNode then [:ivar, node.name]
|
|
951
|
+
end
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
def with_taint(key, rule, tainted, ivars)
|
|
955
|
+
return yield unless key && rule
|
|
956
|
+
|
|
957
|
+
store = (key[0] == :local) ? tainted : ivars
|
|
958
|
+
name = key[1]
|
|
959
|
+
had, prev = store.key?(name), store[name]
|
|
960
|
+
store[name] = rule
|
|
961
|
+
yield
|
|
962
|
+
had ? store[name] = prev : store.delete(name)
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
# A guard that bails unless the variable is one of a
|
|
966
|
+
# flagged gem's classes proves its type for whatever
|
|
967
|
+
# follows in the surrounding sequence.
|
|
968
|
+
def guard_taint(child, tainted, ivars)
|
|
969
|
+
return unless child.is_a?(Prism::UnlessNode) &&
|
|
970
|
+
terminates?(child.statements)
|
|
971
|
+
|
|
972
|
+
key, rule = type_check(child.predicate)
|
|
973
|
+
return unless key && rule
|
|
974
|
+
|
|
975
|
+
store = (key[0] == :local) ? tainted : ivars
|
|
976
|
+
store[key[1]] = rule
|
|
977
|
+
end
|
|
978
|
+
|
|
979
|
+
def terminates?(statements)
|
|
980
|
+
last = statements&.body&.last
|
|
981
|
+
last.is_a?(Prism::ReturnNode) || last.is_a?(Prism::NextNode) ||
|
|
982
|
+
last.is_a?(Prism::BreakNode) ||
|
|
983
|
+
(last.is_a?(Prism::CallNode) && last.name == :raise)
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
# x.is_a?(Some::Const) as a type proof for x, through the
|
|
987
|
+
# left side of a && chain.
|
|
988
|
+
def type_check(predicate)
|
|
989
|
+
node = predicate
|
|
990
|
+
node = node.left while node.is_a?(Prism::AndNode)
|
|
991
|
+
return unless node.is_a?(Prism::CallNode) &&
|
|
992
|
+
TYPE_CHECKS.include?(node.name) && node.receiver
|
|
993
|
+
|
|
994
|
+
key = taint_key(node.receiver)
|
|
995
|
+
return unless key
|
|
996
|
+
|
|
997
|
+
args = node.arguments&.arguments
|
|
998
|
+
return unless args&.size == 1 && constant_type?(args.first)
|
|
999
|
+
|
|
1000
|
+
rule = source_rule(args.first.location.slice)
|
|
1001
|
+
[key, rule] if rule
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
# Classes whose instances hold extension values—an ivar or
|
|
1005
|
+
# method rooted in a flagged namespace—become rules under
|
|
1006
|
+
# their own qualified name: constructing one, or receiving
|
|
1007
|
+
# one through a sig or annotation, taints in any file. A
|
|
1008
|
+
# subclass of a promoted or flagged class is promoted too,
|
|
1009
|
+
# and so is a constant assigned a value rooted in a flagged
|
|
1010
|
+
# namespace, the way protobuf-style generated Ruby binds
|
|
1011
|
+
# extension classes to its own names.
|
|
1012
|
+
def derived_class_rules(paths, progress = Progress::SILENT)
|
|
1013
|
+
found = {}
|
|
1014
|
+
mixins = []
|
|
1015
|
+
@nesting = EMPTY_NESTING
|
|
1016
|
+
paths.each do |path|
|
|
1017
|
+
progress.tick
|
|
1018
|
+
source = read_source(path)
|
|
1019
|
+
next unless source
|
|
1020
|
+
|
|
1021
|
+
result = Prism.parse(source)
|
|
1022
|
+
next unless result.success?
|
|
1023
|
+
|
|
1024
|
+
collect_classes(result.value, [], found)
|
|
1025
|
+
collect_mixins(result.value, [], mixins)
|
|
1026
|
+
end
|
|
1027
|
+
settle_subclasses(found)
|
|
1028
|
+
settle_mixins(found, mixins)
|
|
1029
|
+
found.values.filter_map { |info| info[:rule] }
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
# A mixin's instance methods run with the host as self:
|
|
1033
|
+
# include and prepend bind self to a host instance, extend
|
|
1034
|
+
# to the host itself. Either way, a module mixed into a
|
|
1035
|
+
# name bound to an extension value hands that value out
|
|
1036
|
+
# through self.
|
|
1037
|
+
MIXINS = Ractor.make_shareable(
|
|
1038
|
+
Set.new(%i[include prepend extend])
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
def collect_mixins(node, nesting, out)
|
|
1042
|
+
case node
|
|
1043
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
1044
|
+
path = node.constant_path.location.slice
|
|
1045
|
+
.delete_prefix("::")
|
|
1046
|
+
return each_child(node) do |child|
|
|
1047
|
+
collect_mixins(child, [*nesting, path], out)
|
|
1048
|
+
end
|
|
1049
|
+
when Prism::CallNode
|
|
1050
|
+
note_mixin(node, nesting, out)
|
|
1051
|
+
end
|
|
1052
|
+
each_child(node) { |child| collect_mixins(child, nesting, out) }
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# A bare call mixes into the body it sits in; a receiver
|
|
1056
|
+
# names the host itself.
|
|
1057
|
+
def note_mixin(node, nesting, out)
|
|
1058
|
+
return unless MIXINS.include?(node.name)
|
|
1059
|
+
|
|
1060
|
+
host = case node.receiver
|
|
1061
|
+
when nil then nesting.last
|
|
1062
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
1063
|
+
node.receiver.location.slice.delete_prefix("::")
|
|
1064
|
+
end
|
|
1065
|
+
return unless host
|
|
1066
|
+
|
|
1067
|
+
Array(node.arguments&.arguments).each do |arg|
|
|
1068
|
+
next unless constant_type?(arg)
|
|
1069
|
+
|
|
1070
|
+
out << [host, arg.location.slice.delete_prefix("::"),
|
|
1071
|
+
nesting]
|
|
1072
|
+
end
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
def settle_mixins(found, mixins)
|
|
1076
|
+
loop do
|
|
1077
|
+
changed = false
|
|
1078
|
+
mixins.each do |host, mixin, nesting|
|
|
1079
|
+
info = found[mixin] ||= {rule: nil, superclass: nil,
|
|
1080
|
+
nesting: nesting}
|
|
1081
|
+
next if info[:rule]
|
|
1082
|
+
|
|
1083
|
+
rule = resolve_name(host, nesting, found)
|
|
1084
|
+
next unless rule&.bound
|
|
1085
|
+
|
|
1086
|
+
info[:rule] = derived_rule(rule, mixin, bound: true)
|
|
1087
|
+
changed = true
|
|
1088
|
+
end
|
|
1089
|
+
break unless changed
|
|
1090
|
+
end
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
# A reference resolves the way Ruby would: against each
|
|
1094
|
+
# level of the enclosing nesting, then as written.
|
|
1095
|
+
def resolve_name(name, nesting, found)
|
|
1096
|
+
nesting.size.downto(0) do |depth|
|
|
1097
|
+
candidate = [*nesting.first(depth), name].join("::")
|
|
1098
|
+
rule = namespace_rule(candidate) ||
|
|
1099
|
+
found.dig(candidate, :rule)
|
|
1100
|
+
return rule if rule
|
|
1101
|
+
end
|
|
1102
|
+
nil
|
|
1103
|
+
end
|
|
1104
|
+
|
|
1105
|
+
def collect_classes(node, nesting, found)
|
|
1106
|
+
case node
|
|
1107
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
1108
|
+
path = node.constant_path.location.slice
|
|
1109
|
+
.delete_prefix("::")
|
|
1110
|
+
note_class(node, nesting, path, found) if
|
|
1111
|
+
node.is_a?(Prism::ClassNode)
|
|
1112
|
+
return each_child(node) do |child|
|
|
1113
|
+
collect_classes(child, [*nesting, path], found)
|
|
1114
|
+
end
|
|
1115
|
+
when Prism::ConstantWriteNode, Prism::ConstantOrWriteNode,
|
|
1116
|
+
Prism::ConstantPathWriteNode,
|
|
1117
|
+
Prism::ConstantPathOrWriteNode
|
|
1118
|
+
note_constant(node, nesting, found)
|
|
1119
|
+
end
|
|
1120
|
+
each_child(node) { |child| collect_classes(child, nesting, found) }
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
# A constant bound to an extension-rooted value is the value
|
|
1124
|
+
# under a new name; the name inherits the rule.
|
|
1125
|
+
def note_constant(node, nesting, found)
|
|
1126
|
+
path = if node.respond_to?(:target)
|
|
1127
|
+
node.target.location.slice.delete_prefix("::")
|
|
1128
|
+
else
|
|
1129
|
+
node.name.to_s
|
|
1130
|
+
end
|
|
1131
|
+
fqn = [*nesting, path].join("::")
|
|
1132
|
+
return if namespace_rule(fqn) || found.dig(fqn, :rule)
|
|
1133
|
+
|
|
1134
|
+
@nesting = nesting
|
|
1135
|
+
taint = prepass_rule(node.value, :instance, EMPTY_REGISTRY)
|
|
1136
|
+
return unless taint
|
|
1137
|
+
|
|
1138
|
+
found[fqn] = {rule: derived_rule(taint, fqn, bound: true),
|
|
1139
|
+
superclass: nil, nesting: nesting}
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
def note_class(node, nesting, path, found)
|
|
1143
|
+
fqn = [*nesting, path].join("::")
|
|
1144
|
+
return if namespace_rule(fqn)
|
|
1145
|
+
|
|
1146
|
+
@nesting = [*nesting, path]
|
|
1147
|
+
registry = build_registry(node)
|
|
1148
|
+
taint = registry.ivars[:instance].values.first ||
|
|
1149
|
+
registry.methods[:instance].values.first
|
|
1150
|
+
superclass = node.superclass
|
|
1151
|
+
info = found[fqn] ||= {rule: nil, superclass: nil,
|
|
1152
|
+
nesting: nesting}
|
|
1153
|
+
info[:rule] ||= taint && derived_rule(taint, fqn)
|
|
1154
|
+
if info[:superclass].nil? && constant_type?(superclass)
|
|
1155
|
+
info[:superclass] =
|
|
1156
|
+
superclass.location.slice.delete_prefix("::")
|
|
1157
|
+
end
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
def derived_rule(taint, fqn, bound: false)
|
|
1161
|
+
Rule.new(gem: taint.gem, namespace: fqn,
|
|
1162
|
+
verified: taint.verified, bound: bound)
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
# Promotion follows inheritance to a fixpoint: the superclass
|
|
1166
|
+
# reference resolves the way Ruby would, against each level
|
|
1167
|
+
# of the subclass's own nesting, then as written.
|
|
1168
|
+
def settle_subclasses(found)
|
|
1169
|
+
loop do
|
|
1170
|
+
changed = false
|
|
1171
|
+
found.each do |fqn, info|
|
|
1172
|
+
next if info[:rule] || info[:superclass].nil?
|
|
1173
|
+
|
|
1174
|
+
parent = superclass_rule(info, found)
|
|
1175
|
+
next unless parent
|
|
1176
|
+
|
|
1177
|
+
info[:rule] = derived_rule(parent, fqn,
|
|
1178
|
+
bound: parent.bound)
|
|
1179
|
+
changed = true
|
|
1180
|
+
end
|
|
1181
|
+
break unless changed
|
|
1182
|
+
end
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
def superclass_rule(info, found)
|
|
1186
|
+
resolve_name(info[:superclass], info[:nesting], found)
|
|
1187
|
+
end
|
|
1188
|
+
|
|
1189
|
+
# Pre-pass results for one class or module body: methods
|
|
1190
|
+
# whose return value is rooted in a flagged namespace and
|
|
1191
|
+
# ivars assigned such a value, split by definition side.
|
|
1192
|
+
Registry = Data.define(:class_name, :methods, :ivars,
|
|
1193
|
+
:self_extended)
|
|
1194
|
+
|
|
1195
|
+
EMPTY_REGISTRY = Ractor.make_shareable(
|
|
1196
|
+
Registry.new(class_name: "",
|
|
1197
|
+
methods: {instance: {}, singleton: {}},
|
|
1198
|
+
ivars: {instance: {}, singleton: {}},
|
|
1199
|
+
self_extended: false)
|
|
1200
|
+
)
|
|
1201
|
+
|
|
1202
|
+
Entry = Data.define(:side, :name, :return_expr, :ivar_writes,
|
|
1203
|
+
:writer_calls)
|
|
1204
|
+
|
|
1205
|
+
ATTR_ROLES = Ractor.make_shareable(
|
|
1206
|
+
{attr_accessor: %i[reader writer], attr_reader: %i[reader],
|
|
1207
|
+
attr_writer: %i[writer]}
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
WRITER_NAME = /\A[a-z_]\w*=\z/
|
|
1211
|
+
|
|
1212
|
+
# Collects the body's defs, attr declarations, and ivar
|
|
1213
|
+
# writes, then settles taint to a fixpoint, so a factory
|
|
1214
|
+
# defined below its callers still taints them.
|
|
1215
|
+
def build_registry(node)
|
|
1216
|
+
registry = Registry.new(
|
|
1217
|
+
class_name: node.constant_path.location.slice
|
|
1218
|
+
.delete_prefix("::"),
|
|
1219
|
+
methods: {instance: {}, singleton: {}},
|
|
1220
|
+
ivars: {instance: {}, singleton: {}},
|
|
1221
|
+
self_extended: self_extended?(node.body)
|
|
1222
|
+
)
|
|
1223
|
+
entries = []
|
|
1224
|
+
attrs = {instance: {reader: [], writer: []},
|
|
1225
|
+
singleton: {reader: [], writer: []}}
|
|
1226
|
+
body = node.body
|
|
1227
|
+
if body.is_a?(Prism::StatementsNode)
|
|
1228
|
+
collect_entries(body.body, :instance, entries, attrs,
|
|
1229
|
+
registry)
|
|
1230
|
+
end
|
|
1231
|
+
settle(registry, entries, attrs)
|
|
1232
|
+
registry
|
|
1233
|
+
end
|
|
1234
|
+
|
|
1235
|
+
# extend self and a bare module_function make the body's
|
|
1236
|
+
# instance methods singleton methods too, so a call through
|
|
1237
|
+
# the module's own name reaches them.
|
|
1238
|
+
def self_extended?(body)
|
|
1239
|
+
return false unless body.is_a?(Prism::StatementsNode)
|
|
1240
|
+
|
|
1241
|
+
body.body.any? do |stmt|
|
|
1242
|
+
next false unless stmt.is_a?(Prism::CallNode) &&
|
|
1243
|
+
stmt.receiver.nil?
|
|
1244
|
+
|
|
1245
|
+
case stmt.name
|
|
1246
|
+
when :extend
|
|
1247
|
+
Array(stmt.arguments&.arguments)
|
|
1248
|
+
.any?(Prism::SelfNode)
|
|
1249
|
+
when :module_function then stmt.arguments.nil?
|
|
1250
|
+
end
|
|
1251
|
+
end
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1254
|
+
def collect_entries(statements, side, entries, attrs, registry)
|
|
1255
|
+
sig = nil
|
|
1256
|
+
statements.each do |stmt|
|
|
1257
|
+
case stmt
|
|
1258
|
+
when Prism::DefNode
|
|
1259
|
+
def_side =
|
|
1260
|
+
stmt.receiver.is_a?(Prism::SelfNode) ? :singleton : side
|
|
1261
|
+
entries << def_entry(stmt, def_side)
|
|
1262
|
+
note_sig_return(sig, stmt, def_side, registry)
|
|
1263
|
+
when Prism::SingletonClassNode
|
|
1264
|
+
if stmt.expression.is_a?(Prism::SelfNode) &&
|
|
1265
|
+
stmt.body.is_a?(Prism::StatementsNode)
|
|
1266
|
+
collect_entries(stmt.body.body, :singleton, entries,
|
|
1267
|
+
attrs, registry)
|
|
1268
|
+
end
|
|
1269
|
+
when Prism::CallNode
|
|
1270
|
+
collect_attr(stmt, side, attrs)
|
|
1271
|
+
when Prism::InstanceVariableWriteNode,
|
|
1272
|
+
Prism::InstanceVariableOrWriteNode
|
|
1273
|
+
entries << Entry.new(side: :singleton, name: nil,
|
|
1274
|
+
return_expr: nil,
|
|
1275
|
+
ivar_writes: [[stmt.name, stmt.value]],
|
|
1276
|
+
writer_calls: [])
|
|
1277
|
+
end
|
|
1278
|
+
sig = sig_node?(stmt) ? stmt : nil
|
|
1279
|
+
end
|
|
1280
|
+
end
|
|
1281
|
+
|
|
1282
|
+
# A sig return type under a flagged namespace marks the
|
|
1283
|
+
# method as handing out extension values, body regardless.
|
|
1284
|
+
def note_sig_return(sig, def_node, side, registry)
|
|
1285
|
+
return unless sig
|
|
1286
|
+
|
|
1287
|
+
returns = sig_chain_call(sig, :returns)
|
|
1288
|
+
args = returns&.arguments&.arguments
|
|
1289
|
+
rule = args && args.size == 1 && type_rule(args[0])
|
|
1290
|
+
registry.methods[side][def_node.name] = rule if rule
|
|
1291
|
+
end
|
|
1292
|
+
|
|
1293
|
+
def collect_attr(call, side, attrs)
|
|
1294
|
+
roles = ATTR_ROLES[call.name]
|
|
1295
|
+
return unless roles && call.receiver.nil?
|
|
1296
|
+
|
|
1297
|
+
args = call.arguments&.arguments || []
|
|
1298
|
+
args.grep(Prism::SymbolNode).each do |sym|
|
|
1299
|
+
name = sym.unescaped.to_sym
|
|
1300
|
+
roles.each { |role| attrs[side][role] << name }
|
|
1301
|
+
end
|
|
1302
|
+
end
|
|
1303
|
+
|
|
1304
|
+
def def_entry(node, side)
|
|
1305
|
+
ivar_writes = []
|
|
1306
|
+
writer_calls = []
|
|
1307
|
+
scan_def_body(node.body, ivar_writes, writer_calls)
|
|
1308
|
+
Entry.new(side: side, name: node.name,
|
|
1309
|
+
return_expr: def_return_expr(node),
|
|
1310
|
+
ivar_writes: ivar_writes, writer_calls: writer_calls)
|
|
1311
|
+
end
|
|
1312
|
+
|
|
1313
|
+
def scan_def_body(node, ivar_writes, writer_calls)
|
|
1314
|
+
return if node.nil? || node.is_a?(Prism::ClassNode) ||
|
|
1315
|
+
node.is_a?(Prism::ModuleNode) ||
|
|
1316
|
+
node.is_a?(Prism::SingletonClassNode) ||
|
|
1317
|
+
node.is_a?(Prism::DefNode)
|
|
1318
|
+
|
|
1319
|
+
case node
|
|
1320
|
+
when Prism::InstanceVariableWriteNode,
|
|
1321
|
+
Prism::InstanceVariableOrWriteNode
|
|
1322
|
+
ivar_writes << [node.name, node.value]
|
|
1323
|
+
when Prism::CallNode
|
|
1324
|
+
args = node.arguments&.arguments
|
|
1325
|
+
if node.receiver && args && args.size == 1 &&
|
|
1326
|
+
node.name.match?(WRITER_NAME)
|
|
1327
|
+
writer_calls << [node.name.to_s.chomp("=").to_sym,
|
|
1328
|
+
args.first]
|
|
1329
|
+
end
|
|
1330
|
+
end
|
|
1331
|
+
node.child_nodes.compact.each do |child|
|
|
1332
|
+
scan_def_body(child, ivar_writes, writer_calls)
|
|
1333
|
+
end
|
|
1334
|
+
end
|
|
1335
|
+
|
|
1336
|
+
def def_return_expr(node)
|
|
1337
|
+
body = node.body
|
|
1338
|
+
body = body.statements if body.is_a?(Prism::BeginNode)
|
|
1339
|
+
body.body.last if body.is_a?(Prism::StatementsNode)
|
|
1340
|
+
end
|
|
1341
|
+
|
|
1342
|
+
def settle(registry, entries, attrs)
|
|
1343
|
+
loop do
|
|
1344
|
+
changed = false
|
|
1345
|
+
entries.each do |entry|
|
|
1346
|
+
changed = true if settle_entry(entry, registry, attrs)
|
|
1347
|
+
end
|
|
1348
|
+
changed = true if settle_readers(registry, attrs)
|
|
1349
|
+
break unless changed
|
|
1350
|
+
end
|
|
1351
|
+
end
|
|
1352
|
+
|
|
1353
|
+
# An attr reader over a tainted ivar hands the taint out
|
|
1354
|
+
# like a method returning it would.
|
|
1355
|
+
def settle_readers(registry, attrs)
|
|
1356
|
+
changed = false
|
|
1357
|
+
%i[instance singleton].each do |side|
|
|
1358
|
+
attrs[side][:reader].each do |name|
|
|
1359
|
+
next if registry.methods[side][name]
|
|
1360
|
+
|
|
1361
|
+
rule = registry.ivars[side][:"@#{name}"]
|
|
1362
|
+
if rule
|
|
1363
|
+
registry.methods[side][name] = rule
|
|
1364
|
+
changed = true
|
|
1365
|
+
end
|
|
1366
|
+
end
|
|
1367
|
+
end
|
|
1368
|
+
changed
|
|
1369
|
+
end
|
|
1370
|
+
|
|
1371
|
+
def settle_entry(entry, registry, attrs)
|
|
1372
|
+
changed = false
|
|
1373
|
+
if entry.name && !registry.methods[entry.side][entry.name] &&
|
|
1374
|
+
(rule = return_rule(entry.return_expr, entry.side, registry))
|
|
1375
|
+
registry.methods[entry.side][entry.name] = rule
|
|
1376
|
+
changed = true
|
|
1377
|
+
end
|
|
1378
|
+
entry.ivar_writes.each do |ivar, value|
|
|
1379
|
+
next if registry.ivars[entry.side][ivar]
|
|
1380
|
+
|
|
1381
|
+
rule = prepass_rule(value, entry.side, registry)
|
|
1382
|
+
if rule
|
|
1383
|
+
registry.ivars[entry.side][ivar] = rule
|
|
1384
|
+
changed = true
|
|
1385
|
+
end
|
|
1386
|
+
end
|
|
1387
|
+
entry.writer_calls.each do |name, value|
|
|
1388
|
+
side = writer_side(name, entry.side, attrs)
|
|
1389
|
+
next unless side
|
|
1390
|
+
next if registry.ivars[side][:"@#{name}"]
|
|
1391
|
+
|
|
1392
|
+
rule = prepass_rule(value, entry.side, registry)
|
|
1393
|
+
if rule
|
|
1394
|
+
registry.ivars[side][:"@#{name}"] = rule
|
|
1395
|
+
changed = true
|
|
1396
|
+
end
|
|
1397
|
+
end
|
|
1398
|
+
changed
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
# A writer call taints the ivar behind the attr on the side
|
|
1402
|
+
# that declares it, wherever the call sits.
|
|
1403
|
+
def writer_side(name, caller_side, attrs)
|
|
1404
|
+
%i[instance singleton]
|
|
1405
|
+
.sort_by { |side| (side == caller_side) ? 0 : 1 }
|
|
1406
|
+
.find { |side| attrs[side][:writer].include?(name) }
|
|
1407
|
+
end
|
|
1408
|
+
|
|
1409
|
+
def return_rule(expr, side, registry)
|
|
1410
|
+
case expr
|
|
1411
|
+
when Prism::InstanceVariableWriteNode,
|
|
1412
|
+
Prism::InstanceVariableOrWriteNode
|
|
1413
|
+
prepass_rule(expr.value, side, registry)
|
|
1414
|
+
else
|
|
1415
|
+
prepass_rule(expr, side, registry)
|
|
1416
|
+
end
|
|
1417
|
+
end
|
|
1418
|
+
|
|
1419
|
+
# The pre-pass mirror of visit_call's taint sources, over
|
|
1420
|
+
# syntax alone: a chain is tainted when its root is a
|
|
1421
|
+
# flagged constant, a tainted ivar, or a call to a method
|
|
1422
|
+
# the registry already holds.
|
|
1423
|
+
def prepass_rule(node, side, registry)
|
|
1424
|
+
case node
|
|
1425
|
+
when Prism::InstanceVariableReadNode
|
|
1426
|
+
registry.ivars[side][node.name]
|
|
1427
|
+
when Prism::CallNode
|
|
1428
|
+
if CORE_METHODS.include?(node.name)
|
|
1429
|
+
return nil unless CHAIN_METHODS.include?(node.name)
|
|
1430
|
+
|
|
1431
|
+
return prepass_rule(node.receiver, side, registry)
|
|
1432
|
+
end
|
|
1433
|
+
return nil if node.name.end_with?("?")
|
|
1434
|
+
|
|
1435
|
+
if t_call?(node, :let) || t_call?(node, :cast)
|
|
1436
|
+
args = node.arguments&.arguments
|
|
1437
|
+
return (args && args.size >= 2) ? type_rule(args[1]) : nil
|
|
1438
|
+
end
|
|
1439
|
+
prepass_receiver_rule(node, side, registry)
|
|
1440
|
+
end
|
|
1441
|
+
end
|
|
1442
|
+
|
|
1443
|
+
def prepass_receiver_rule(node, side, registry)
|
|
1444
|
+
case (receiver = node.receiver)
|
|
1445
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
1446
|
+
receiver_rule(receiver) ||
|
|
1447
|
+
(registry.methods[:singleton][node.name] if
|
|
1448
|
+
names_class?(receiver, registry.class_name))
|
|
1449
|
+
when Prism::CallNode
|
|
1450
|
+
if receiver.name == :class &&
|
|
1451
|
+
receiver.receiver.is_a?(Prism::SelfNode)
|
|
1452
|
+
registry.methods[:singleton][node.name]
|
|
1453
|
+
else
|
|
1454
|
+
prepass_rule(receiver, side, registry)
|
|
1455
|
+
end
|
|
1456
|
+
when nil, Prism::SelfNode
|
|
1457
|
+
registry.methods[side][node.name]
|
|
1458
|
+
when Prism::InstanceVariableReadNode
|
|
1459
|
+
registry.ivars[side][receiver.name]
|
|
1460
|
+
end
|
|
1461
|
+
end
|
|
1462
|
+
|
|
1463
|
+
# The class's own name in receiver position reaches its
|
|
1464
|
+
# singleton: the full path, or the trailing segments an
|
|
1465
|
+
# inner reference would use.
|
|
1466
|
+
def names_class?(receiver, class_name)
|
|
1467
|
+
path = receiver.location.slice.delete_prefix("::")
|
|
1468
|
+
path == class_name || class_name.end_with?("::#{path}") ||
|
|
1469
|
+
path == class_name.split("::").last
|
|
1470
|
+
end
|
|
1471
|
+
|
|
1472
|
+
# Returns the matched rule so the caller can taint the
|
|
1473
|
+
# call's block even when the return value stays clean.
|
|
1474
|
+
def visit_call(node, tainted, ivars, scan)
|
|
1475
|
+
if CORE_METHODS.include?(node.name)
|
|
1476
|
+
return chain_through(node, tainted, ivars, scan)
|
|
1477
|
+
end
|
|
1478
|
+
|
|
1479
|
+
if (rule = receiver_rule(node.receiver))
|
|
1480
|
+
scan.findings << finding_for(rule, node, scan.path)
|
|
1481
|
+
scan.producers[node.object_id] = rule
|
|
1482
|
+
elsif (rule = annotation_rule(node, scan))
|
|
1483
|
+
scan.producers[node.object_id] = rule
|
|
1484
|
+
elsif (rule = taint_source(node, tainted, ivars, scan))
|
|
1485
|
+
scan.findings << derived_finding(rule, node, scan.path)
|
|
1486
|
+
# A predicate returns a plain boolean and ends the
|
|
1487
|
+
# chain; anything else is presumed to still live in the
|
|
1488
|
+
# extension.
|
|
1489
|
+
unless node.name.end_with?("?")
|
|
1490
|
+
scan.producers[node.object_id] = rule
|
|
1491
|
+
end
|
|
1492
|
+
rule
|
|
1493
|
+
elsif (rule = registry_rule(node))
|
|
1494
|
+
scan.producers[node.object_id] = rule
|
|
1495
|
+
elsif (rule = self_call_rule(node))
|
|
1496
|
+
scan.findings << derived_finding(rule, node, scan.path)
|
|
1497
|
+
unless node.name.end_with?("?")
|
|
1498
|
+
scan.producers[node.object_id] = rule
|
|
1499
|
+
end
|
|
1500
|
+
rule
|
|
1501
|
+
end
|
|
1502
|
+
end
|
|
1503
|
+
|
|
1504
|
+
# Inside a reopened bound class, self is the extension's
|
|
1505
|
+
# own object: a self-call the body does not define lands in
|
|
1506
|
+
# the extension, and so does a bare argumentless call that
|
|
1507
|
+
# is not plain Ruby.
|
|
1508
|
+
def self_call_rule(node)
|
|
1509
|
+
return nil unless @self_rule
|
|
1510
|
+
|
|
1511
|
+
if node.receiver.is_a?(Prism::SelfNode)
|
|
1512
|
+
return nil if @self_defs.include?(node.name)
|
|
1513
|
+
|
|
1514
|
+
@self_rule
|
|
1515
|
+
elsif node.receiver.nil? && node.arguments.nil? && !node.block
|
|
1516
|
+
return nil if @self_defs.include?(node.name) ||
|
|
1517
|
+
RUBY_METHODS.include?(node.name)
|
|
1518
|
+
|
|
1519
|
+
@self_rule
|
|
1520
|
+
end
|
|
1521
|
+
end
|
|
1522
|
+
|
|
1523
|
+
def chain_through(node, tainted, ivars, scan)
|
|
1524
|
+
return unless CHAIN_METHODS.include?(node.name)
|
|
1525
|
+
|
|
1526
|
+
rule = taint_source(node, tainted, ivars, scan) ||
|
|
1527
|
+
receiver_rule(node.receiver)
|
|
1528
|
+
scan.producers[node.object_id] = rule if rule
|
|
1529
|
+
end
|
|
1530
|
+
|
|
1531
|
+
# Calls to methods the pre-pass proved to hand out extension
|
|
1532
|
+
# values produce taint but no finding of their own: the
|
|
1533
|
+
# finding lands where the value is used.
|
|
1534
|
+
def registry_rule(node)
|
|
1535
|
+
return nil unless @registry
|
|
1536
|
+
|
|
1537
|
+
case (receiver = node.receiver)
|
|
1538
|
+
when nil, Prism::SelfNode
|
|
1539
|
+
method_rule(@singleton ? :singleton : :instance, node.name)
|
|
1540
|
+
when Prism::CallNode
|
|
1541
|
+
if receiver.name == :class &&
|
|
1542
|
+
receiver.receiver.is_a?(Prism::SelfNode)
|
|
1543
|
+
method_rule(:singleton, node.name)
|
|
1544
|
+
end
|
|
1545
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
1546
|
+
if names_class?(receiver, @registry.class_name)
|
|
1547
|
+
method_rule(:singleton, node.name)
|
|
1548
|
+
end
|
|
1549
|
+
end
|
|
1550
|
+
end
|
|
1551
|
+
|
|
1552
|
+
def method_rule(side, name)
|
|
1553
|
+
@registry.methods[side][name] ||
|
|
1554
|
+
@return_taints[[seed_scope, side, name]]
|
|
1555
|
+
end
|
|
1556
|
+
|
|
1557
|
+
def taint_source(node, tainted, ivars, scan)
|
|
1558
|
+
case (receiver = node.receiver)
|
|
1559
|
+
when Prism::LocalVariableReadNode
|
|
1560
|
+
tainted[receiver.name]
|
|
1561
|
+
when Prism::InstanceVariableReadNode
|
|
1562
|
+
ivars[receiver.name]
|
|
1563
|
+
when Prism::CallNode
|
|
1564
|
+
scan.producers[receiver.object_id]
|
|
1565
|
+
end
|
|
1566
|
+
end
|
|
1567
|
+
|
|
1568
|
+
# T.let and T.cast assert the value's type: a type under a
|
|
1569
|
+
# flagged namespace taints, any other constant type clears
|
|
1570
|
+
# whatever the value expression suggested.
|
|
1571
|
+
def annotation_rule(node, scan)
|
|
1572
|
+
return nil unless t_call?(node, :let) || t_call?(node, :cast)
|
|
1573
|
+
|
|
1574
|
+
args = node.arguments&.arguments
|
|
1575
|
+
return nil unless args && args.size >= 2
|
|
1576
|
+
|
|
1577
|
+
rule = type_rule(args[1])
|
|
1578
|
+
return rule if rule
|
|
1579
|
+
|
|
1580
|
+
constant_type?(args[1]) ? nil : scan.producers[args[0].object_id]
|
|
1581
|
+
end
|
|
1582
|
+
|
|
1583
|
+
def constant_type?(node)
|
|
1584
|
+
node.is_a?(Prism::ConstantReadNode) ||
|
|
1585
|
+
node.is_a?(Prism::ConstantPathNode)
|
|
1586
|
+
end
|
|
1587
|
+
|
|
1588
|
+
def sig_node?(node)
|
|
1589
|
+
node.is_a?(Prism::CallNode) && node.name == :sig && node.block
|
|
1590
|
+
end
|
|
1591
|
+
|
|
1592
|
+
# Params typed with a constant under a flagged namespace seed
|
|
1593
|
+
# the def's taint scope.
|
|
1594
|
+
def sig_taints(sig)
|
|
1595
|
+
params = sig_chain_call(sig, :params)
|
|
1596
|
+
args = params&.arguments&.arguments
|
|
1597
|
+
return {} unless args
|
|
1598
|
+
|
|
1599
|
+
taints = {}
|
|
1600
|
+
args.grep(Prism::KeywordHashNode).each do |kw|
|
|
1601
|
+
kw.elements.each do |assoc|
|
|
1602
|
+
next unless assoc.is_a?(Prism::AssocNode) &&
|
|
1603
|
+
assoc.key.is_a?(Prism::SymbolNode)
|
|
1604
|
+
|
|
1605
|
+
rule = type_rule(assoc.value)
|
|
1606
|
+
taints[assoc.key.unescaped.to_sym] = rule if rule
|
|
1607
|
+
end
|
|
1608
|
+
end
|
|
1609
|
+
taints
|
|
1610
|
+
end
|
|
1611
|
+
|
|
1612
|
+
# Param names the sig types with a constant outside every
|
|
1613
|
+
# flagged namespace: proven plain, immune to seeding.
|
|
1614
|
+
def plain_params(sig)
|
|
1615
|
+
params = sig_chain_call(sig, :params)
|
|
1616
|
+
args = params&.arguments&.arguments
|
|
1617
|
+
return EMPTY_SET unless args
|
|
1618
|
+
|
|
1619
|
+
plain = Set.new
|
|
1620
|
+
args.grep(Prism::KeywordHashNode).each do |kw|
|
|
1621
|
+
kw.elements.each do |assoc|
|
|
1622
|
+
next unless assoc.is_a?(Prism::AssocNode) &&
|
|
1623
|
+
assoc.key.is_a?(Prism::SymbolNode)
|
|
1624
|
+
next unless plain_type?(assoc.value)
|
|
1625
|
+
|
|
1626
|
+
plain << assoc.key.unescaped.to_sym
|
|
1627
|
+
end
|
|
1628
|
+
end
|
|
1629
|
+
plain
|
|
1630
|
+
end
|
|
1631
|
+
|
|
1632
|
+
# A bare unflagged constant, T.nilable of one, or a T::
|
|
1633
|
+
# container holding only such constants; T.untyped inside
|
|
1634
|
+
# a container keeps the erasure.
|
|
1635
|
+
def plain_type?(node)
|
|
1636
|
+
case node
|
|
1637
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
1638
|
+
source_rule(node.location.slice).nil?
|
|
1639
|
+
when Prism::CallNode
|
|
1640
|
+
args = node.arguments&.arguments
|
|
1641
|
+
if t_call?(node, :nilable)
|
|
1642
|
+
args&.size == 1 && plain_type?(args.first)
|
|
1643
|
+
elsif t_container?(node)
|
|
1644
|
+
!args.nil? && args.all? { |arg| plain_type?(arg) }
|
|
1645
|
+
else
|
|
1646
|
+
false
|
|
1647
|
+
end
|
|
1648
|
+
else
|
|
1649
|
+
false
|
|
1650
|
+
end
|
|
1651
|
+
end
|
|
1652
|
+
|
|
1653
|
+
def t_container?(node)
|
|
1654
|
+
return false unless node.name == :[] &&
|
|
1655
|
+
constant_type?(node.receiver)
|
|
1656
|
+
|
|
1657
|
+
node.receiver.location.slice.delete_prefix("::")
|
|
1658
|
+
.start_with?("T::")
|
|
1659
|
+
end
|
|
1660
|
+
|
|
1661
|
+
def sig_chain_call(sig, name)
|
|
1662
|
+
body = sig.block.body
|
|
1663
|
+
node = body.is_a?(Prism::StatementsNode) ? body.body.first : body
|
|
1664
|
+
while node.is_a?(Prism::CallNode)
|
|
1665
|
+
return node if node.name == name
|
|
1666
|
+
|
|
1667
|
+
node = node.receiver
|
|
1668
|
+
end
|
|
1669
|
+
nil
|
|
1670
|
+
end
|
|
1671
|
+
|
|
1672
|
+
def type_rule(node)
|
|
1673
|
+
case node
|
|
1674
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
1675
|
+
source_rule(node.location.slice)
|
|
1676
|
+
when Prism::CallNode
|
|
1677
|
+
args = node.arguments&.arguments
|
|
1678
|
+
if t_call?(node, :nilable) && args&.size == 1
|
|
1679
|
+
type_rule(args.first)
|
|
1680
|
+
end
|
|
1681
|
+
end
|
|
1682
|
+
end
|
|
1683
|
+
|
|
1684
|
+
def t_call?(node, name)
|
|
1685
|
+
node.name == name &&
|
|
1686
|
+
node.receiver.is_a?(Prism::ConstantReadNode) &&
|
|
1687
|
+
node.receiver.name == :T
|
|
1688
|
+
end
|
|
1689
|
+
|
|
1690
|
+
def index_rules
|
|
1691
|
+
@rule_index = @rules.to_h { |rule| [rule.namespace, rule] }
|
|
1692
|
+
end
|
|
1693
|
+
|
|
1694
|
+
# Cumulative :: prefixes of the path against the namespace
|
|
1695
|
+
# index, shortest first, tried under each level of the given
|
|
1696
|
+
# nesting before the path as written—flat cost however
|
|
1697
|
+
# many classes the tree promotes to rules.
|
|
1698
|
+
def namespace_rule(path, nesting = EMPTY_NESTING)
|
|
1699
|
+
segments = path.split("::")
|
|
1700
|
+
nesting.size.downto(1) do |depth|
|
|
1701
|
+
rule = prefix_rule(nesting.first(depth), segments)
|
|
1702
|
+
return rule if rule
|
|
1703
|
+
end
|
|
1704
|
+
prefix_rule(EMPTY_NESTING, segments)
|
|
1705
|
+
end
|
|
1706
|
+
|
|
1707
|
+
def prefix_rule(base, segments)
|
|
1708
|
+
prefix = base.empty? ? nil : base.join("::")
|
|
1709
|
+
segments.each do |seg|
|
|
1710
|
+
prefix = prefix ? "#{prefix}::#{seg}" : seg
|
|
1711
|
+
rule = @rule_index[prefix]
|
|
1712
|
+
return rule if rule
|
|
1713
|
+
end
|
|
1714
|
+
nil
|
|
1715
|
+
end
|
|
1716
|
+
|
|
1717
|
+
# An anchored path resolves from the root; anything else the
|
|
1718
|
+
# way Ruby would look it up, innermost scope first.
|
|
1719
|
+
def source_rule(slice)
|
|
1720
|
+
if slice.start_with?("::")
|
|
1721
|
+
namespace_rule(slice.delete_prefix("::"))
|
|
1722
|
+
else
|
|
1723
|
+
namespace_rule(slice, @nesting)
|
|
1724
|
+
end
|
|
1725
|
+
end
|
|
1726
|
+
|
|
1727
|
+
def receiver_rule(receiver)
|
|
1728
|
+
return nil unless constant_type?(receiver)
|
|
1729
|
+
|
|
1730
|
+
source_rule(receiver.location.slice)
|
|
1731
|
+
end
|
|
1732
|
+
|
|
1733
|
+
def finding_for(rule, node, path)
|
|
1734
|
+
interp = {
|
|
1735
|
+
method: node.name, gem: rule.gem,
|
|
1736
|
+
receiver: node.receiver.location.slice.delete_prefix("::")
|
|
1737
|
+
}
|
|
1738
|
+
message = rule.verified ? MESSAGE : UNVERIFIED_MESSAGE
|
|
1739
|
+
# In a chained call the node spans its whole receiver;
|
|
1740
|
+
# the method-name line is where the reader looks.
|
|
1741
|
+
location = node.message_loc || node.location
|
|
1742
|
+
Finding.new(
|
|
1743
|
+
check: CHECK,
|
|
1744
|
+
severity: :warning,
|
|
1745
|
+
message: format(message, interp),
|
|
1746
|
+
why: format(rule.verified ? UNSAFE_WHY : UNVERIFIED_WHY, interp),
|
|
1747
|
+
fix: format(FIX, interp),
|
|
1748
|
+
path: path,
|
|
1749
|
+
line: location.start_line,
|
|
1750
|
+
source: node.location.slice.lines.first&.strip
|
|
1751
|
+
)
|
|
1752
|
+
end
|
|
1753
|
+
|
|
1754
|
+
def derived_finding(rule, node, path)
|
|
1755
|
+
interp = {method: node.name, gem: rule.gem}
|
|
1756
|
+
location = node.message_loc || node.location
|
|
1757
|
+
Finding.new(
|
|
1758
|
+
check: CHECK,
|
|
1759
|
+
severity: :warning,
|
|
1760
|
+
message: format(DERIVED_MESSAGE, interp),
|
|
1761
|
+
why: format(rule.verified ? UNSAFE_WHY : UNVERIFIED_WHY, interp),
|
|
1762
|
+
fix: format(FIX, interp),
|
|
1763
|
+
path: path,
|
|
1764
|
+
line: location.start_line,
|
|
1765
|
+
source: node.location.slice.lines.first&.strip
|
|
1766
|
+
)
|
|
1767
|
+
end
|
|
1768
|
+
end
|
|
1769
|
+
end
|
|
1770
|
+
end
|