security_box 0.3.0 → 0.6.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/CHANGELOG.md +128 -0
- data/README.md +187 -22
- data/lib/security_box/assets/security_box.wasm +2 -2
- data/lib/security_box/configuration.rb +286 -7
- data/lib/security_box/errors.rb +2 -0
- data/lib/security_box/eval_run.rb +201 -0
- data/lib/security_box/guest/main.rb +4 -1
- data/lib/security_box/guest/rpc.rb +77 -0
- data/lib/security_box/guest_ext/Gemfile +10 -0
- data/lib/security_box/guest_ext/Gemfile.lock +31 -0
- data/lib/security_box/guest_ext/sb_rpc/ext/sb_rpc/extconf.rb +5 -0
- data/lib/security_box/guest_ext/sb_rpc/ext/sb_rpc/sb_rpc.c +29 -0
- data/lib/security_box/guest_ext/sb_rpc/lib/sb_rpc.rb +6 -0
- data/lib/security_box/guest_ext/sb_rpc/sb_rpc.gemspec +17 -0
- data/lib/security_box/guest_rpc.rb +132 -0
- data/lib/security_box/pool.rb +138 -0
- data/lib/security_box/ractor_pool.rb +214 -0
- data/lib/security_box/registry.rb +6 -1
- data/lib/security_box/result.rb +17 -3
- data/lib/security_box/runtime.rb +17 -0
- data/lib/security_box/sandbox.rb +26 -151
- data/lib/security_box/version.rb +1 -1
- data/lib/security_box.rb +19 -0
- metadata +12 -1
data/lib/security_box/sandbox.rb
CHANGED
|
@@ -1,179 +1,54 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require "securerandom"
|
|
5
|
-
require "tmpdir"
|
|
3
|
+
require_relative "eval_run"
|
|
6
4
|
|
|
7
5
|
module SecurityBox
|
|
8
6
|
# :oneshot mode sandbox — one wasm instance per #eval.
|
|
9
7
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
8
|
+
# The sandbox itself is stateless and cheap: it holds references to the
|
|
9
|
+
# shared runtime artifacts (Engine + compiled Module, both memoized in
|
|
10
|
+
# Runtime) and a WASI Linker. Each #eval runs the shared EvalRun core: an
|
|
11
|
+
# exclusive tmpdir mounted as /work, a per-eval token, fuel + epoch
|
|
12
|
+
# deadlines, and a validated result envelope.
|
|
13
|
+
#
|
|
14
|
+
# Thread safety: Engine/Module/Linker are thread-safe and the eval core is
|
|
15
|
+
# self-contained, so a single Sandbox can be used from multiple threads
|
|
16
|
+
# (evals serialize on the GVL — see RactorPool for real parallelism).
|
|
19
17
|
class Sandbox
|
|
20
|
-
GUEST_ENTRYPOINT = "/src/main.rb"
|
|
21
|
-
RESULT_FILE = "out.json"
|
|
22
|
-
CODE_FILE = "code.rb"
|
|
23
|
-
TOKEN_ENV_VAR = "SB_TOKEN"
|
|
24
|
-
|
|
25
18
|
def initialize(configuration = Configuration.build)
|
|
26
19
|
@config = configuration
|
|
27
20
|
@engine = Runtime.engine(epoch_interval_ms: @config.epoch_interval_ms)
|
|
28
21
|
@module = Runtime.module_for(@engine, @config.image_path)
|
|
22
|
+
@linker_mutex = Mutex.new
|
|
29
23
|
end
|
|
30
24
|
|
|
31
25
|
# Runs `code` in the sandbox and returns a Result.
|
|
32
26
|
# Per-call options (derived from the configuration without mutating it):
|
|
33
|
-
# timeout_ms:, fuel:, memory_size:, stdout_limit:, stderr_limit
|
|
27
|
+
# timeout_ms:, fuel:, fuel_ms:, memory_size:, stdout_limit:, stderr_limit:,
|
|
28
|
+
# mounts: (replaces the configuration's mounts, like env:)
|
|
34
29
|
def eval(code, **overrides)
|
|
35
30
|
raise ArgumentError, "code is required" if code.nil? || code.empty?
|
|
36
31
|
|
|
37
32
|
config = overrides.empty? ? @config : @config.with(**overrides)
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
# SecureRandom is not guaranteed Ractor-safe; EvalRun callers generate
|
|
34
|
+
# the token in the main Ractor/thread.
|
|
40
35
|
token = SecureRandom.hex(16)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
Dir.mktmpdir("security_box") do |workdir|
|
|
44
|
-
File.write(File.join(workdir, CODE_FILE), code)
|
|
45
|
-
envelope = nil
|
|
46
|
-
status = nil
|
|
47
|
-
fuel_used = nil
|
|
48
|
-
|
|
49
|
-
store = Wasmtime::Store.new(
|
|
50
|
-
@engine,
|
|
51
|
-
wasi_p1_config: build_wasi(workdir, stdout, stderr, config, token),
|
|
52
|
-
limits: { memory_size: config.memory_size }
|
|
53
|
-
)
|
|
54
|
-
begin
|
|
55
|
-
store.set_fuel(config.fuel)
|
|
56
|
-
instance = build_linker.instantiate(store, @module)
|
|
57
|
-
store.set_epoch_deadline(epoch_ticks(config))
|
|
58
|
-
status = invoke_guest(instance, store)
|
|
59
|
-
fuel_used = config.fuel - store.get_fuel unless status == :timeout
|
|
60
|
-
envelope = read_envelope(workdir, stdout, token)
|
|
61
|
-
rescue Wasmtime::Error => e
|
|
62
|
-
# Instantiation can fail before the guest ever runs (e.g. a
|
|
63
|
-
# memory_size below the module's declared minimum pages).
|
|
64
|
-
status = :sandbox_error
|
|
65
|
-
stderr << "security_box: #{e.class}: #{e.message}\n"
|
|
66
|
-
ensure
|
|
67
|
-
store.close
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
build_result(status, envelope, stdout, stderr, fuel_used, monotonic_ms - t0)
|
|
71
|
-
end
|
|
36
|
+
EvalRun.run(engine: @engine, module_: @module, linker: linker,
|
|
37
|
+
config: config, code: code, token: token)
|
|
72
38
|
end
|
|
73
39
|
|
|
74
40
|
private
|
|
75
41
|
|
|
76
|
-
def
|
|
77
|
-
|
|
78
|
-
.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def build_linker
|
|
87
|
-
@linker ||= Wasmtime::Linker.new(@engine).tap do |linker|
|
|
88
|
-
Wasmtime::WASI::P1.add_to_linker_sync(linker)
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def epoch_ticks(config)
|
|
93
|
-
[config.timeout_ms / config.epoch_interval_ms + 1, 1].max
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def invoke_guest(instance, store)
|
|
97
|
-
instance.invoke("_start")
|
|
98
|
-
:ok
|
|
99
|
-
rescue Wasmtime::WasiExit => e
|
|
100
|
-
handle_wasi_exit(e, store)
|
|
101
|
-
rescue Wasmtime::Trap => e
|
|
102
|
-
map_trap(e, store)
|
|
103
|
-
rescue Wasmtime::Error
|
|
104
|
-
# Unexpected runtime error (non-trap): treated as a sandbox failure.
|
|
105
|
-
:sandbox_error
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def handle_wasi_exit(exit_error, store)
|
|
109
|
-
# The guest rescues SystemExit; a WasiExit here means exit! or a guest crash
|
|
110
|
-
# before the envelope was written.
|
|
111
|
-
store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def map_trap(trap, store)
|
|
115
|
-
case trap.code
|
|
116
|
-
when :interrupt then :timeout
|
|
117
|
-
when :out_of_fuel then :fuel_exhausted
|
|
118
|
-
when :memory_out_of_bounds then :memory_limit
|
|
119
|
-
else
|
|
120
|
-
store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def read_envelope(workdir, stdout, token)
|
|
125
|
-
# Preferred channel: /work/out.json. Fallback: sentinel line on stdout.
|
|
126
|
-
# Both go through the same strict validation (schema + token).
|
|
127
|
-
Envelope.parse(read_result_file(workdir), token) ||
|
|
128
|
-
Envelope.from_stdout(stdout, token)
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def read_result_file(workdir)
|
|
132
|
-
File.read(File.join(workdir, RESULT_FILE))
|
|
133
|
-
rescue SystemCallError
|
|
134
|
-
nil
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def build_result(status, envelope, stdout, stderr, fuel_used, duration_ms)
|
|
138
|
-
if envelope.nil?
|
|
139
|
-
status = :sandbox_error if status == :ok
|
|
140
|
-
return Result.new(
|
|
141
|
-
status: status, stdout: stdout, stderr: stderr,
|
|
142
|
-
fuel_used: fuel_used, duration_ms: duration_ms
|
|
143
|
-
)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
guest_error = envelope["error"]
|
|
147
|
-
if envelope["ok"]
|
|
148
|
-
Result.new(
|
|
149
|
-
status: status, value: envelope["value"], stdout: stdout, stderr: stderr,
|
|
150
|
-
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
151
|
-
guest_duration_ms: envelope["duration_ms"]
|
|
152
|
-
)
|
|
153
|
-
elsif guest_memory_error?(guest_error)
|
|
154
|
-
# The guest hit the store memory limit through the Ruby interpreter
|
|
155
|
-
# (NoMemoryError instead of a wasm trap) — report it as a limit, not
|
|
156
|
-
# a user error.
|
|
157
|
-
Result.new(
|
|
158
|
-
status: :memory_limit, error: guest_error, stdout: stdout, stderr: stderr,
|
|
159
|
-
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
160
|
-
guest_duration_ms: envelope["duration_ms"]
|
|
161
|
-
)
|
|
162
|
-
else
|
|
163
|
-
Result.new(
|
|
164
|
-
status: :error, error: guest_error, stdout: stdout, stderr: stderr,
|
|
165
|
-
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
166
|
-
guest_duration_ms: envelope["duration_ms"]
|
|
167
|
-
)
|
|
42
|
+
def linker
|
|
43
|
+
@linker_mutex.synchronize do
|
|
44
|
+
@linker ||= Wasmtime::Linker.new(@engine).tap do |linker|
|
|
45
|
+
Wasmtime::WASI::P1.add_to_linker_sync(linker)
|
|
46
|
+
# The image statically links the sb_rpc extension, so the
|
|
47
|
+
# "sb"/"call" import must be defined even when no rpcs are
|
|
48
|
+
# configured (state is per-Store; see GuestRpc).
|
|
49
|
+
GuestRpc.define_import(linker)
|
|
50
|
+
end
|
|
168
51
|
end
|
|
169
52
|
end
|
|
170
|
-
|
|
171
|
-
def guest_memory_error?(guest_error)
|
|
172
|
-
guest_error.is_a?(Hash) && guest_error["class"] == "NoMemoryError"
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def monotonic_ms
|
|
176
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
|
|
177
|
-
end
|
|
178
53
|
end
|
|
179
54
|
end
|
data/lib/security_box/version.rb
CHANGED
data/lib/security_box.rb
CHANGED
|
@@ -8,7 +8,11 @@ require_relative "security_box/result"
|
|
|
8
8
|
require_relative "security_box/module_cache"
|
|
9
9
|
require_relative "security_box/runtime"
|
|
10
10
|
require_relative "security_box/envelope"
|
|
11
|
+
require_relative "security_box/guest_rpc"
|
|
11
12
|
require_relative "security_box/sandbox"
|
|
13
|
+
require_relative "security_box/eval_run"
|
|
14
|
+
require_relative "security_box/pool"
|
|
15
|
+
require_relative "security_box/ractor_pool"
|
|
12
16
|
|
|
13
17
|
module SecurityBox
|
|
14
18
|
# Convenience shortcut:
|
|
@@ -45,6 +49,21 @@ module SecurityBox
|
|
|
45
49
|
Sandbox.new(config)
|
|
46
50
|
end
|
|
47
51
|
|
|
52
|
+
# Builds a Pool of :oneshot sandboxes (bounded concurrency on threads).
|
|
53
|
+
# NOTE: evals serialize on the GVL — see RactorPool for parallelism.
|
|
54
|
+
# SecurityBox.pool(:lean, size: 4).eval("1 + 1")
|
|
55
|
+
def self.pool(profile = nil, size: 4, **overrides)
|
|
56
|
+
Pool.new(profile, size: size, **overrides)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Builds a RactorPool (real parallelism: worker Ractors sharing one
|
|
60
|
+
# Engine+Module). Costs one module deserialize at creation (~0.5s via the
|
|
61
|
+
# disk cache); each eval still pays the guest boot (~240ms).
|
|
62
|
+
# SecurityBox.ractor_pool(:lean, size: 4).eval("1 + 1")
|
|
63
|
+
def self.ractor_pool(profile = nil, size: 4, **overrides)
|
|
64
|
+
RactorPool.new(profile, size: size, **overrides)
|
|
65
|
+
end
|
|
66
|
+
|
|
48
67
|
# Prepares the shared runtime artifacts (Engine + compiled Module) ahead of
|
|
49
68
|
# time so the first #eval skips the cold module compilation (~15s per
|
|
50
69
|
# process). #eval warms these caches lazily on first use, so calling
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: security_box
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marcelo Junior
|
|
@@ -41,9 +41,20 @@ files:
|
|
|
41
41
|
- lib/security_box/configuration.rb
|
|
42
42
|
- lib/security_box/envelope.rb
|
|
43
43
|
- lib/security_box/errors.rb
|
|
44
|
+
- lib/security_box/eval_run.rb
|
|
44
45
|
- lib/security_box/guest/main.rb
|
|
45
46
|
- lib/security_box/guest/prelude.rb
|
|
47
|
+
- lib/security_box/guest/rpc.rb
|
|
48
|
+
- lib/security_box/guest_ext/Gemfile
|
|
49
|
+
- lib/security_box/guest_ext/Gemfile.lock
|
|
50
|
+
- lib/security_box/guest_ext/sb_rpc/ext/sb_rpc/extconf.rb
|
|
51
|
+
- lib/security_box/guest_ext/sb_rpc/ext/sb_rpc/sb_rpc.c
|
|
52
|
+
- lib/security_box/guest_ext/sb_rpc/lib/sb_rpc.rb
|
|
53
|
+
- lib/security_box/guest_ext/sb_rpc/sb_rpc.gemspec
|
|
54
|
+
- lib/security_box/guest_rpc.rb
|
|
46
55
|
- lib/security_box/module_cache.rb
|
|
56
|
+
- lib/security_box/pool.rb
|
|
57
|
+
- lib/security_box/ractor_pool.rb
|
|
47
58
|
- lib/security_box/registry.rb
|
|
48
59
|
- lib/security_box/result.rb
|
|
49
60
|
- lib/security_box/runtime.rb
|