Skip to main content

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}