Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.