audition 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +290 -0
- data/exe/audition +6 -0
- data/lib/audition/baseline.rb +66 -0
- data/lib/audition/bundle_sweep.rb +110 -0
- data/lib/audition/cli.rb +415 -0
- data/lib/audition/config.rb +67 -0
- data/lib/audition/directives.rb +56 -0
- data/lib/audition/dynamic/harness.rb +423 -0
- data/lib/audition/dynamic/prober.rb +362 -0
- data/lib/audition/finding.rb +94 -0
- data/lib/audition/fixer.rb +141 -0
- data/lib/audition/report.rb +344 -0
- data/lib/audition/rewriters.rb +459 -0
- data/lib/audition/static/analyzer.rb +96 -0
- data/lib/audition/static/checks/base.rb +160 -0
- data/lib/audition/static/checks/global_variables.rb +67 -0
- data/lib/audition/static/checks/mutable_constants.rb +145 -0
- data/lib/audition/static/checks/ractor_isolation.rb +105 -0
- data/lib/audition/static/checks/runtime_require.rb +126 -0
- data/lib/audition/static/checks/unsafe_calls.rb +166 -0
- data/lib/audition/static/checks.rb +46 -0
- data/lib/audition/static/graph_audit.rb +143 -0
- data/lib/audition/static/literal_classifier.rb +145 -0
- data/lib/audition/static/source_file.rb +109 -0
- data/lib/audition/target.rb +172 -0
- data/lib/audition/version.rb +5 -0
- data/lib/audition.rb +37 -0
- metadata +143 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Audition's dynamic probe harness. Executed as a subprocess, never
|
|
4
|
+
# required into the host process:
|
|
5
|
+
#
|
|
6
|
+
# ruby harness.rb MODE < payload.json
|
|
7
|
+
#
|
|
8
|
+
# Prints exactly one JSON document on stdout and never raises.
|
|
9
|
+
# Stdlib only; must stay runnable on a bare Ruby 4.0.
|
|
10
|
+
|
|
11
|
+
Warning[:experimental] = false
|
|
12
|
+
Thread.report_on_exception = false
|
|
13
|
+
|
|
14
|
+
require "json"
|
|
15
|
+
|
|
16
|
+
module AuditionHarness
|
|
17
|
+
MAX_CONSTS = 5000
|
|
18
|
+
|
|
19
|
+
# Fixtures for capability probes.
|
|
20
|
+
CAP_CONST = [1, 2] # audition:disable mutable-constants
|
|
21
|
+
|
|
22
|
+
class CapClassVar
|
|
23
|
+
@@flag = true # audition:disable class-variables
|
|
24
|
+
|
|
25
|
+
def self.read
|
|
26
|
+
@@flag
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class CapIvar
|
|
31
|
+
@mutable = {"k" => 1} # audition:disable class-level-state
|
|
32
|
+
|
|
33
|
+
def self.write
|
|
34
|
+
@x = 1 # audition:disable class-level-state
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.read_mutable
|
|
38
|
+
@mutable
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
def main(mode, payload, out:)
|
|
45
|
+
result =
|
|
46
|
+
case mode
|
|
47
|
+
when "script_main" then script_main(payload.fetch("path"))
|
|
48
|
+
when "script_ractor" then script_ractor(payload.fetch("path"))
|
|
49
|
+
when "require" then library(payload)
|
|
50
|
+
when "rack" then rack(payload)
|
|
51
|
+
when "rails" then rails(payload)
|
|
52
|
+
when "capabilities" then capabilities
|
|
53
|
+
else {"error" => {"class" => "ArgumentError",
|
|
54
|
+
"message" => "unknown mode #{mode}"}}
|
|
55
|
+
end
|
|
56
|
+
out.puts(JSON.generate(result))
|
|
57
|
+
rescue Exception => e
|
|
58
|
+
out.puts(JSON.generate("error" => describe_error(e)))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def describe_error(error)
|
|
62
|
+
root = unwrap(error)
|
|
63
|
+
{"class" => root.class.name,
|
|
64
|
+
"message" => root.message.to_s[0, 500]}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def unwrap(error)
|
|
68
|
+
if error.is_a?(Ractor::RemoteError) && error.cause
|
|
69
|
+
error.cause
|
|
70
|
+
else
|
|
71
|
+
error
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def in_ractor(*args, &block)
|
|
76
|
+
ractor = Ractor.new(*args, &block)
|
|
77
|
+
{"ok" => true, "value" => jsonable(ractor.value)}
|
|
78
|
+
rescue Exception => e
|
|
79
|
+
{"ok" => false, "error" => describe_error(e)}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def jsonable(value)
|
|
83
|
+
case value
|
|
84
|
+
when Numeric, String, Symbol, true, false, nil then value
|
|
85
|
+
else value.inspect[0, 200]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# -- scripts -----------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def script_main(path)
|
|
92
|
+
load path # audition:disable runtime-require
|
|
93
|
+
{"ok" => true}
|
|
94
|
+
rescue Exception => e
|
|
95
|
+
{"ok" => false, "error" => describe_error(e)}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# `load` is not proxied to the main Ractor (unlike `require` on
|
|
99
|
+
# Ruby 4.0), so the script body truly executes inside the Ractor.
|
|
100
|
+
def script_ractor(path)
|
|
101
|
+
in_ractor(path) do |p|
|
|
102
|
+
load p # audition:disable runtime-require
|
|
103
|
+
:ok
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# -- libraries ---------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def library(payload)
|
|
110
|
+
Array(payload["load_paths"]).each do |lp|
|
|
111
|
+
$LOAD_PATH.unshift(lp) # audition:disable global-variables
|
|
112
|
+
end
|
|
113
|
+
before = Object.constants
|
|
114
|
+
require payload.fetch("feature") # audition:disable runtime-require
|
|
115
|
+
scan(Object.constants - before, root: payload["root"])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Breadth-first walk of every constant the require introduced:
|
|
119
|
+
# plain values get a Ractor.shareable? verdict; classes and modules
|
|
120
|
+
# are inspected for class-level ivars and class variables, then
|
|
121
|
+
# descended into. const_get can raise (autoload failures) and
|
|
122
|
+
# anything can lie; every step is rescued and counted.
|
|
123
|
+
def scan(root_names, root: nil)
|
|
124
|
+
# Loaded features are realpathed by require; the target root
|
|
125
|
+
# must be too, or symlinked paths (macOS /var vs /private/var)
|
|
126
|
+
# break the own-vs-dependency comparison.
|
|
127
|
+
if root
|
|
128
|
+
root = begin
|
|
129
|
+
File.realpath(root)
|
|
130
|
+
rescue
|
|
131
|
+
root
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
unshareable = []
|
|
135
|
+
class_state = []
|
|
136
|
+
class_vars = []
|
|
137
|
+
errors = 0
|
|
138
|
+
seen = {}
|
|
139
|
+
queue = root_names.map { |name| [Object, name.to_s] }
|
|
140
|
+
visited = 0
|
|
141
|
+
|
|
142
|
+
until queue.empty?
|
|
143
|
+
owner, name = queue.shift
|
|
144
|
+
visited += 1
|
|
145
|
+
break if visited > MAX_CONSTS
|
|
146
|
+
|
|
147
|
+
begin
|
|
148
|
+
value = owner.const_get(name, false)
|
|
149
|
+
rescue Exception
|
|
150
|
+
errors += 1
|
|
151
|
+
next
|
|
152
|
+
end
|
|
153
|
+
full = owner.equal?(Object) ? name : "#{owner}::#{name}"
|
|
154
|
+
origin = origin_for(owner, name, root)
|
|
155
|
+
|
|
156
|
+
if value.is_a?(Module)
|
|
157
|
+
next if seen[value.object_id]
|
|
158
|
+
|
|
159
|
+
seen[value.object_id] = true
|
|
160
|
+
errors += inspect_module(full, value, origin,
|
|
161
|
+
class_state, class_vars)
|
|
162
|
+
value.constants(false).each do |child|
|
|
163
|
+
queue << [value, child.to_s]
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
begin
|
|
167
|
+
unless Ractor.shareable?(value)
|
|
168
|
+
unshareable << origin.merge(
|
|
169
|
+
"const" => full, "class" => value.class.name
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
rescue Exception
|
|
173
|
+
errors += 1
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
{"unshareable_constants" => unshareable,
|
|
179
|
+
"class_state" => class_state,
|
|
180
|
+
"class_variables" => class_vars,
|
|
181
|
+
"scanned" => visited,
|
|
182
|
+
"errors" => errors}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Where was this constant defined, and does that location belong
|
|
186
|
+
# to the audited target (as opposed to a dependency it loaded)?
|
|
187
|
+
# Unknown locations (C extensions, core) count as own so nothing
|
|
188
|
+
# gets silently downgraded.
|
|
189
|
+
def origin_for(owner, name, root)
|
|
190
|
+
path, line = begin
|
|
191
|
+
owner.const_source_location(name)
|
|
192
|
+
rescue Exception
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
own = root.nil? || path.nil? || path.start_with?(root)
|
|
196
|
+
{"path" => path, "line" => line, "own" => own}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def inspect_module(full, mod, origin, class_state, class_vars)
|
|
200
|
+
ivars = mod.instance_variables
|
|
201
|
+
if ivars.any?
|
|
202
|
+
shareability = ivars.map do |ivar|
|
|
203
|
+
value = mod.instance_variable_get(ivar)
|
|
204
|
+
[ivar.to_s, safe_shareable?(value)]
|
|
205
|
+
end
|
|
206
|
+
class_state << origin.merge(
|
|
207
|
+
"const" => full,
|
|
208
|
+
"ivars" => shareability.map(&:first),
|
|
209
|
+
"unshareable" => shareability.reject(&:last).map(&:first)
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
cvars = mod.class_variables(false)
|
|
213
|
+
if cvars.any?
|
|
214
|
+
class_vars << origin.merge(
|
|
215
|
+
"const" => full, "cvars" => cvars.map(&:to_s)
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
0
|
|
219
|
+
rescue Exception
|
|
220
|
+
1
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def safe_shareable?(value)
|
|
224
|
+
Ractor.shareable?(value)
|
|
225
|
+
rescue Exception
|
|
226
|
+
false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# -- rack --------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
# App objects built in config.ru are almost never shareable (the
|
|
232
|
+
# file is instance_eval'd inside Rack::Builder, so every lambda's
|
|
233
|
+
# self is the Builder). Ractor web servers therefore boot the app
|
|
234
|
+
# once per Ractor; the probe mirrors that model: parse config.ru
|
|
235
|
+
# and serve one request entirely inside a Ractor.
|
|
236
|
+
def rack(payload)
|
|
237
|
+
config_ru = payload.fetch("config_ru")
|
|
238
|
+
begin
|
|
239
|
+
require "rack" # audition:disable runtime-require
|
|
240
|
+
rescue LoadError
|
|
241
|
+
return {"rack_available" => false}
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
out = {"rack_available" => true}
|
|
245
|
+
begin
|
|
246
|
+
app = Rack::Builder.parse_file(config_ru)
|
|
247
|
+
app = app.first if app.is_a?(Array)
|
|
248
|
+
out["app_class"] = app.class.name
|
|
249
|
+
out["shareable"] = Ractor.shareable?(app)
|
|
250
|
+
rescue Exception => e
|
|
251
|
+
out["main_boot_error"] = describe_error(e)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
out["ractor_boot_call"] = rack_in_ractor(config_ru)
|
|
255
|
+
if out["ractor_boot_call"]["ok"]
|
|
256
|
+
out["concurrency"] = rack_concurrent(
|
|
257
|
+
config_ru,
|
|
258
|
+
payload.fetch("ractors", 4),
|
|
259
|
+
payload.fetch("requests", 25)
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
out
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Real failures (races on shared state, require-proxy
|
|
266
|
+
# serialization) only show up under load: boot the app in N
|
|
267
|
+
# Ractors and serve M requests from each.
|
|
268
|
+
def rack_concurrent(config_ru, workers, requests)
|
|
269
|
+
ractors = workers.times.map do
|
|
270
|
+
Ractor.new(config_ru, requests) do |path, n|
|
|
271
|
+
require "rack" # audition:disable runtime-require
|
|
272
|
+
require "stringio" # audition:disable runtime-require
|
|
273
|
+
builder = Rack::Builder.new
|
|
274
|
+
builder.instance_eval(File.read(path), path, 1)
|
|
275
|
+
app = builder.to_app
|
|
276
|
+
statuses = Hash.new(0)
|
|
277
|
+
n.times do
|
|
278
|
+
statuses[app.call(AuditionHarness.base_env).first] += 1
|
|
279
|
+
end
|
|
280
|
+
statuses
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
results = ractors.map do |ractor|
|
|
284
|
+
{"ok" => true, "statuses" => ractor.value}
|
|
285
|
+
rescue Exception => e
|
|
286
|
+
{"ok" => false, "error" => describe_error(e)}
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
merged = Hash.new(0)
|
|
290
|
+
results.each do |result|
|
|
291
|
+
next unless result["ok"]
|
|
292
|
+
|
|
293
|
+
result["statuses"].each { |code, n| merged[code.to_s] += n }
|
|
294
|
+
end
|
|
295
|
+
{"workers" => workers,
|
|
296
|
+
"requests_per_worker" => requests,
|
|
297
|
+
"failures" => results.count { |r| !r["ok"] },
|
|
298
|
+
"first_error" => results.find { |r| !r["ok"] }&.dig("error"),
|
|
299
|
+
"statuses" => merged}
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def base_env
|
|
303
|
+
{
|
|
304
|
+
"REQUEST_METHOD" => "GET",
|
|
305
|
+
"PATH_INFO" => "/",
|
|
306
|
+
"QUERY_STRING" => "",
|
|
307
|
+
"SERVER_NAME" => "localhost",
|
|
308
|
+
"SERVER_PORT" => "80",
|
|
309
|
+
"SERVER_PROTOCOL" => "HTTP/1.1",
|
|
310
|
+
"rack.url_scheme" => "http",
|
|
311
|
+
"rack.input" => StringIO.new(+""),
|
|
312
|
+
"rack.errors" => StringIO.new(+"")
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Rack::Builder.parse_file cannot run inside a Ractor at all on
|
|
317
|
+
# rack 3.2 (Rack::BUILDER_TOPLEVEL_BINDING holds an unshareable
|
|
318
|
+
# Binding), so the per-Ractor boot rebuilds the app by
|
|
319
|
+
# instance_eval'ing config.ru into a fresh Builder; same DSL,
|
|
320
|
+
# no poisoned constant.
|
|
321
|
+
def rack_in_ractor(config_ru)
|
|
322
|
+
in_ractor(config_ru) do |path|
|
|
323
|
+
require "rack" # audition:disable runtime-require
|
|
324
|
+
require "stringio" # audition:disable runtime-require
|
|
325
|
+
builder = Rack::Builder.new
|
|
326
|
+
builder.instance_eval(File.read(path), path, 1)
|
|
327
|
+
app = builder.to_app
|
|
328
|
+
app.call(AuditionHarness.base_env).first
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# -- rails -------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def rails(payload)
|
|
335
|
+
environment = payload.fetch("environment")
|
|
336
|
+
before = Object.constants
|
|
337
|
+
started = Time.now
|
|
338
|
+
require environment # audition:disable runtime-require
|
|
339
|
+
begin
|
|
340
|
+
Rails.application.eager_load!
|
|
341
|
+
rescue Exception
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
boot = {"ok" => true,
|
|
345
|
+
"seconds" => (Time.now - started).round(1)}
|
|
346
|
+
scan(Object.constants - before, root: payload["root"])
|
|
347
|
+
.merge("boot" => boot)
|
|
348
|
+
rescue Exception => e
|
|
349
|
+
{"boot" => {"ok" => false, "error" => describe_error(e)}}
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# -- capabilities ------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def capabilities
|
|
355
|
+
caps = {}
|
|
356
|
+
capability_probes.each do |label, probe|
|
|
357
|
+
probe.call
|
|
358
|
+
caps[label] = {"ok" => true, "error" => nil}
|
|
359
|
+
rescue Exception => e
|
|
360
|
+
caps[label] = {"ok" => false,
|
|
361
|
+
"error" => unwrap(e).class.name}
|
|
362
|
+
end
|
|
363
|
+
{"capabilities" => caps}
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def capability_probes
|
|
367
|
+
{
|
|
368
|
+
"global variable read" =>
|
|
369
|
+
-> { Ractor.new { $audition_cap }.value }, # audition:disable
|
|
370
|
+
"global variable write" =>
|
|
371
|
+
-> { Ractor.new { $audition_cap = 1 }.value }, # audition:disable
|
|
372
|
+
"class variable access" =>
|
|
373
|
+
-> { Ractor.new { CapClassVar.read }.value },
|
|
374
|
+
"class ivar write" =>
|
|
375
|
+
-> { Ractor.new { CapIvar.write }.value },
|
|
376
|
+
"class ivar read (mutable value)" =>
|
|
377
|
+
-> { Ractor.new { CapIvar.read_mutable }.value },
|
|
378
|
+
"unshareable constant read" =>
|
|
379
|
+
-> { Ractor.new { CAP_CONST }.value },
|
|
380
|
+
"constant set (unshareable value)" =>
|
|
381
|
+
-> { Ractor.new { Object.const_set(:AUDITION_X, +"s") }.value },
|
|
382
|
+
"ENV read" =>
|
|
383
|
+
-> { Ractor.new { ENV.fetch("HOME", "none") }.value },
|
|
384
|
+
"ENV write" =>
|
|
385
|
+
-> { Ractor.new { ENV["AUD_CAP"] = "1" }.value }, # audition:disable
|
|
386
|
+
"require inside Ractor" =>
|
|
387
|
+
-> { Ractor.new { require "date" }.value }, # audition:disable
|
|
388
|
+
"ObjectSpace.each_object" =>
|
|
389
|
+
-> { Ractor.new { ObjectSpace.each_object(Class).first }.value },
|
|
390
|
+
"Signal.trap" =>
|
|
391
|
+
-> { Ractor.new { Signal.trap("USR2") {} }.value }, # audition:disable
|
|
392
|
+
"Thread.current storage" =>
|
|
393
|
+
-> { Ractor.new { Thread.current[:x] = 1 }.value },
|
|
394
|
+
"Timeout.timeout" =>
|
|
395
|
+
lambda do
|
|
396
|
+
require "timeout" # audition:disable runtime-require
|
|
397
|
+
Ractor.new { Timeout.timeout(2) { :ok } }.value
|
|
398
|
+
end,
|
|
399
|
+
"proc copied into Ractor" =>
|
|
400
|
+
lambda do
|
|
401
|
+
pr = proc { 1 }
|
|
402
|
+
Ractor.new(pr) { |_p| :ok }.value
|
|
403
|
+
end,
|
|
404
|
+
"outer local capture" =>
|
|
405
|
+
lambda do
|
|
406
|
+
z = [1]
|
|
407
|
+
Ractor.new { z }.value # audition:disable ractor-isolation
|
|
408
|
+
end
|
|
409
|
+
}
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
if $PROGRAM_NAME == __FILE__ # audition:disable global-variables
|
|
414
|
+
# The audited code may print anything, from any Ractor, straight
|
|
415
|
+
# to fd 1. Keep a private dup of the real stdout for the JSON
|
|
416
|
+
# document and point fd 1 at stderr for everyone else.
|
|
417
|
+
real_stdout = $stdout.dup
|
|
418
|
+
$stdout.reopen($stderr)
|
|
419
|
+
mode = ARGV.fetch(0, "capabilities")
|
|
420
|
+
raw = $stdin.tty? ? "" : $stdin.read
|
|
421
|
+
payload = raw.empty? ? {} : JSON.parse(raw)
|
|
422
|
+
AuditionHarness.main(mode, payload, out: real_stdout)
|
|
423
|
+
end
|