Writing Synaps WASM Modules
Synaps modules are Rust crates that compile to wasm32-unknown-unknown and run inside Hugin's sandboxed WASM runtime. Each module is an isolated, independently deployable vulnerability check. The sandbox enforces a 1 billion instruction fuel limit and a 16 MB memory cap.
Project Setup
cargo new --lib my-check
cd my-check
Set the crate type in Cargo.toml:
[lib]
crate-type = ["cdylib"]
[dependencies]
hugin-synaps-guest = { path = "../../hugin-scanner-guest" }
serde_json = "1"
Add a .cargo/config.toml to set the default target:
[build]
target = "wasm32-unknown-unknown"
Module Structure
Every module needs three functions wired together with the synaps_module! macro: get_info, check, and optionally should_check.
use hugin_synaps_guest::prelude::*;
fn get_info() -> ModuleInfo {
ModuleInfo {
id: "my-vuln-check".into(),
name: "My Vulnerability Check".into(),
author: "yourname".into(),
description: "Detects XYZ misconfiguration".into(),
severity: Severity::High,
tags: vec!["web".into(), "misconfig".into()],
cve: Some("CVE-2024-1234".into()),
cwe: Some(vec!["CWE-16".into()]),
cvss: Some(7.5),
references: vec!["https://example.com/advisory".into()],
dependencies: vec![],
is_producer: false,
}
}
fn should_check(target: &TargetInfo) -> bool {
target.scheme == "https"
}
fn check<C: Context>(ctx: &C, target: &TargetInfo) -> Result<CheckResult, CheckError> {
let resp = ctx.http_get("/api/status")?;
if resp.status == 200 && resp.contains("debug_mode: true") {
return Ok(CheckResult::vulnerable(Confidence::High)
.with_evidence(Evidence::http_response(resp.body_str().unwrap_or("")))
.with_message("Debug mode enabled on production endpoint"));
}
Ok(CheckResult::not_vulnerable())
}
synaps_module!(
info: get_info,
check: check,
should_check: should_check
);
The Context Trait
The Context trait is the module's interface to the host. It provides every capability the module needs without direct network or filesystem access -- the runtime brokers all calls.
HTTP
// Simple GET
let resp = ctx.http_get("/path")?;
// Custom request with headers
let req = HttpRequest::get("/api/data")
.with_header("X-Custom", "value")
.with_header("Accept", "application/json");
let resp = ctx.http_request(&req)?;
// POST with body
let resp = ctx.http_post("/submit", b"{\"key\":\"value\"}".to_vec())?;
// Inspect response
println!("Status: {}", resp.status);
println!("Body: {}", resp.body_str().unwrap_or(""));
println!("Header: {}", resp.header("content-type").unwrap_or(""));
DNS and TLS
let dns = ctx.dns_query(&DnsQuery::a("target.example.com"))?;
let first_ip = dns.first_value();
let tls = ctx.tls_info("target.example.com", 443)?;
let cert = tls.certificate.as_ref();
Raw TCP
let req = TcpRequest::new("target.example.com", 8080, b"PING\r\n".to_vec())
.with_timeout(3000);
let resp = ctx.tcp_request(&req)?;
let data = resp.data_str().unwrap_or("");
WebSocket
let conn = ctx.ws_connect("wss://target.example.com/ws")?;
ctx.ws_send_text(conn, r#"{"action":"ping"}"#)?;
let msg = ctx.ws_recv(conn, 5000)?;
ctx.ws_close(conn)?;
OOB (Oastify) Payloads
let payload = ctx.oastify_dns(Some("my-check"))?;
// Inject payload.payload into the target
// ...
// Wait and check for callback
if ctx.oastify_was_triggered(&payload.correlation_id) {
return Ok(CheckResult::vulnerable(Confidence::Confirmed)
.with_message("OOB DNS interaction received"));
}
Headless Browser
ctx.browser_navigate("https://target.example.com/login", 2000)?;
let dom = ctx.browser_get_dom()?;
if dom.has_form_action("/submit") {
ctx.browser_input("#username", "test")?;
ctx.browser_click("#submit")?;
}
let screenshot = ctx.browser_screenshot()?;
Logging
ctx.debug("Checking endpoint /api");
ctx.info("Found suspicious header");
ctx.warn("Unexpected response status");
Severity and Confidence
Severity maps to CVSS scores when from_cvss() is used: Critical (9.0+), High (7.0+), Medium (4.0+), Low (0.1+). Set it in get_info.
Confidence on CheckResult has four levels:
Confirmed-- OOB callback proof or definitive evidence. Use only when you have Oastify or equivalent confirmation.High-- Direct response evidence (error messages, math evaluation, file contents).Medium-- Differential signals (response differs from baseline).Low-- Heuristic or indirect signals.
Module Workflows (Producer/Consumer)
Modules can form pipelines. A producer module sets is_producer: true and stores data for downstream modules:
// Producer module
ctx.set_shared_data("discovered_api_key", &api_key)?;
Consumer modules declare dependencies and retrieve that data:
// In get_info
dependencies: vec!["my-producer-module".into()],
// In check
let api_key = ctx.get_shared_data("discovered_api_key")?;
let result = ctx.get_module_result("my-producer-module")?;
if let Some(r) = result {
if r.is_vulnerable() {
// Build on the producer's finding
}
}
Building
cargo build --target wasm32-unknown-unknown --release
The output is at target/wasm32-unknown-unknown/release/my_check.wasm.
Testing Without WASM
The guest SDK ships a MockContext for native unit tests. The macros compile correctly for native targets and use MockContext automatically:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_module_info() {
let info = get_info();
assert_eq!(info.severity, Severity::High);
assert!(!info.id.is_empty());
}
#[test]
fn test_should_check_https_only() {
let target = TargetInfo {
scheme: "https".into(),
host: "example.com".into(),
..Default::default()
};
assert!(should_check(&target));
let http_target = TargetInfo { scheme: "http".into(), ..target };
assert!(!should_check(&http_target));
}
}
Run native tests with:
cargo test
Installing a Built Module
Copy the .wasm file into the Hugin modules directory or use the synaps MCP tool with the scan action pointing to a local path. The runtime validates the WASM magic bytes and exports before loading.