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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22aa6209552c4b4a4fbc60fa98b71e6f70448fb308e3c8f133515ef663c32d7c
4
- data.tar.gz: a33ce5bc7a235946efbd283eb3a8f5a718dc87a971eeb3eff799c5b6701ed172
3
+ metadata.gz: 9949a172b6bf1ddf1eddd9756c8dcbad273d8f2409669304227bb2f981c9fef6
4
+ data.tar.gz: 499c948e3fd57c221f8aef7f8ad6dd63757b36e9d5a4a62d8c8a70823908f86d
5
5
  SHA512:
6
- metadata.gz: aa260831067c04ed120b56d082b068ffd606a060999921e5e4f21f925eb4b721cd37c6e786a646cf024e02a5eba05f4e8e0354311045df76d3d9fb99838aa0fd
7
- data.tar.gz: 1d0f95de215b37537d2ba3ca638120599d0461d50b783e1e5b52c5c56375d5729f4c1978e815082dc8c824a0389c1f765147cdf5d06f29981ad05e0f99c7ebc3
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
- 3. A fresh wasm instance boots, evaluates the code and serializes a JSON envelope to
23
- `/work/out.json`.
24
- 4. The host reads the envelope, maps any wasmtime trap to a `Result` status, closes the
25
- store and discards the tmpdir.
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) plus the engine setup. Call `SecurityBox.warmup` ahead of time (e.g., at
92
- boot) to pay that cost up front — every subsequent `eval` then only pays the
93
- ~240ms guest boot:
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 (~250ms, no cold compile)
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('raise ArgumentError, "boom"')
147
+ result = SecurityBox.eval("def boom; raise ArgumentError, 'boom'; end; boom")
142
148
 
143
- result.status # => :error
144
- result.error["class"] # => "ArgumentError"
145
- result.error["message"] # => "boom"
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` | no-op stubs / `ArgumentError` (nothing executes) |
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` | `{}` (sanitized) |
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)
@@ -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
- # NOTE (stage 1): the result channel is reliable against accidents, not against
7
- # an active adversary (guest code can forge the envelope). Token mitigations and
8
- # schema validation land in Stage 2.
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
- { ok: false, value: nil, error: { "class" => e.class.name, "message" => e.message.to_s } }
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
- def emit(out)
51
- json = JSON.generate(out)
52
- File.write(File.join(WORK_DIR, "out.json"), json)
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
- $stdout.puts "#{SENTINEL}:#{json}"
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
@@ -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 disk cache
11
- # (Module#serialize) lands in Stage 2.
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
- Wasmtime::Module.from_file(engine, image_path)
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
 
@@ -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. guest (/src/main.rb) evaluates it and writes /work/out.json
14
- # 4. host reads the envelope and returns a Result
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
- path = File.join(workdir, RESULT_FILE)
115
- return nil unless File.exist?(path)
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
- JSON.parse(File.read(path))
118
- rescue JSON::ParserError
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecurityBox
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
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.1.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