security_box 0.1.0 → 0.3.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 +53 -0
- data/README.md +75 -21
- data/lib/security_box/assets/security_box.wasm +0 -0
- data/lib/security_box/configuration.rb +61 -0
- data/lib/security_box/envelope.rb +74 -0
- data/lib/security_box/guest/main.rb +50 -12
- data/lib/security_box/guest/prelude.rb +71 -0
- data/lib/security_box/module_cache.rb +94 -0
- data/lib/security_box/registry.rb +65 -0
- data/lib/security_box/runtime.rb +9 -4
- data/lib/security_box/sandbox.rb +40 -12
- data/lib/security_box/version.rb +1 -1
- data/lib/security_box.rb +30 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9949a172b6bf1ddf1eddd9756c8dcbad273d8f2409669304227bb2f981c9fef6
|
|
4
|
+
data.tar.gz: 499c948e3fd57c221f8aef7f8ad6dd63757b36e9d5a4a62d8c8a70823908f86d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1cf51781bb108aa6849ae24bd1db5b812c7e0a7e94f0d1876eb1a860cabc4a6b00241fdc19b4b142b88da8bdf86ed6e9c5dc53009237f36706a4ff637fe96d5f
|
|
7
|
+
data.tar.gz: 39dfd3af95bc8bfd2e3bd79ac57b118ee706b2f7da3aee98854aea832094b29342e751b2a41bf630c593fd07289ecf7ac041b1d416682924f276a29be4a7259b
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,59 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.0] - 2026-09-14
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Named, reusable profiles: `SecurityBox.register(:name, from: :base) { |c| c.fuel 100 }`
|
|
13
|
+
and `SecurityBox.spawn(:name, **overrides)` (a `Configuration` or the defaults also
|
|
14
|
+
work). Profiles are immutable; duplicate/unknown names raise
|
|
15
|
+
`SecurityBox::InvalidConfiguration`.
|
|
16
|
+
- `SecurityBox::Configuration#fingerprint`: a stable identity for equal settings
|
|
17
|
+
(SHA-256 of the canonical configuration), ready for artifact caches.
|
|
18
|
+
- Guest error envelopes now carry a `backtrace`: user frames only, capped at 20,
|
|
19
|
+
sandbox-internal locations (no host paths). Surfaced via `Result#error["backtrace"]`;
|
|
20
|
+
malformed backtraces invalidate the envelope (`:sandbox_error`).
|
|
21
|
+
- `Result#fuel_used` is now `nil` for `:timeout`: epoch traps restore fuel to the last
|
|
22
|
+
checkpoint, so the restored value understated consumption by orders of magnitude.
|
|
23
|
+
(Truthful on completion, fuel exhaustion and memory-limit traps.)
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- A guest `NoMemoryError` (interpreter OOM against the store limit) is reported as
|
|
28
|
+
`:memory_limit` instead of `:error`.
|
|
29
|
+
- A `memory_size` below the image's declared minimum (1528 pages ≈ 95.5 MiB) now
|
|
30
|
+
returns a `:sandbox_error` Result with a `security_box:` note on stderr instead of
|
|
31
|
+
raising `Wasmtime::Error` out of `#eval`.
|
|
32
|
+
- The sandbox image must be repacked (`rake security_box:build_image`) when
|
|
33
|
+
upgrading: the guest entrypoint protocol changed.
|
|
34
|
+
|
|
35
|
+
## [0.2.0] - 2026-09-14
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- Compiled-module disk cache (`~/.cache/security_box/modules`, override with
|
|
40
|
+
`SECURITY_BOX_CACHE_DIR`): new processes boot the runtime in ~0.5s instead
|
|
41
|
+
of ~15s by deserializing the compiled artifact instead of recompiling.
|
|
42
|
+
Best effort — misses, corruption or an unwritable directory fall back to a
|
|
43
|
+
normal compile.
|
|
44
|
+
- Result-channel integrity: a per-eval random token is passed to the guest via
|
|
45
|
+
`SB_TOKEN`, captured and scrubbed by the hardening prelude, and embedded in
|
|
46
|
+
both the `/work/out.json` envelope and the stdout sentinel line. The host
|
|
47
|
+
validates the envelope schema and token; forged results (e.g. an `at_exit`
|
|
48
|
+
overwrite or a fake sentinel) become `:sandbox_error`.
|
|
49
|
+
- Hardening prelude (`lib/security_box/guest/prelude.rb`): `system`, `exec`,
|
|
50
|
+
`Kernel#spawn`, backticks, `IO.popen`, `Process.spawn` and `Kernel#open` now
|
|
51
|
+
raise `SecurityError` inside the guest (they previously were misleading
|
|
52
|
+
WASI stubs); `ENV` is scrubbed before user code runs; `$stdout.sync = true`.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- Guest result envelope carries a `token` field; `system(...)` inside guest
|
|
57
|
+
code raises `SecurityError` instead of returning a stub `true`.
|
|
58
|
+
- The sandbox image must be repacked (`rake security_box:build_image`) when
|
|
59
|
+
upgrading: the guest entrypoint protocol changed.
|
|
60
|
+
|
|
8
61
|
## [0.1.0] - 2026-09-13
|
|
9
62
|
|
|
10
63
|
### Added
|
data/README.md
CHANGED
|
@@ -16,13 +16,16 @@ tries to escape.
|
|
|
16
16
|
## How it works
|
|
17
17
|
|
|
18
18
|
1. A `ruby.wasm` image is packed with the Ruby runtime + stdlib + a small guest
|
|
19
|
-
entrypoint (`lib/security_box/guest/main.rb`)
|
|
19
|
+
entrypoint (`lib/security_box/guest/main.rb`) plus a hardening prelude
|
|
20
|
+
(`lib/security_box/guest/prelude.rb`).
|
|
20
21
|
2. On every `#eval`, the host creates an exclusive tmpdir, writes the user code to it, and
|
|
21
|
-
mounts it read-write as `/work` inside the sandbox.
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
mounts it read-write as `/work` inside the sandbox. It also generates a per-eval
|
|
23
|
+
random token and passes it to the guest via `SB_TOKEN`.
|
|
24
|
+
3. A fresh wasm instance boots; the prelude captures the token, scrubs `ENV` and
|
|
25
|
+
neutralizes process-spawn APIs, then the guest evaluates the code and serializes a
|
|
26
|
+
token-signed JSON envelope to `/work/out.json` (stdout sentinel fallback).
|
|
27
|
+
4. The host validates the envelope (schema + token — forged results are rejected),
|
|
28
|
+
maps any wasmtime trap to a `Result` status, closes the store and discards the tmpdir.
|
|
26
29
|
|
|
27
30
|
One wasm process per `#eval` means zero residual state between executions.
|
|
28
31
|
|
|
@@ -87,15 +90,18 @@ result.duration_ms # => 257.0 (approx; dominated by the ruby.wasm boot)
|
|
|
87
90
|
|
|
88
91
|
### Warming up
|
|
89
92
|
|
|
90
|
-
The first `eval` in a process pays the cold WebAssembly compilation of the image
|
|
91
|
-
(~15s)
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
The first `eval` in a process pays either the cold WebAssembly compilation of the image
|
|
94
|
+
(~15s) or a fast deserialize from the compiled-module disk cache (~0.5s) plus the engine
|
|
95
|
+
setup. The cache lives in `~/.cache/security_box/modules` (override with
|
|
96
|
+
`SECURITY_BOX_CACHE_DIR`), is keyed by the image content, and is fully best effort —
|
|
97
|
+
on any miss or corruption the module is simply recompiled. Call `SecurityBox.warmup`
|
|
98
|
+
ahead of time (e.g., at boot) to pay that cost up front — every subsequent `eval` then
|
|
99
|
+
only pays the ~260ms guest boot:
|
|
94
100
|
|
|
95
101
|
```ruby
|
|
96
102
|
SecurityBox.warmup
|
|
97
103
|
|
|
98
|
-
SecurityBox.eval("40 + 2").value # => 42 (~
|
|
104
|
+
SecurityBox.eval("40 + 2").value # => 42 (~260ms, no cold compile)
|
|
99
105
|
```
|
|
100
106
|
|
|
101
107
|
`#eval` warms the same caches lazily on first use, so calling `warmup` is a pure
|
|
@@ -138,13 +144,44 @@ SecurityBox.eval('1000.times { print "x" * 1000 }', stdout_limit: 4096).stdout.b
|
|
|
138
144
|
Exceptions raised by guest code are a *result*, not a sandbox failure:
|
|
139
145
|
|
|
140
146
|
```ruby
|
|
141
|
-
result = SecurityBox.eval(
|
|
147
|
+
result = SecurityBox.eval("def boom; raise ArgumentError, 'boom'; end; boom")
|
|
142
148
|
|
|
143
|
-
result.status
|
|
144
|
-
result.error["class"]
|
|
145
|
-
result.error["message"]
|
|
149
|
+
result.status # => :error
|
|
150
|
+
result.error["class"] # => "ArgumentError"
|
|
151
|
+
result.error["message"] # => "boom"
|
|
152
|
+
result.error["backtrace"] # => ["sandbox:1:in 'Object#boom'", "sandbox:1:in '<main>'"]
|
|
146
153
|
```
|
|
147
154
|
|
|
155
|
+
The backtrace contains guest frames only (sandbox-internal locations, capped at
|
|
156
|
+
20 frames) — nothing from the host filesystem leaks.
|
|
157
|
+
|
|
158
|
+
### Named profiles
|
|
159
|
+
|
|
160
|
+
Reusable configurations registered once and spawned as often as needed:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
SecurityBox.register(:default) do |c|
|
|
164
|
+
c.fuel 10_000_000_000
|
|
165
|
+
c.timeout_ms 2_000
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
SecurityBox.register(:lean, from: :default) do |c|
|
|
169
|
+
c.fuel 2_000_000_000 # boot alone costs ~1e9; see the fuel notes below
|
|
170
|
+
c.timeout_ms 500
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
box = SecurityBox.spawn(:lean) # Sandbox from the profile
|
|
174
|
+
box.eval("40 + 2").value # => 42
|
|
175
|
+
|
|
176
|
+
SecurityBox.spawn(:lean, timeout_ms: 100) # per-call override (profile unchanged)
|
|
177
|
+
SecurityBox.spawn # default configuration
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Profiles are immutable: duplicate names and unknown names raise
|
|
181
|
+
`SecurityBox::InvalidConfiguration`; overrides never mutate the profile.
|
|
182
|
+
`SecurityBox::Configuration#fingerprint` gives every configuration a stable
|
|
183
|
+
identity (equal settings → equal fingerprint), used to share runtime artifacts.
|
|
184
|
+
|
|
148
185
|
### Configuration
|
|
149
186
|
|
|
150
187
|
`SecurityBox::Configuration` is immutable; use `.build` to create and `#with` to derive:
|
|
@@ -169,11 +206,11 @@ sandbox = SecurityBox::Sandbox.new(lean)
|
|
|
169
206
|
| Status | Meaning |
|
|
170
207
|
|---|---|
|
|
171
208
|
| `:ok` | guest code ran and returned a value |
|
|
172
|
-
| `:error` | guest code raised an exception (see `#error`) |
|
|
173
|
-
| `:timeout` | interrupted by the epoch deadline (wall clock) |
|
|
209
|
+
| `:error` | guest code raised an exception (see `#error`; includes `backtrace`) |
|
|
210
|
+
| `:timeout` | interrupted by the epoch deadline (wall clock); `fuel_used` is `nil` (epoch traps restore fuel to the checkpoint) |
|
|
174
211
|
| `:fuel_exhausted` | CPU budget exhausted |
|
|
175
|
-
| `:memory_limit` | exceeded the store `memory_size` |
|
|
176
|
-
| `:sandbox_error` | sandbox failure (unexpected trap, missing/invalid envelope) |
|
|
212
|
+
| `:memory_limit` | exceeded the store `memory_size` (wasm trap or guest `NoMemoryError`) |
|
|
213
|
+
| `:sandbox_error` | sandbox failure (unexpected trap, missing/invalid envelope, `memory_size` below the image's minimum) |
|
|
177
214
|
|
|
178
215
|
Values are JSON-serialized; non-serializable objects are returned as their `inspect`
|
|
179
216
|
string.
|
|
@@ -184,11 +221,13 @@ string.
|
|
|
184
221
|
|---|---|
|
|
185
222
|
| `File.write("/etc/passwd", ...)` | `Errno::ENOENT` (guest does not see the host FS) |
|
|
186
223
|
| `Dir["/*"]` | `[]` (empty root) |
|
|
187
|
-
| `system("ls")` / backticks / `IO.popen`
|
|
224
|
+
| `system("ls")` / backticks / `IO.popen` / `Process.spawn` | `SecurityError` (hardening prelude) |
|
|
225
|
+
| `Kernel#open(...)` | `SecurityError` (pipe form; use `File.open`) |
|
|
188
226
|
| `fork` | `NotImplementedError` |
|
|
189
227
|
| `Thread.new` | `NotImplementedError` (WASI p1 has no threads) |
|
|
190
228
|
| `require "socket"` | `LoadError` (no network) |
|
|
191
|
-
| `ENV` | `{}` (
|
|
229
|
+
| `ENV` | `{}` (token read and scrubbed by the prelude) |
|
|
230
|
+
| forge `out.json` via `at_exit` / fake sentinel | rejected (token mismatch → `:sandbox_error`) |
|
|
192
231
|
| infinite loop | killed by epoch deadline or fuel budget |
|
|
193
232
|
|
|
194
233
|
Notes:
|
|
@@ -197,6 +236,17 @@ Notes:
|
|
|
197
236
|
- Concurrency: `invoke` holds the GVL, so executions serialize per host process. Scale
|
|
198
237
|
horizontally with multiple processes (e.g., Puma workers); a stuck guest still can't
|
|
199
238
|
hang the process thanks to the epoch deadline.
|
|
239
|
+
- Ractor note: wasmtime `Engine`/`Module` are Ractor-shareable and Ractors run wasm in
|
|
240
|
+
parallel (measured ≈3.6x with 4 Ractors on 6 cores); a supported Ractor pool is on
|
|
241
|
+
the roadmap (see `docs/plan/stages/stage_3.md`).
|
|
242
|
+
- Memory: the packed image declares a 1528-page (~95.5 MiB) minimum; `memory_size`
|
|
243
|
+
below that fails instantiation (reported as `:sandbox_error`). Practical minimum is
|
|
244
|
+
~128–144MB for small workloads; the 512MB default leaves comfortable headroom.
|
|
245
|
+
- Fuel budgeting: compute workloads burn ~4–8e9 fuel/s (tight loops up to ~8.4e9/s)
|
|
246
|
+
and every eval costs ~1e9 fuel for boot — see the calibration table in
|
|
247
|
+
`docs/plan/stages/stage_3.md`. Consequently `fuel` below ~1e9 cannot even boot, and
|
|
248
|
+
`timeout_ms` below ~300ms times out during the guest boot (~500ms is a practical
|
|
249
|
+
floor).
|
|
200
250
|
|
|
201
251
|
## Running the tests
|
|
202
252
|
|
|
@@ -230,3 +280,7 @@ bundle exec ruby bin/spike.rb
|
|
|
230
280
|
|
|
231
281
|
- `docs/PLAN.md` — architecture, roadmap and threat model
|
|
232
282
|
- `docs/plan/stages/stage_1.md` — stage 1 findings and measured numbers
|
|
283
|
+
- `docs/plan/stages/stage_2.md` — stage 2 findings (hardening prelude, result-channel
|
|
284
|
+
integrity, compiled-module disk cache)
|
|
285
|
+
- `docs/plan/stages/stage_3.md` — stage 3 findings (named profiles, Ractor
|
|
286
|
+
parallelism, memory floor, fuel calibration, backtrace)
|
|
Binary file
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
3
6
|
module SecurityBox
|
|
4
7
|
# Immutable sandbox configuration. Use .build to create and #with to derive.
|
|
5
8
|
class Configuration
|
|
@@ -55,6 +58,64 @@ module SecurityBox
|
|
|
55
58
|
}
|
|
56
59
|
end
|
|
57
60
|
|
|
61
|
+
# Stable identity of the configuration values (SHA-256 of the normalized
|
|
62
|
+
# hash). Two configurations with equal settings — regardless of how they
|
|
63
|
+
# were built — share the same fingerprint; any #with change produces a
|
|
64
|
+
# different one. Used to key profiles and, later, cached artifacts.
|
|
65
|
+
def fingerprint
|
|
66
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def canonical
|
|
70
|
+
to_h.merge(env: @env.sort.to_h)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Mutable collector for the register DSL. Setter names match the
|
|
74
|
+
# Configuration options (no `=`, e.g. `c.fuel 100`); only changed values
|
|
75
|
+
# are collected and merged over the base profile.
|
|
76
|
+
class Builder
|
|
77
|
+
def initialize
|
|
78
|
+
@changes = {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def changes
|
|
82
|
+
@changes
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def image_path(value)
|
|
86
|
+
@changes[:image_path] = value
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def fuel(value)
|
|
90
|
+
@changes[:fuel] = value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def timeout_ms(value)
|
|
94
|
+
@changes[:timeout_ms] = value
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def memory_size(value)
|
|
98
|
+
@changes[:memory_size] = value
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def stdout_limit(value)
|
|
102
|
+
@changes[:stdout_limit] = value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def stderr_limit(value)
|
|
106
|
+
@changes[:stderr_limit] = value
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def epoch_interval_ms(value)
|
|
110
|
+
@changes[:epoch_interval_ms] = value
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Replaces the guest environment (it is not merged with the base).
|
|
114
|
+
def env(value)
|
|
115
|
+
@changes[:env] = value
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
58
119
|
private
|
|
59
120
|
|
|
60
121
|
# Resolution order (first existing path wins):
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SecurityBox
|
|
6
|
+
# Host-side validation of the guest result envelope.
|
|
7
|
+
#
|
|
8
|
+
# The guest reports its result either via /work/out.json or, when /work is
|
|
9
|
+
# unavailable, via a sentinel line on stdout (format:
|
|
10
|
+
# `__SECURITY_BOX_RESULT__:<token>:<json>`). Both embed the per-eval random
|
|
11
|
+
# token the host generated. An envelope is trusted only when it parses, the
|
|
12
|
+
# token matches and every field has the expected type; otherwise nil is
|
|
13
|
+
# returned and the execution is reported as a sandbox failure.
|
|
14
|
+
module Envelope
|
|
15
|
+
# Keep in sync with lib/security_box/guest/main.rb.
|
|
16
|
+
SENTINEL = "__SECURITY_BOX_RESULT__"
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Parses `raw` (JSON text) and validates it against `token`.
|
|
20
|
+
# Returns the envelope Hash or nil when invalid/untrusted.
|
|
21
|
+
def parse(raw, token)
|
|
22
|
+
return nil unless raw.is_a?(String) && token.is_a?(String) && !token.empty?
|
|
23
|
+
|
|
24
|
+
envelope = JSON.parse(raw)
|
|
25
|
+
return nil unless valid?(envelope, token)
|
|
26
|
+
|
|
27
|
+
envelope
|
|
28
|
+
rescue JSON::ParserError, TypeError, ArgumentError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Scans captured stdout for exactly one sentinel line carrying `token`.
|
|
33
|
+
# Zero or multiple sentinel lines (e.g. a guest forging an extra one)
|
|
34
|
+
# yield nil.
|
|
35
|
+
def from_stdout(stdout, token)
|
|
36
|
+
prefix = "#{SENTINEL}:"
|
|
37
|
+
lines = stdout.to_s.split("\n").select { |line| line.start_with?(prefix) }
|
|
38
|
+
return nil unless lines.size == 1
|
|
39
|
+
|
|
40
|
+
_sentinel, _embedded_token, json = lines.first.split(":", 3)
|
|
41
|
+
parse(json, token)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def valid?(envelope, token)
|
|
47
|
+
return false unless envelope.is_a?(Hash)
|
|
48
|
+
return false unless [true, false].include?(envelope["ok"])
|
|
49
|
+
return false unless envelope["token"] == token
|
|
50
|
+
|
|
51
|
+
if envelope["ok"]
|
|
52
|
+
return false unless envelope.key?("value")
|
|
53
|
+
else
|
|
54
|
+
error = envelope["error"]
|
|
55
|
+
return false unless error.is_a?(Hash)
|
|
56
|
+
return false unless error["class"].is_a?(String)
|
|
57
|
+
return false unless error["message"].is_a?(String)
|
|
58
|
+
return false unless valid_backtrace?(error["backtrace"])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
duration = envelope["duration_ms"]
|
|
62
|
+
duration.nil? || duration.is_a?(Numeric)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Optional: an array of backtrace frame strings (guest-side errors).
|
|
66
|
+
def valid_backtrace?(backtrace)
|
|
67
|
+
return true if backtrace.nil?
|
|
68
|
+
return false unless backtrace.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
backtrace.all?(String)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -3,11 +3,16 @@
|
|
|
3
3
|
# Input: /work/code.rb (preferred) or ARGV[0].
|
|
4
4
|
# Result: /work/out.json (JSON envelope); fallback: sentinel line on stdout.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# Integrity: the host generates a per-eval random token and passes it via ENV;
|
|
7
|
+
# the prelude captures it and scrubs ENV before user code runs. Both the
|
|
8
|
+
# envelope and the sentinel line embed the token, and the host rejects any
|
|
9
|
+
# result that does not carry the expected token (see lib/security_box/envelope.rb).
|
|
10
|
+
# This hardens the channel against forged results (e.g. an at_exit handler
|
|
11
|
+
# overwriting out.json); it is defense in depth, not a cryptographic guarantee.
|
|
9
12
|
require "json"
|
|
10
13
|
|
|
14
|
+
require_relative "prelude"
|
|
15
|
+
|
|
11
16
|
module SB
|
|
12
17
|
SENTINEL = "__SECURITY_BOX_RESULT__"
|
|
13
18
|
WORK_DIR = "/work"
|
|
@@ -15,13 +20,15 @@ module SB
|
|
|
15
20
|
module_function
|
|
16
21
|
|
|
17
22
|
def run
|
|
23
|
+
token = SBPrelude.apply!
|
|
24
|
+
|
|
18
25
|
code = fetch_code
|
|
19
|
-
return fatal("no code provided") unless code
|
|
26
|
+
return fatal("no code provided", token) unless code
|
|
20
27
|
|
|
21
28
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
22
29
|
out = evaluate(code)
|
|
23
30
|
out[:duration_ms] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(2)
|
|
24
|
-
emit(out)
|
|
31
|
+
emit(out, token)
|
|
25
32
|
end
|
|
26
33
|
|
|
27
34
|
def fetch_code
|
|
@@ -36,7 +43,26 @@ module SB
|
|
|
36
43
|
def evaluate(code)
|
|
37
44
|
{ ok: true, value: jsonable(eval(code, TOPLEVEL_BINDING, "sandbox")) } # rubocop:disable Security/Eval,Style/EvalWithLocation
|
|
38
45
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
39
|
-
{
|
|
46
|
+
{
|
|
47
|
+
ok: false, value: nil,
|
|
48
|
+
error: {
|
|
49
|
+
"class" => e.class.name,
|
|
50
|
+
"message" => e.message.to_s,
|
|
51
|
+
"backtrace" => guest_backtrace(e)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Frames of the user code only: guest protocol internals (main.rb /
|
|
57
|
+
# prelude.rb) are stripped and the list is capped so a deep recursion
|
|
58
|
+
# cannot flood the result channel.
|
|
59
|
+
def guest_backtrace(exception)
|
|
60
|
+
frames = (exception.backtrace || []).reject do |frame|
|
|
61
|
+
frame.include?("main.rb") || frame.include?("prelude.rb")
|
|
62
|
+
end
|
|
63
|
+
frames.first(20)
|
|
64
|
+
rescue StandardError
|
|
65
|
+
[]
|
|
40
66
|
end
|
|
41
67
|
|
|
42
68
|
# Values must survive JSON; the round-trip normalizes what the host will read.
|
|
@@ -47,15 +73,27 @@ module SB
|
|
|
47
73
|
end
|
|
48
74
|
|
|
49
75
|
# Preferred: /work/out.json. Fallback (no /work): sentinel on stdout.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
76
|
+
# Both carry the sandbox token so the host can reject forged results.
|
|
77
|
+
# The write is verified by reading it back — a silent truncation or
|
|
78
|
+
# partial write falls through to the sentinel instead of losing the
|
|
79
|
+
# result.
|
|
80
|
+
def emit(out, token)
|
|
81
|
+
json = JSON.generate(out.merge(token: token))
|
|
82
|
+
unless write_verified(json)
|
|
83
|
+
$stdout.puts "#{SENTINEL}:#{token}:#{json}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def write_verified(json)
|
|
88
|
+
path = File.join(WORK_DIR, "out.json")
|
|
89
|
+
File.write(path, json)
|
|
90
|
+
File.read(path) == json
|
|
53
91
|
rescue StandardError, SystemCallError
|
|
54
|
-
|
|
92
|
+
false
|
|
55
93
|
end
|
|
56
94
|
|
|
57
|
-
def fatal(message)
|
|
58
|
-
emit({ ok: false, value: nil, error: { "class" => "SecurityBox::Guest", "message" => message } })
|
|
95
|
+
def fatal(message, token)
|
|
96
|
+
emit({ ok: false, value: nil, error: { "class" => "SecurityBox::Guest", "message" => message } }, token)
|
|
59
97
|
end
|
|
60
98
|
end
|
|
61
99
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Hardening prelude — loaded before user code (defense in depth; WASI is the
|
|
2
|
+
# primary isolation boundary).
|
|
3
|
+
#
|
|
4
|
+
# Responsibilities (in order):
|
|
5
|
+
# 1. capture the per-eval sandbox token from ENV and hand it to the guest
|
|
6
|
+
# protocol exactly once (main.rb), erasing every other reference;
|
|
7
|
+
# 2. scrub ENV so user code sees an empty environment;
|
|
8
|
+
# 3. neutralize the process-spawn APIs that WASI leaves as misleading stubs
|
|
9
|
+
# (e.g. `system` returns true without running anything) by raising
|
|
10
|
+
# SecurityError instead;
|
|
11
|
+
# 4. disable Kernel#open entirely (its pipe form executes processes; user
|
|
12
|
+
# code must use File.open/IO.read for plain files);
|
|
13
|
+
# 5. set $stdout.sync so captured output is not lost on a hard kill.
|
|
14
|
+
module SBPrelude
|
|
15
|
+
DENIED_MESSAGE = "process execution is not allowed inside the sandbox"
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Applies all hardening and returns the sandbox token (or nil). The token
|
|
19
|
+
# is removed from the prelude state; afterwards it exists only in the
|
|
20
|
+
# caller's local scope.
|
|
21
|
+
def apply!
|
|
22
|
+
capture_token
|
|
23
|
+
scrub_env
|
|
24
|
+
neutralize_process_apis
|
|
25
|
+
sync_stdout
|
|
26
|
+
take_token
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the token captured by apply! and erases the stored reference.
|
|
30
|
+
def take_token
|
|
31
|
+
token = @token
|
|
32
|
+
remove_instance_variable(:@token) if instance_variable_defined?(:@token)
|
|
33
|
+
token
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def capture_token
|
|
39
|
+
@token = ENV["SB_TOKEN"]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def scrub_env
|
|
43
|
+
ENV.each_key { |key| ENV.delete(key) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def neutralize_process_apis
|
|
47
|
+
%w[system exec spawn `].each do |name|
|
|
48
|
+
neutralize_instance_method(Kernel, name)
|
|
49
|
+
end
|
|
50
|
+
neutralize_instance_method(Kernel, "open")
|
|
51
|
+
neutralize_singleton_method(IO, :popen)
|
|
52
|
+
neutralize_singleton_method(Process, :spawn)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def neutralize_instance_method(owner, name)
|
|
56
|
+
owner.module_eval do
|
|
57
|
+
define_method(name) { |*args, **kwargs| raise SecurityError, DENIED_MESSAGE }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def neutralize_singleton_method(owner, name)
|
|
62
|
+
owner.define_singleton_method(name) do |*args, **kwargs|
|
|
63
|
+
raise SecurityError, DENIED_MESSAGE
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def sync_stdout
|
|
68
|
+
$stdout.sync = true
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "wasmtime"
|
|
6
|
+
|
|
7
|
+
module SecurityBox
|
|
8
|
+
# Content-addressed disk cache for compiled wasm modules.
|
|
9
|
+
#
|
|
10
|
+
# Compiling the ruby.wasm image costs ~15s per process. This cache stores the
|
|
11
|
+
# compiled artifact (Module#serialize) so later processes pay only a fast
|
|
12
|
+
# deserialize. Files live at <cache_dir>/modules/<key>.cwasm, where the key
|
|
13
|
+
# combines the image content digest with the wasmtime precompile compatibility
|
|
14
|
+
# key — a stored module is only reused when the running engine can consume it.
|
|
15
|
+
#
|
|
16
|
+
# Everything here is best effort: on any I/O or deserialization failure the
|
|
17
|
+
# caller falls back to a regular compile and the cache self-heals on the next
|
|
18
|
+
# successful store. Set SECURITY_BOX_CACHE_DIR to relocate the cache; the
|
|
19
|
+
# default is ~/.cache/security_box.
|
|
20
|
+
module ModuleCache
|
|
21
|
+
CACHE_DIR_ENV_VAR = "SECURITY_BOX_CACHE_DIR"
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Returns the cached module for this engine+image, or nil on cache miss,
|
|
25
|
+
# corrupted cache file or any cache error.
|
|
26
|
+
def load_module(engine, image_path)
|
|
27
|
+
path = cache_path(engine, image_path)
|
|
28
|
+
return nil unless path && File.file?(path)
|
|
29
|
+
|
|
30
|
+
Wasmtime::Module.deserialize_file(engine, path)
|
|
31
|
+
rescue Wasmtime::Error, TypeError, SystemCallError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Compiles the image and stores the serialized artifact in the cache
|
|
36
|
+
# (best effort). Returns the compiled module in all cases.
|
|
37
|
+
def compile_and_store(engine, image_path)
|
|
38
|
+
module_ = Wasmtime::Module.from_file(engine, image_path)
|
|
39
|
+
store(engine, image_path, module_)
|
|
40
|
+
module_
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def store(engine, image_path, module_)
|
|
44
|
+
path = cache_path(engine, image_path)
|
|
45
|
+
return if path.nil?
|
|
46
|
+
|
|
47
|
+
dir = File.dirname(path)
|
|
48
|
+
FileUtils.mkdir_p(dir)
|
|
49
|
+
tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.tmp")
|
|
50
|
+
File.binwrite(tmp, module_.serialize)
|
|
51
|
+
File.rename(tmp, path)
|
|
52
|
+
rescue Wasmtime::Error, SystemCallError
|
|
53
|
+
File.delete(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Nil when no usable cache directory exists (cache disabled).
|
|
58
|
+
def cache_path(engine, image_path)
|
|
59
|
+
dir = modules_dir
|
|
60
|
+
return nil unless dir
|
|
61
|
+
|
|
62
|
+
File.join(dir, "#{cache_key(engine, image_path)}.cwasm")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def modules_dir
|
|
66
|
+
base = ENV[CACHE_DIR_ENV_VAR] || default_base_dir
|
|
67
|
+
return nil unless base
|
|
68
|
+
|
|
69
|
+
File.expand_path("modules", base)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def default_base_dir
|
|
75
|
+
home = ENV["HOME"]
|
|
76
|
+
home && File.join(home, ".cache", "security_box")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Digest cost scales with the image size (~0.7s for the 110MB ruby.wasm
|
|
80
|
+
# image); memoized per file identity so it is paid once per process
|
|
81
|
+
# (and recomputed only if the file changes on disk).
|
|
82
|
+
def cache_key(engine, image_path)
|
|
83
|
+
@keys ||= {}
|
|
84
|
+
stat = File.stat(image_path)
|
|
85
|
+
key = @keys[[image_path, stat.size, stat.mtime]]
|
|
86
|
+
return key if key
|
|
87
|
+
|
|
88
|
+
image_digest = Digest::SHA256.file(image_path).hexdigest
|
|
89
|
+
@keys[[image_path, stat.size, stat.mtime]] =
|
|
90
|
+
Digest::SHA256.hexdigest("#{image_digest}:#{engine.precompile_compatibility_key}")
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecurityBox
|
|
4
|
+
# Named, reusable profiles: `SecurityBox.register(:name, from: :base) { |c| ... }`
|
|
5
|
+
# stores an immutable Configuration built through the builder DSL; `spawn`
|
|
6
|
+
# resolves a profile and derives per-call overrides with `#with`.
|
|
7
|
+
#
|
|
8
|
+
# Profiles must be registered before they are referenced by `from:`, cannot
|
|
9
|
+
# be redefined (immutability avoids accidental shadowing) and live for the
|
|
10
|
+
# process lifetime; tests can reset the registry with `clear!`.
|
|
11
|
+
module Registry
|
|
12
|
+
MUTEX = Mutex.new
|
|
13
|
+
|
|
14
|
+
@profiles = {}
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def register(name, from: nil, &block)
|
|
18
|
+
key = normalize(name)
|
|
19
|
+
MUTEX.synchronize do
|
|
20
|
+
raise InvalidConfiguration, "profile #{key.inspect} is already registered" if @profiles.key?(key)
|
|
21
|
+
|
|
22
|
+
builder = Configuration::Builder.new
|
|
23
|
+
block&.call(builder)
|
|
24
|
+
@profiles[key] = Configuration.build(**base_of(from), **builder.changes)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve(name)
|
|
29
|
+
key = normalize(name)
|
|
30
|
+
MUTEX.synchronize do
|
|
31
|
+
@profiles.fetch(key) do
|
|
32
|
+
raise InvalidConfiguration, "unknown profile #{key.inspect}; register it first with SecurityBox.register"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def profiles
|
|
38
|
+
MUTEX.synchronize { @profiles.dup.freeze }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def clear!
|
|
42
|
+
MUTEX.synchronize { @profiles.clear }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def normalize(name)
|
|
48
|
+
unless name.is_a?(Symbol) || name.is_a?(String)
|
|
49
|
+
raise InvalidConfiguration, "profile name must be a Symbol or String, got #{name.class}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
name.to_sym
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def base_of(from)
|
|
56
|
+
return {} if from.nil?
|
|
57
|
+
|
|
58
|
+
key = normalize(from)
|
|
59
|
+
@profiles.fetch(key) do
|
|
60
|
+
raise InvalidConfiguration, "unknown base profile #{key.inspect}; register it before deriving from it"
|
|
61
|
+
end.to_h
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/security_box/runtime.rb
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
require "wasmtime"
|
|
4
4
|
|
|
5
|
+
require_relative "module_cache"
|
|
6
|
+
|
|
5
7
|
module SecurityBox
|
|
6
8
|
# Registry of heavyweight artifacts shared across sandboxes:
|
|
7
9
|
# - Engine (one per runtime configuration; owns the epoch timer)
|
|
8
10
|
# - Compiled Module (one per engine+image)
|
|
9
11
|
#
|
|
10
|
-
# Module compilation is slow (~15s the first time per process); the
|
|
11
|
-
#
|
|
12
|
+
# Module compilation is slow (~15s the first time per process); the compiled
|
|
13
|
+
# artifact is persisted via ModuleCache so later processes deserialize it
|
|
14
|
+
# from disk instead of recompiling.
|
|
12
15
|
module Runtime
|
|
13
16
|
MUTEX = Mutex.new
|
|
14
17
|
|
|
@@ -24,8 +27,10 @@ module SecurityBox
|
|
|
24
27
|
|
|
25
28
|
def module_for(engine, image_path)
|
|
26
29
|
MUTEX.synchronize do
|
|
27
|
-
@modules[[engine.object_id, image_path]] ||=
|
|
28
|
-
|
|
30
|
+
@modules[[engine.object_id, image_path]] ||= begin
|
|
31
|
+
ModuleCache.load_module(engine, image_path) ||
|
|
32
|
+
ModuleCache.compile_and_store(engine, image_path)
|
|
33
|
+
end
|
|
29
34
|
end
|
|
30
35
|
end
|
|
31
36
|
|
data/lib/security_box/sandbox.rb
CHANGED
|
@@ -10,12 +10,17 @@ module SecurityBox
|
|
|
10
10
|
# Flow:
|
|
11
11
|
# 1. exclusive tmpdir mounted as /work (read-write)
|
|
12
12
|
# 2. user code written to /work/code.rb
|
|
13
|
-
# 3.
|
|
14
|
-
#
|
|
13
|
+
# 3. a per-eval random token is passed via ENV (SB_TOKEN); the guest
|
|
14
|
+
# prelude captures it and scrubs ENV before user code runs
|
|
15
|
+
# 4. guest (/src/main.rb) evaluates the code and writes /work/out.json,
|
|
16
|
+
# embedding the token (stdout sentinel fallback does the same)
|
|
17
|
+
# 5. host reads the envelope, validates schema + token (untrusted results
|
|
18
|
+
# become a sandbox failure) and returns a Result
|
|
15
19
|
class Sandbox
|
|
16
20
|
GUEST_ENTRYPOINT = "/src/main.rb"
|
|
17
21
|
RESULT_FILE = "out.json"
|
|
18
22
|
CODE_FILE = "code.rb"
|
|
23
|
+
TOKEN_ENV_VAR = "SB_TOKEN"
|
|
19
24
|
|
|
20
25
|
def initialize(configuration = Configuration.build)
|
|
21
26
|
@config = configuration
|
|
@@ -32,6 +37,7 @@ module SecurityBox
|
|
|
32
37
|
config = overrides.empty? ? @config : @config.with(**overrides)
|
|
33
38
|
stdout = +""
|
|
34
39
|
stderr = +""
|
|
40
|
+
token = SecureRandom.hex(16)
|
|
35
41
|
t0 = monotonic_ms
|
|
36
42
|
|
|
37
43
|
Dir.mktmpdir("security_box") do |workdir|
|
|
@@ -42,7 +48,7 @@ module SecurityBox
|
|
|
42
48
|
|
|
43
49
|
store = Wasmtime::Store.new(
|
|
44
50
|
@engine,
|
|
45
|
-
wasi_p1_config: build_wasi(workdir, stdout, stderr, config),
|
|
51
|
+
wasi_p1_config: build_wasi(workdir, stdout, stderr, config, token),
|
|
46
52
|
limits: { memory_size: config.memory_size }
|
|
47
53
|
)
|
|
48
54
|
begin
|
|
@@ -50,8 +56,13 @@ module SecurityBox
|
|
|
50
56
|
instance = build_linker.instantiate(store, @module)
|
|
51
57
|
store.set_epoch_deadline(epoch_ticks(config))
|
|
52
58
|
status = invoke_guest(instance, store)
|
|
53
|
-
fuel_used = config.fuel - store.get_fuel
|
|
54
|
-
envelope = read_envelope(workdir)
|
|
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"
|
|
55
66
|
ensure
|
|
56
67
|
store.close
|
|
57
68
|
end
|
|
@@ -62,13 +73,13 @@ module SecurityBox
|
|
|
62
73
|
|
|
63
74
|
private
|
|
64
75
|
|
|
65
|
-
def build_wasi(workdir, stdout, stderr, config)
|
|
76
|
+
def build_wasi(workdir, stdout, stderr, config, token)
|
|
66
77
|
Wasmtime::WasiConfig.new
|
|
67
78
|
.set_stdin_string("")
|
|
68
79
|
.set_stdout_buffer(stdout, config.stdout_limit)
|
|
69
80
|
.set_stderr_buffer(stderr, config.stderr_limit)
|
|
70
81
|
.set_argv(["ruby", GUEST_ENTRYPOINT])
|
|
71
|
-
.set_env(config.env)
|
|
82
|
+
.set_env(config.env.merge(TOKEN_ENV_VAR => token))
|
|
72
83
|
.set_mapped_directory(workdir, "/work", :read_write)
|
|
73
84
|
end
|
|
74
85
|
|
|
@@ -110,12 +121,16 @@ module SecurityBox
|
|
|
110
121
|
end
|
|
111
122
|
end
|
|
112
123
|
|
|
113
|
-
def read_envelope(workdir)
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
116
130
|
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
def read_result_file(workdir)
|
|
132
|
+
File.read(File.join(workdir, RESULT_FILE))
|
|
133
|
+
rescue SystemCallError
|
|
119
134
|
nil
|
|
120
135
|
end
|
|
121
136
|
|
|
@@ -135,6 +150,15 @@ module SecurityBox
|
|
|
135
150
|
fuel_used: fuel_used, duration_ms: duration_ms,
|
|
136
151
|
guest_duration_ms: envelope["duration_ms"]
|
|
137
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
|
+
)
|
|
138
162
|
else
|
|
139
163
|
Result.new(
|
|
140
164
|
status: :error, error: guest_error, stdout: stdout, stderr: stderr,
|
|
@@ -144,6 +168,10 @@ module SecurityBox
|
|
|
144
168
|
end
|
|
145
169
|
end
|
|
146
170
|
|
|
171
|
+
def guest_memory_error?(guest_error)
|
|
172
|
+
guest_error.is_a?(Hash) && guest_error["class"] == "NoMemoryError"
|
|
173
|
+
end
|
|
174
|
+
|
|
147
175
|
def monotonic_ms
|
|
148
176
|
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000
|
|
149
177
|
end
|
data/lib/security_box/version.rb
CHANGED
data/lib/security_box.rb
CHANGED
|
@@ -3,8 +3,11 @@
|
|
|
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"
|
|
8
|
+
require_relative "security_box/module_cache"
|
|
7
9
|
require_relative "security_box/runtime"
|
|
10
|
+
require_relative "security_box/envelope"
|
|
8
11
|
require_relative "security_box/sandbox"
|
|
9
12
|
|
|
10
13
|
module SecurityBox
|
|
@@ -15,6 +18,33 @@ module SecurityBox
|
|
|
15
18
|
Sandbox.new.eval(code, **overrides)
|
|
16
19
|
end
|
|
17
20
|
|
|
21
|
+
# Registers a named, reusable profile (see Registry).
|
|
22
|
+
# SecurityBox.register(:lean, from: :default) { |c| c.fuel 5_000_000 }
|
|
23
|
+
def self.register(name, from: nil, &block)
|
|
24
|
+
Registry.register(name, from: from, &block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Builds a Sandbox from a registered profile, a Configuration, or the
|
|
28
|
+
# defaults:
|
|
29
|
+
# SecurityBox.spawn(:lean) # named profile
|
|
30
|
+
# SecurityBox.spawn(:lean, fuel: 1_000) # profile + per-call overrides
|
|
31
|
+
# SecurityBox.spawn(my_config) # explicit configuration
|
|
32
|
+
# SecurityBox.spawn # default configuration
|
|
33
|
+
def self.spawn(profile = nil, **overrides)
|
|
34
|
+
config = case profile
|
|
35
|
+
when nil then Configuration.build(**overrides)
|
|
36
|
+
when Symbol, String
|
|
37
|
+
resolved = Registry.resolve(profile)
|
|
38
|
+
overrides.empty? ? resolved : resolved.with(**overrides)
|
|
39
|
+
when Configuration
|
|
40
|
+
overrides.empty? ? profile : profile.with(**overrides)
|
|
41
|
+
else
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"profile must be a registered name or a Configuration, got #{profile.class}"
|
|
44
|
+
end
|
|
45
|
+
Sandbox.new(config)
|
|
46
|
+
end
|
|
47
|
+
|
|
18
48
|
# Prepares the shared runtime artifacts (Engine + compiled Module) ahead of
|
|
19
49
|
# time so the first #eval skips the cold module compilation (~15s per
|
|
20
50
|
# 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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marcelo Junior
|
|
@@ -39,8 +39,12 @@ files:
|
|
|
39
39
|
- lib/security_box.rb
|
|
40
40
|
- lib/security_box/assets/security_box.wasm
|
|
41
41
|
- lib/security_box/configuration.rb
|
|
42
|
+
- lib/security_box/envelope.rb
|
|
42
43
|
- lib/security_box/errors.rb
|
|
43
44
|
- lib/security_box/guest/main.rb
|
|
45
|
+
- lib/security_box/guest/prelude.rb
|
|
46
|
+
- lib/security_box/module_cache.rb
|
|
47
|
+
- lib/security_box/registry.rb
|
|
44
48
|
- lib/security_box/result.rb
|
|
45
49
|
- lib/security_box/runtime.rb
|
|
46
50
|
- lib/security_box/sandbox.rb
|