security_box 0.1.0 → 0.2.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 +26 -0
- data/README.md +23 -13
- data/lib/security_box/assets/security_box.wasm +0 -0
- data/lib/security_box/envelope.rb +65 -0
- data/lib/security_box/guest/main.rb +18 -10
- data/lib/security_box/guest/prelude.rb +71 -0
- data/lib/security_box/module_cache.rb +94 -0
- data/lib/security_box/runtime.rb +9 -4
- data/lib/security_box/sandbox.rb +21 -11
- data/lib/security_box/version.rb +1 -1
- data/lib/security_box.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bdcc33a7689dd5d68b5e9ca6c8cd36a83d8da9aa5a44c9b29a5994bd991c5d9e
|
|
4
|
+
data.tar.gz: a2f4fbf283527b9fcf484a9dc06adb3a5d34d2a1c97f355412a880c5204c9283
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 86158b7097c7e53602f80949350d2f2aebaf9f672400f908127fb995fbf829dc5a16928873231d1c2b8539660a0f7c5a0c2f600f40a225558afaf39f83a9ee77
|
|
7
|
+
data.tar.gz: e08ee261ac36c91b3fe6761a01e655410d60c3023094e852c7e1fa9ddc8728efe646366bd840c6863264b1f41826874794b52e6a1125dddbca8f662ae3f76fe4
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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.2.0] - 2026-09-14
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Compiled-module disk cache (`~/.cache/security_box/modules`, override with
|
|
13
|
+
`SECURITY_BOX_CACHE_DIR`): new processes boot the runtime in ~0.5s instead
|
|
14
|
+
of ~15s by deserializing the compiled artifact instead of recompiling.
|
|
15
|
+
Best effort — misses, corruption or an unwritable directory fall back to a
|
|
16
|
+
normal compile.
|
|
17
|
+
- Result-channel integrity: a per-eval random token is passed to the guest via
|
|
18
|
+
`SB_TOKEN`, captured and scrubbed by the hardening prelude, and embedded in
|
|
19
|
+
both the `/work/out.json` envelope and the stdout sentinel line. The host
|
|
20
|
+
validates the envelope schema and token; forged results (e.g. an `at_exit`
|
|
21
|
+
overwrite or a fake sentinel) become `:sandbox_error`.
|
|
22
|
+
- Hardening prelude (`lib/security_box/guest/prelude.rb`): `system`, `exec`,
|
|
23
|
+
`Kernel#spawn`, backticks, `IO.popen`, `Process.spawn` and `Kernel#open` now
|
|
24
|
+
raise `SecurityError` inside the guest (they previously were misleading
|
|
25
|
+
WASI stubs); `ENV` is scrubbed before user code runs; `$stdout.sync = true`.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- Guest result envelope carries a `token` field; `system(...)` inside guest
|
|
30
|
+
code raises `SecurityError` instead of returning a stub `true`.
|
|
31
|
+
- The sandbox image must be repacked (`rake security_box:build_image`) when
|
|
32
|
+
upgrading: the guest entrypoint protocol changed.
|
|
33
|
+
|
|
8
34
|
## [0.1.0] - 2026-09-13
|
|
9
35
|
|
|
10
36
|
### 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
|
|
@@ -184,11 +190,13 @@ string.
|
|
|
184
190
|
|---|---|
|
|
185
191
|
| `File.write("/etc/passwd", ...)` | `Errno::ENOENT` (guest does not see the host FS) |
|
|
186
192
|
| `Dir["/*"]` | `[]` (empty root) |
|
|
187
|
-
| `system("ls")` / backticks / `IO.popen`
|
|
193
|
+
| `system("ls")` / backticks / `IO.popen` / `Process.spawn` | `SecurityError` (hardening prelude) |
|
|
194
|
+
| `Kernel#open(...)` | `SecurityError` (pipe form; use `File.open`) |
|
|
188
195
|
| `fork` | `NotImplementedError` |
|
|
189
196
|
| `Thread.new` | `NotImplementedError` (WASI p1 has no threads) |
|
|
190
197
|
| `require "socket"` | `LoadError` (no network) |
|
|
191
|
-
| `ENV` | `{}` (
|
|
198
|
+
| `ENV` | `{}` (token read and scrubbed by the prelude) |
|
|
199
|
+
| forge `out.json` via `at_exit` / fake sentinel | rejected (token mismatch → `:sandbox_error`) |
|
|
192
200
|
| infinite loop | killed by epoch deadline or fuel budget |
|
|
193
201
|
|
|
194
202
|
Notes:
|
|
@@ -230,3 +238,5 @@ bundle exec ruby bin/spike.rb
|
|
|
230
238
|
|
|
231
239
|
- `docs/PLAN.md` — architecture, roadmap and threat model
|
|
232
240
|
- `docs/plan/stages/stage_1.md` — stage 1 findings and measured numbers
|
|
241
|
+
- `docs/plan/stages/stage_2.md` — stage 2 findings (hardening prelude, result-channel
|
|
242
|
+
integrity, compiled-module disk cache)
|
|
Binary file
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
end
|
|
59
|
+
|
|
60
|
+
duration = envelope["duration_ms"]
|
|
61
|
+
duration.nil? || duration.is_a?(Numeric)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
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
|
|
@@ -47,15 +54,16 @@ module SB
|
|
|
47
54
|
end
|
|
48
55
|
|
|
49
56
|
# Preferred: /work/out.json. Fallback (no /work): sentinel on stdout.
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
# Both carry the sandbox token so the host can reject forged results.
|
|
58
|
+
def emit(out, token)
|
|
59
|
+
json = JSON.generate(out.merge(token: token))
|
|
52
60
|
File.write(File.join(WORK_DIR, "out.json"), json)
|
|
53
61
|
rescue StandardError, SystemCallError
|
|
54
|
-
$stdout.puts "#{SENTINEL}:#{json}"
|
|
62
|
+
$stdout.puts "#{SENTINEL}:#{token}:#{json}"
|
|
55
63
|
end
|
|
56
64
|
|
|
57
|
-
def fatal(message)
|
|
58
|
-
emit({ ok: false, value: nil, error: { "class" => "SecurityBox::Guest", "message" => message } })
|
|
65
|
+
def fatal(message, token)
|
|
66
|
+
emit({ ok: false, value: nil, error: { "class" => "SecurityBox::Guest", "message" => message } }, token)
|
|
59
67
|
end
|
|
60
68
|
end
|
|
61
69
|
|
|
@@ -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
|
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
|
|
@@ -51,7 +57,7 @@ module SecurityBox
|
|
|
51
57
|
store.set_epoch_deadline(epoch_ticks(config))
|
|
52
58
|
status = invoke_guest(instance, store)
|
|
53
59
|
fuel_used = config.fuel - store.get_fuel
|
|
54
|
-
envelope = read_envelope(workdir)
|
|
60
|
+
envelope = read_envelope(workdir, stdout, token)
|
|
55
61
|
ensure
|
|
56
62
|
store.close
|
|
57
63
|
end
|
|
@@ -62,13 +68,13 @@ module SecurityBox
|
|
|
62
68
|
|
|
63
69
|
private
|
|
64
70
|
|
|
65
|
-
def build_wasi(workdir, stdout, stderr, config)
|
|
71
|
+
def build_wasi(workdir, stdout, stderr, config, token)
|
|
66
72
|
Wasmtime::WasiConfig.new
|
|
67
73
|
.set_stdin_string("")
|
|
68
74
|
.set_stdout_buffer(stdout, config.stdout_limit)
|
|
69
75
|
.set_stderr_buffer(stderr, config.stderr_limit)
|
|
70
76
|
.set_argv(["ruby", GUEST_ENTRYPOINT])
|
|
71
|
-
.set_env(config.env)
|
|
77
|
+
.set_env(config.env.merge(TOKEN_ENV_VAR => token))
|
|
72
78
|
.set_mapped_directory(workdir, "/work", :read_write)
|
|
73
79
|
end
|
|
74
80
|
|
|
@@ -110,12 +116,16 @@ module SecurityBox
|
|
|
110
116
|
end
|
|
111
117
|
end
|
|
112
118
|
|
|
113
|
-
def read_envelope(workdir)
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
116
125
|
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
def read_result_file(workdir)
|
|
127
|
+
File.read(File.join(workdir, RESULT_FILE))
|
|
128
|
+
rescue SystemCallError
|
|
119
129
|
nil
|
|
120
130
|
end
|
|
121
131
|
|
data/lib/security_box/version.rb
CHANGED
data/lib/security_box.rb
CHANGED
|
@@ -4,7 +4,9 @@ require_relative "security_box/version"
|
|
|
4
4
|
require_relative "security_box/errors"
|
|
5
5
|
require_relative "security_box/configuration"
|
|
6
6
|
require_relative "security_box/result"
|
|
7
|
+
require_relative "security_box/module_cache"
|
|
7
8
|
require_relative "security_box/runtime"
|
|
9
|
+
require_relative "security_box/envelope"
|
|
8
10
|
require_relative "security_box/sandbox"
|
|
9
11
|
|
|
10
12
|
module SecurityBox
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marcelo Junior
|
|
@@ -39,8 +39,11 @@ 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
|
|
44
47
|
- lib/security_box/result.rb
|
|
45
48
|
- lib/security_box/runtime.rb
|
|
46
49
|
- lib/security_box/sandbox.rb
|