vanta_provider/wasm.rs
1//! The sandboxed execution primitive for WASM provider hooks
2//! (`docs/22-provider-sdk.md`, `docs/15-security.md`).
3//!
4//! Guest modules run under Wasmtime with **no ambient authority** — no WASI, no
5//! host imports are provided unless explicitly granted — and with a **fuel
6//! budget** so a malicious or runaway hook traps instead of hanging the host.
7//! This is the security core of the provider model; the full component-model WIT
8//! ABI (scoped `http-get`, `hash`) builds on this primitive.
9
10use vanta_core::{Area, VtaError, VtaResult};
11use wasmtime::{Config, Engine, Instance, Module, Store, StoreLimitsBuilder};
12
13/// Memory ceiling for a guest instance (audit L10): caps `memory.grow` so a
14/// guest cannot exhaust host RAM (`memory.grow` past this fails instead of
15/// climbing toward the 4 GiB wasm32 maximum).
16const MAX_MEMORY_BYTES: usize = 256 * 1024 * 1024; // 256 MiB
17
18/// A capability-free WASM sandbox.
19pub struct Sandbox {
20 engine: Engine,
21}
22
23impl Sandbox {
24 /// Create a sandbox with fuel metering enabled and no host capabilities.
25 pub fn new() -> VtaResult<Sandbox> {
26 let mut config = Config::new();
27 config.consume_fuel(true);
28 let engine = Engine::new(&config).map_err(|e| err(format!("engine: {e}")))?;
29 Ok(Sandbox { engine })
30 }
31
32 /// Run an exported `func(i32) -> i32` in a fresh, capability-free instance
33 /// under a fuel budget. Instantiation fails if the module imports anything
34 /// (no ambient authority is granted); the call traps cleanly if it exhausts
35 /// its fuel. Compute-only hooks use this; richer hooks extend the host set.
36 pub fn run_i32(&self, wasm: &[u8], func: &str, arg: i32, fuel: u64) -> VtaResult<i32> {
37 // TODO(security, L10): provider modules are currently unsigned. Before
38 // instantiating untrusted modules in production, verify `wasm` against a
39 // pinned provider-signing key (reusing `vanta-security`'s minisign
40 // verification) so only vetted hooks ever reach `Module::new`.
41 let module = Module::new(&self.engine, wasm).map_err(|e| err(format!("compile: {e}")))?;
42 // L10: bound guest memory so `memory.grow` cannot exhaust host RAM.
43 let limits = StoreLimitsBuilder::new()
44 .memory_size(MAX_MEMORY_BYTES)
45 .build();
46 let mut store = Store::new(&self.engine, limits);
47 store.limiter(|state| state as &mut dyn wasmtime::ResourceLimiter);
48 store
49 .set_fuel(fuel)
50 .map_err(|e| err(format!("set fuel: {e}")))?;
51 // Empty import list: a module requiring any import cannot instantiate.
52 let instance = Instance::new(&mut store, &module, &[])
53 .map_err(|e| err(format!("instantiate (no capabilities granted): {e}")))?;
54 let typed = instance
55 .get_typed_func::<i32, i32>(&mut store, func)
56 .map_err(|e| err(format!("export `{func}`: {e}")))?;
57 typed
58 .call(&mut store, arg)
59 .map_err(|e| err(format!("guest trap: {e}")))
60 }
61}
62
63fn err(msg: String) -> VtaError {
64 VtaError::new(Area::Prov, 1, msg)
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70
71 // A compute-only module: (func (export "double") (param i32) (result i32) ...).
72 const DOUBLE: &str = r#"(module
73 (func (export "double") (param i32) (result i32)
74 local.get 0
75 i32.const 2
76 i32.mul))"#;
77
78 // A module that loops forever — must trap on fuel exhaustion, not hang.
79 const SPIN: &str = r#"(module
80 (func (export "spin") (param i32) (result i32)
81 (loop (br 0))
82 i32.const 0))"#;
83
84 // A module that imports a host function it is not granted — must fail to
85 // instantiate (no ambient authority).
86 const NEEDS_IMPORT: &str = r#"(module
87 (import "env" "secret" (func $secret (result i32)))
88 (func (export "go") (param i32) (result i32) (call $secret)))"#;
89
90 #[test]
91 fn runs_pure_compute() {
92 let wasm = wat::parse_str(DOUBLE).unwrap();
93 let sb = Sandbox::new().unwrap();
94 assert_eq!(sb.run_i32(&wasm, "double", 21, 1_000_000).unwrap(), 42);
95 }
96
97 #[test]
98 fn fuel_exhaustion_traps_not_hangs() {
99 let wasm = wat::parse_str(SPIN).unwrap();
100 let sb = Sandbox::new().unwrap();
101 let err = sb.run_i32(&wasm, "spin", 0, 10_000).unwrap_err();
102 assert_eq!(err.area, Area::Prov); // trapped (out of fuel), did not hang
103 }
104
105 #[test]
106 fn imports_are_denied() {
107 let wasm = wat::parse_str(NEEDS_IMPORT).unwrap();
108 let sb = Sandbox::new().unwrap();
109 // No host functions are provided, so instantiation must fail.
110 assert!(sb.run_i32(&wasm, "go", 0, 1_000_000).is_err());
111 }
112}