security_box 0.2.0 → 0.5.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 +105 -0
- data/README.md +163 -13
- data/lib/security_box/assets/security_box.wasm +2 -2
- data/lib/security_box/configuration.rb +254 -6
- data/lib/security_box/envelope.rb +9 -0
- data/lib/security_box/errors.rb +2 -0
- data/lib/security_box/eval_run.rb +191 -0
- data/lib/security_box/guest/main.rb +33 -3
- data/lib/security_box/pool.rb +138 -0
- data/lib/security_box/ractor_pool.rb +199 -0
- data/lib/security_box/registry.rb +70 -0
- data/lib/security_box/result.rb +7 -0
- data/lib/security_box/runtime.rb +17 -0
- data/lib/security_box/sandbox.rb +22 -133
- data/lib/security_box/version.rb +1 -1
- data/lib/security_box.rb +46 -0
- metadata +5 -1
data/lib/security_box/runtime.rb
CHANGED
|
@@ -41,6 +41,23 @@ module SecurityBox
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Builds a dedicated (not memoized) Engine + Module pair prepared for
|
|
45
|
+
# use across Ractors: the epoch timer is started and the precompile
|
|
46
|
+
# key is touched BEFORE freezing, then both artifacts are made
|
|
47
|
+
# Ractor-shareable (stage-3 Q2 "Plan C"). The pair is not stored in the
|
|
48
|
+
# class memos — freezing shared artifacts would affect every Sandbox —
|
|
49
|
+
# so each RactorPool pays one module deserialize (~0.5s via the disk
|
|
50
|
+
# cache). Callers must treat the returned pair as immutable.
|
|
51
|
+
def build_shareable(image_path:, epoch_interval_ms:)
|
|
52
|
+
engine = build_engine(epoch_interval_ms)
|
|
53
|
+
module_ = ModuleCache.load_module(engine, image_path) ||
|
|
54
|
+
ModuleCache.compile_and_store(engine, image_path)
|
|
55
|
+
engine.precompile_compatibility_key # touch before make_shareable
|
|
56
|
+
Ractor.make_shareable(engine)
|
|
57
|
+
Ractor.make_shareable(module_)
|
|
58
|
+
[engine, module_]
|
|
59
|
+
end
|
|
60
|
+
|
|
44
61
|
private
|
|
45
62
|
|
|
46
63
|
def build_engine(epoch_interval_ms)
|
data/lib/security_box/sandbox.rb
CHANGED
|
@@ -1,161 +1,50 @@
|
|
|
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
|
|
60
|
-
envelope = read_envelope(workdir, stdout, token)
|
|
61
|
-
ensure
|
|
62
|
-
store.close
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
build_result(status, envelope, stdout, stderr, fuel_used, monotonic_ms - t0)
|
|
66
|
-
end
|
|
36
|
+
EvalRun.run(engine: @engine, module_: @module, linker: linker,
|
|
37
|
+
config: config, code: code, token: token)
|
|
67
38
|
end
|
|
68
39
|
|
|
69
40
|
private
|
|
70
41
|
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
.set_argv(["ruby", GUEST_ENTRYPOINT])
|
|
77
|
-
.set_env(config.env.merge(TOKEN_ENV_VAR => token))
|
|
78
|
-
.set_mapped_directory(workdir, "/work", :read_write)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def build_linker
|
|
82
|
-
@linker ||= Wasmtime::Linker.new(@engine).tap do |linker|
|
|
83
|
-
Wasmtime::WASI::P1.add_to_linker_sync(linker)
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def epoch_ticks(config)
|
|
88
|
-
[config.timeout_ms / config.epoch_interval_ms + 1, 1].max
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def invoke_guest(instance, store)
|
|
92
|
-
instance.invoke("_start")
|
|
93
|
-
:ok
|
|
94
|
-
rescue Wasmtime::WasiExit => e
|
|
95
|
-
handle_wasi_exit(e, store)
|
|
96
|
-
rescue Wasmtime::Trap => e
|
|
97
|
-
map_trap(e, store)
|
|
98
|
-
rescue Wasmtime::Error
|
|
99
|
-
# Unexpected runtime error (non-trap): treated as a sandbox failure.
|
|
100
|
-
:sandbox_error
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def handle_wasi_exit(exit_error, store)
|
|
104
|
-
# The guest rescues SystemExit; a WasiExit here means exit! or a guest crash
|
|
105
|
-
# before the envelope was written.
|
|
106
|
-
store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def map_trap(trap, store)
|
|
110
|
-
case trap.code
|
|
111
|
-
when :interrupt then :timeout
|
|
112
|
-
when :out_of_fuel then :fuel_exhausted
|
|
113
|
-
when :memory_out_of_bounds then :memory_limit
|
|
114
|
-
else
|
|
115
|
-
store.linear_memory_limit_hit? ? :memory_limit : :sandbox_error
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def read_envelope(workdir, stdout, token)
|
|
120
|
-
# Preferred channel: /work/out.json. Fallback: sentinel line on stdout.
|
|
121
|
-
# Both go through the same strict validation (schema + token).
|
|
122
|
-
Envelope.parse(read_result_file(workdir), token) ||
|
|
123
|
-
Envelope.from_stdout(stdout, token)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def read_result_file(workdir)
|
|
127
|
-
File.read(File.join(workdir, RESULT_FILE))
|
|
128
|
-
rescue SystemCallError
|
|
129
|
-
nil
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def build_result(status, envelope, stdout, stderr, fuel_used, duration_ms)
|
|
133
|
-
if envelope.nil?
|
|
134
|
-
status = :sandbox_error if status == :ok
|
|
135
|
-
return Result.new(
|
|
136
|
-
status: status, stdout: stdout, stderr: stderr,
|
|
137
|
-
fuel_used: fuel_used, duration_ms: duration_ms
|
|
138
|
-
)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
guest_error = envelope["error"]
|
|
142
|
-
if envelope["ok"]
|
|
143
|
-
Result.new(
|
|
144
|
-
status: status, value: envelope["value"], stdout: stdout, stderr: stderr,
|
|
145
|
-
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
146
|
-
guest_duration_ms: envelope["duration_ms"]
|
|
147
|
-
)
|
|
148
|
-
else
|
|
149
|
-
Result.new(
|
|
150
|
-
status: :error, error: guest_error, stdout: stdout, stderr: stderr,
|
|
151
|
-
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
152
|
-
guest_duration_ms: envelope["duration_ms"]
|
|
153
|
-
)
|
|
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
|
+
end
|
|
154
47
|
end
|
|
155
48
|
end
|
|
156
|
-
|
|
157
|
-
def monotonic_ms
|
|
158
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
|
|
159
|
-
end
|
|
160
49
|
end
|
|
161
50
|
end
|
data/lib/security_box/version.rb
CHANGED
data/lib/security_box.rb
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
require_relative "security_box/version"
|
|
4
4
|
require_relative "security_box/errors"
|
|
5
5
|
require_relative "security_box/configuration"
|
|
6
|
+
require_relative "security_box/registry"
|
|
6
7
|
require_relative "security_box/result"
|
|
7
8
|
require_relative "security_box/module_cache"
|
|
8
9
|
require_relative "security_box/runtime"
|
|
9
10
|
require_relative "security_box/envelope"
|
|
10
11
|
require_relative "security_box/sandbox"
|
|
12
|
+
require_relative "security_box/eval_run"
|
|
13
|
+
require_relative "security_box/pool"
|
|
14
|
+
require_relative "security_box/ractor_pool"
|
|
11
15
|
|
|
12
16
|
module SecurityBox
|
|
13
17
|
# Convenience shortcut:
|
|
@@ -17,6 +21,48 @@ module SecurityBox
|
|
|
17
21
|
Sandbox.new.eval(code, **overrides)
|
|
18
22
|
end
|
|
19
23
|
|
|
24
|
+
# Registers a named, reusable profile (see Registry).
|
|
25
|
+
# SecurityBox.register(:lean, from: :default) { |c| c.fuel 5_000_000 }
|
|
26
|
+
def self.register(name, from: nil, &block)
|
|
27
|
+
Registry.register(name, from: from, &block)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Builds a Sandbox from a registered profile, a Configuration, or the
|
|
31
|
+
# defaults:
|
|
32
|
+
# SecurityBox.spawn(:lean) # named profile
|
|
33
|
+
# SecurityBox.spawn(:lean, fuel: 1_000) # profile + per-call overrides
|
|
34
|
+
# SecurityBox.spawn(my_config) # explicit configuration
|
|
35
|
+
# SecurityBox.spawn # default configuration
|
|
36
|
+
def self.spawn(profile = nil, **overrides)
|
|
37
|
+
config = case profile
|
|
38
|
+
when nil then Configuration.build(**overrides)
|
|
39
|
+
when Symbol, String
|
|
40
|
+
resolved = Registry.resolve(profile)
|
|
41
|
+
overrides.empty? ? resolved : resolved.with(**overrides)
|
|
42
|
+
when Configuration
|
|
43
|
+
overrides.empty? ? profile : profile.with(**overrides)
|
|
44
|
+
else
|
|
45
|
+
raise ArgumentError,
|
|
46
|
+
"profile must be a registered name or a Configuration, got #{profile.class}"
|
|
47
|
+
end
|
|
48
|
+
Sandbox.new(config)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Builds a Pool of :oneshot sandboxes (bounded concurrency on threads).
|
|
52
|
+
# NOTE: evals serialize on the GVL — see RactorPool for parallelism.
|
|
53
|
+
# SecurityBox.pool(:lean, size: 4).eval("1 + 1")
|
|
54
|
+
def self.pool(profile = nil, size: 4, **overrides)
|
|
55
|
+
Pool.new(profile, size: size, **overrides)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Builds a RactorPool (real parallelism: worker Ractors sharing one
|
|
59
|
+
# Engine+Module). Costs one module deserialize at creation (~0.5s via the
|
|
60
|
+
# disk cache); each eval still pays the guest boot (~240ms).
|
|
61
|
+
# SecurityBox.ractor_pool(:lean, size: 4).eval("1 + 1")
|
|
62
|
+
def self.ractor_pool(profile = nil, size: 4, **overrides)
|
|
63
|
+
RactorPool.new(profile, size: size, **overrides)
|
|
64
|
+
end
|
|
65
|
+
|
|
20
66
|
# Prepares the shared runtime artifacts (Engine + compiled Module) ahead of
|
|
21
67
|
# time so the first #eval skips the cold module compilation (~15s per
|
|
22
68
|
# 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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marcelo Junior
|
|
@@ -41,9 +41,13 @@ 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
|
|
46
47
|
- lib/security_box/module_cache.rb
|
|
48
|
+
- lib/security_box/pool.rb
|
|
49
|
+
- lib/security_box/ractor_pool.rb
|
|
50
|
+
- lib/security_box/registry.rb
|
|
47
51
|
- lib/security_box/result.rb
|
|
48
52
|
- lib/security_box/runtime.rb
|
|
49
53
|
- lib/security_box/sandbox.rb
|