Scan Node Application Plugin Development

Hook task start, TCP I/O and MITM traffic as a resident plugin: all 应用_ hooks and app_* host functions.

An application plugin is a resident business plugin of the scan node: rather than scanning a single packet, it is loaded alongside the scan node as a WASI wasm module and exports functions named with the 应用_ prefix to "hook" the key business points of the scan node — task start, TCP I/O, MITM HTTP/WS/SSE traffic, task packets, and task finalization.

This document is written for third-party developers and explains every function one by one: each hook, each host function, and each public SDK symbol is given with its signature, a parameter table, real JSON samples, and copy-paste-ready Go code. When you finish reading, you should be able to complete the whole flow — "create project → build → place files → trigger → debug" — on your own.

说明

One-line selection guide: to "produce a vulnerability per packet" → use the WASM POC template (scan_*); to "stay resident and hook scan-node business points" → use an application plugin (app_*). The two sets of host functions are not interchangeable; see chapter 1 for details.


1. What It Is

An application plugin is downloaded by the controller from the plugin store and delivered via AddPluginPackage into the scan node's scan-poc/plugin/<uuid>/ directory, where the plugin manager scans and loads it when the scan node starts. Each plugin implements only the hooks it needs, and the host calls only the hook functions you actually exported.

1.1 Essential Differences from a WASM POC

DimensionWASM POCApplication Plugin
Directoryscan-poc/<lang>/wasm/<VulnIde>/scan-poc/plugin/<uuid>/
Entry point_start (func main), processes one packet per runExported functions with the 应用_ prefix, invoked per hook trigger
Capability detectionFixed (stdin JSON + _start)Export-table detection: the host only calls hooks that are actually exported
Build modeOrdinary wasip1 command moduleMust use -buildmode=c-shared (the host needs _initialize + direct calls to exported functions)
Host namespacescan_http / scan_log / scan_report / scan_call / scan_t / scan_config / scan_set_timeoutapp_log / app_send_tcp* / app_call / app_spawn_tool / …
Where it takes effectOnly entries with PocType=wasm in the POC template listScan-node application plugins installed from the plugin store
Use casesVulnerability detection, single request/response analysisTask pre-processing, traffic filtering, MITM rewriting, TCP command extension, external tool integration
SDK filescan.goapp.go

Both SDK files in the table are provided in the downloadable SDK package; copy them into the app/ subdirectory of your plugin project and they are ready to use (see sections 5 and 6.3).

1.2 The Two Host-Function Sets Are Not Interchangeable (Core Constraint)

The scan node injects two completely disjoint sets of host functions for the two kinds of WASM modules:

  • The WASM POC path registers only the scan_* group;
  • The application plugin path registers only the app_* group (injected by the host into the wasm env module).
注意

Putting app.ExecTool(...) into a WASM POC, or scan.HTTP(...) into an application plugin, will cause an instantiation failure because the host does not export the corresponding function (the wasm import cannot be resolved). How to tell: if your artifact is scanned as a vulnerability from the "POC template list" → use scan.*; if it runs as a resident plugin from the "plugin store / application plugin directory" → use app.*. Controller plugins use ctl_*, and AiAgent external tools use stdin/stdout JSON. The four sets are not interchangeable.

1.3 Capability Detection Mechanism (Core)

When the host first needs to call a plugin (or probes 应用_Init at load time), it compiles that plugin's wasm, then enumerates the module's exported functions; every export named with the 应用_ prefix is collected into that plugin's capability table caps:

caps = { name | name starts with "应用_" }

Before calling any hook, the host first checks whether that hook is in the actual export table; hooks that were not exported are always skipped, incurring no instantiation or execution cost at all (this is exactly why "implement only the hooks you need" saves resources). The module is compiled once and then cached; each subsequent hook call merely creates a new module instance (resetting wasm global state).

1.4 Dual-Channel Determination

Besides the export table, there is also a declaration channel [INIT], consistent with controller application plugins:

ChannelSourceRole
Export table (Capabilities)Enumerate 应用_-prefixed exports after compilationWhether a hook is called is ultimately decided by this
[INIT] declaration (Declared)The [INIT] {...} line printed by the plugin calling Declare() at the start of any hookUsed for gating (e.g. the tools.exec authorization check) and consistency metadata

Decision logic: capability determination = export table (Capabilities) ∪ [INIT] declarations (Declared); but actual execution is still governed by the real exports. If a plugin declares a capability only in [INIT] but does not export the corresponding hook function, the host logs one line and skips it — it is never called:

Plugin[<uuid>] declared capability 应用_OnTaskStart but did not export the corresponding hook function; cannot call (skipped)

1.5 Data Channel and Line Protocol

The host and the plugin communicate through stdin / stdout:

DirectionContent
Host → pluginThe request JSON is written to stdin
Plugin → hoststdout line by line: [RESP] <JSON> result lines, [LOG] <text> debug logs, [INIT] <JSON> capability declarations

Every call proceeds as follows: instantiate the module → call _initialize (Go wasip1 runtime initialization) → call the target 应用_xxx export → parse the stdout line protocol. WriteResp must emit a single [RESP] <JSON> line; Log goes through the app_log host function (and is collected together with [LOG] lines).


2. Where the Entry Point Is: How the Host Discovers, Compiles, and Calls Your Plugin

This chapter breaks the complete chain from "code compiled" to "hook actually called" into steps. After reading it you will know why a plugin "does nothing at all" when you forget a certain switch.

2.1 Step One: The Directory and Files Must Be Placed Correctly

The host scans the plugin root directory under the scan node's run directory and finally locates each plugin's build artifact:

<run dir>/scan-poc/plugin/<uuid>/
├── build/
│   └── scan.wasm              # build artifact (required; the host locates build/*.wasm, preferring scan.wasm)
├── plugin.config.json         # plugin Key/Value config (optional; read by app_config)
├── language-cn.json           # Chinese language pack (optional; read by app_t)
├── language-en.json           # English language pack (optional)
├── sig.json                   # optional Ed25519 signature information
└── tools/<name>/              # external tools (optional; usable after declaration + GUI authorization)
    ├── bin/<name>[.exe]       # executable
    └── <name>.py / <name>.jar # python / java tool entry point

The controller obtains the packaged source from the plugin store, and after "installing to the GUI" it broadcasts it to each scan node; upon receiving the zip delivered by AddPluginPackage, the node extracts it into scan-poc/plugin/<uuid>/ and reloads the plugin.

重要

The host supports two layouts: the standard root/<uuid>/build/scan.wasm and the legacy root/<uuid>/Plugin/scan/<uuid>/build/scan.wasm. Directories without a .wasm under build/ are ignored outright. If sig.json exists and signature verification fails (verify_failed), the plugin is refused loading; a missing signature is recorded as unsigned (trusting the controller's AES-GCM delivery channel).

2.2 Step Two: You Must Build with -buildmode=c-shared

GOWORK=off GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .

Why c-shared is mandatory:

  • In command-module mode the entry point is _start, Go wasip1 runs main(), and as soon as main returns it calls proc_exit(0) and closes the module — the module has already exited before the host even calls your hook.
  • The c-shared library mode exports _initialize (runtime initialization). On every hook call the host first clears the automatic _start, instantiates the module, then manually calls _initialize, and only then calls the 应用_xxx export.
  • //go:wasmexport for exporting functions with a Chinese prefix and //go:wasmimport env ... for importing host functions both depend on the c-shared library mode.

func main() {} must be kept (required by c-shared linking; an empty implementation is fine).

2.3 Step Three: When the Host Compiles (lazy + cache + poisoned rebuild)

  • Lazy compilation: the load phase only reads metadata (path, signature, configuration) and does not touch wasm contents; actual compilation is deferred until the plugin first needs to be called.
  • Compile once and cache: build artifacts are cached per plugin UUID. Each later hook call only creates a new module instance (resetting wasm global state) without recompiling.
  • Probe 应用_Init at load time: during the load phase, plugins that export 应用_Init are called once proactively (which also triggers the first compilation) with the request JSON {}, in order to collect capabilities and tool declarations.
  • Poisoned rebuild: if one execution is judged timed out by the watchdog, that plugin's runtime is marked poisoned; the next call automatically discards the old runtime and recompiles, ensuring a stuck plugin does not drag down subsequent calls.
plugin load phase
  └─ scan the plugin root directory, build metadata for each plugin
  └─ for each plugin:
        if it exports "应用_Init":            // triggers the first compilation
              call "应用_Init" (request JSON is "{}")
  aggregate declared tools → send a tool authorization request to the GUI (ToolAuthRequest)

2.4 Step Four: Export-Table Detection (a Hook That Was Not Exported Is Never Called)

After compilation, the host enumerates the module's exported functions and collects them into the plugin's capability table:

caps = { name | name starts with "应用_" }

Before executing any hook it first checks "is this hook in the actual export table":

if not exported:
    if the plugin once declared this capability:
        log: "Plugin[<uuid>] declared capability 应用_OnXxx but did not export the corresponding hook function; cannot call (skipped)"
    skip this call

Therefore: not exported = not called, with no error and no resource usage; declared but not exported = one log line, then skipped.

2.5 Step Five: The Timing of Each Hook Call

1. Host business code triggers the hook (e.g. TCP data received → receive hook triggered)
2. Fast filtering: if no plugin implements the hook, the whole class is skipped
3. For each plugin, determine whether it implements the hook; if not, skip
4. Execute one hook call:
   a. Acquire/build the runtime (the first time triggers compilation)
   b. If this plugin is already executing → skip this call outright (anti-reentrancy/deadlock)
   c. Start a goroutine + watchdog timer (timeout, 30s by default)
   d. The actual call:
        - reset the execution environment for this run
        - stdin = request JSON; stdout/stderr = in-memory buffers
        - clear the automatic _start
        - create a new module instance
        - call _initialize (if exported)
        - call 应用_xxx (error if not exported; normally unreachable)
        - scan stdout: [RESP] → response/consumed flag/error; [LOG] → log; [INIT] → capability and tool declarations
        - merge in the logs collected by app_log
   e. On success, incrementally merge the [INIT] declarations (capabilities and tools)
   f. On timeout → mark that plugin's runtime poisoned and return an error
5. Business code decides what to do next based on the response JSON (see 2.6)

The request JSON is marshalled by the host and written to stdin; inside the plugin you just read it with app.LoadRequest(&req). The response is written to stdout by your app.WriteResp(...), and the host parses the [RESP] line.

2.6 Step Six: How Hook Return Values Affect the Main Flow

HookHow the host uses the return value
应用_OnTcpDataReceivedhandled=true → consume this event and skip the built-in handling; if data is non-empty and differs from the original → re-parse the new data before dispatching
应用_OnTcpDataSendOnly data is used: if non-empty and different → replace the content to be sent; handled has no effect
应用_OnTaskStartUses task (TaskStartModify): non-nil / non-empty fields replace the corresponding task fields
应用_OnTaskFilterVulnsUses filterIds: merged into the set of "vulnIde to exclude" (union across plugins)
应用_OnTaskFilterFlowsUses filterIds: merged into the set of "packet ide to exclude"
应用_OnMitmHttpRequestUses request (MitmHttpModify) merged into the request (later writes override earlier ones; empty fields do not override)
应用_OnMitmHttpResponseUses response (MitmHttpModify) merged into the response
应用_OnMitmWsMessageUses message.content: if non-empty, replace the message content
应用_OnMitmSseEventUses event.data: if non-empty, replace the event data
应用_OnTaskPacketIgnored entirely (notification-only)
应用_OnTaskEndUses done: in the drain phase, polling stops only when all plugins report done=true; in the close phase done is ignored
应用_InitUses the [INIT] lines: capabilities and tools declarations

3. The 12 Hooks, Explained Function by Function

Hook export names are uniformly 应用_ plus the business point name. The table below summarizes trigger timing and whether handled really takes effect:

Exported functionTrigger timingCan modify dataDoes handled=true take effect
应用_InitProbed once after the plugin is loadedEmits capability/tool declarationsNot applicable
应用_OnTcpDataReceivedAfter TCP data parsing, before built-in dispatchingdata can be modifiedYes: consumes this event and skips built-in handling
应用_OnTcpDataSendBefore data is sentdata can be modifiedNo (only data is used)
应用_OnTaskStartAfter the task is parsedTask filter fields can be modifiedParsed but ineffective (only task is used)
应用_OnTaskFilterVulnsBefore the task startsReturns vulnerability identifiers to excludeNo (only filterIds is used)
应用_OnTaskFilterFlowsBefore the task startsReturns packet identifiers to excludeNo (only filterIds is used)
应用_OnMitmHttpRequestMITM captures an HTTP requestmethod/url/headers/body can be modifiedParsed but currently ineffective (only request is used)
应用_OnMitmHttpResponseMITM captures an HTTP responsestatusCode/headers/body can be modifiedParsed but currently ineffective (only response is used)
应用_OnMitmWsMessageMITM captures a WS frameMessage content can be modifiedParsed but currently ineffective (only message is used)
应用_OnMitmSseEventMITM captures an SSE eventEvent data can be modifiedParsed but currently ineffective (only event is used)
应用_OnTaskPacketEvery raw packet in the scan dispatch loopNotification-only, no return semanticsThe response is ignored entirely
应用_OnTaskEndTask finalization (drain polling + close cleanup)Report done / clean upNot involved; only done is used
提示

When multiple plugins implement the same hook they are called in plugin order: for the TCP hooks, a later plugin sees the data already modified by an earlier one; modifications from MITM and filter hooks are merged (later writes override earlier ones, omitempty fields do not override). A single call is serial per plugin.

应用_Init

Purpose: called once proactively by the host during the load phase after the plugin is loaded (the request JSON is {}), to declare capability points and the external tool inventory before any other hook is invoked.

Signature / trigger form: the exported function takes no parameters and returns no value (returning an int is also fine; the host ignores it).

//go:wasmexport 应用_Init
func OnInit() { /* ... */ }

Parameter table: no parameters (the host writes {} to stdin).

Return / response: no business return value (the host ignores [RESP]); the [INIT] lines are what matters.

Request JSON injected by the host:

{}

The [INIT] lines and response you should emit:

[INIT] {"capabilities":["应用_OnTaskStart","应用_OnTaskPacket","应用_OnTaskEnd","tools.exec","fs.read","fs.write"],"tools":[{"name":"nuclei","runtime":""}]}
[RESP] {"handled":false}
FieldMeaningTakes effect?
capabilitiesDeclared capability points (hook names + tools.exec/fs.read/fs.write)Used for the capability inventory and the tools.exec gate; whether a hook is called is still governed by the export table
toolsDeclares external tools [{name,runtime}]Used for the GUI authorization popup inventory

Code sample:

package main

import "app"

//go:wasmexport 应用_Init
func OnInit() {
    app.Capabilities(
        app.HookTaskStart, app.HookTaskPacket, app.HookTaskEnd,
        app.CapToolsExec, app.CapFSRead, app.CapFSWrite,
    )
    app.DeclareTool("nuclei", "") // tool lives under tools/nuclei/; runtime="" means a native executable
    app.Declare()                 // emits [INIT] {"capabilities":[...],"tools":[...]}
    app.WriteResp(map[string]any{"handled": false})
}

func main() {}

Notes:

  1. 应用_Init itself must also exist in the export table (//go:wasmexport 应用_Init); otherwise the load-time probe will not call it, and declarations such as tools.exec can never be collected.
  2. Declare() must be called inside a hook function, not from package initialization or global variable initialization — the [INIT] lines must be written to this run's stdout.
  3. An [INIT] declaration alone is not enough to make a hook get called: the corresponding 应用_xxx must really be exported (otherwise the log says "declared capability but did not export").

应用_OnTcpDataReceived

Purpose: triggered after TCP data has been parsed and before the host's built-in dispatch. Use it to inspect / modify / consume one TCP message.

Signature / trigger form:

//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() { /* ... */ }

Parameter table: the request body is app.TcpDataRequest, written to stdin.

ParameterTypeWhat to passWhere it comes fromExample value
uuidstringIdentifier of this connection/this nodeTCP packet field"n-0a1b"
command1stringLevel-1 command (routing layer)Level-1 command field"relayData"
command2stringLevel-2 command (module/action)Level-2 command field"TaskStart"
command3stringLevel-3 command (target UUID)Level-3 command field"gui"
command4stringLevel-4 command (source UUID)Level-4 command field"n-0a1b"
sourcestringSource: 0=GUI 1=controller 2=scan nodeSource identifier field"1"
commandAstringBusiness action name (CommandA inside Data)Field inside the Data section"SaveScanVuln"
datastringRaw Data section (string([]byte))Raw Data section"{"a":1}"

Return / response: app.TcpDataResponse → {handled, error, data}.

Request JSON injected by the host:

{"uuid":"n-0a1b","command1":"relayData","command2":"Heartbeat","command3":"gui","command4":"n-0a1b","source":"1","commandA":"Heartbeat","data":"{\"tick\":1}"}

Response JSON you should return:

{"handled":false,"error":"","data":"{\"tick\":1}_seen"}
FieldSemantics
handledtrue → the plugin has consumed this event and the main program skips its built-in handling
errorProcessing error message; only logged, does not affect the main flow
dataNon-empty and different from the original data → the main program re-parses the new data and dispatches it; empty/identical → no modification

Code sample:

//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() {
    var req app.TcpDataRequest
    if app.LoadRequest(&req) != nil {
        app.WriteError(errors.New("请求解析失败"))
        return
    }
    app.Logf("收到 TCP 数据 command2=%s commandA=%s", req.Command2, req.CommandA)

    if strings.Contains(req.Data, "need-sign") {
        // 值怎么传出去:把改过的 Data 放进响应
        app.WriteResp(app.TcpDataResponse{Handled: false, Data: req.Data + "|signed"})
        return
    }
    // 只观察、不修改:返回空 data
    app.WriteResp(app.TcpDataResponse{Handled: false, Data: req.Data})
}

Notes:

  1. Messages that the plugin re-injects by calling app.ReinjectTcpData (app_tcp_received_data) inside the hook will not trigger this hook again (messages from the re-injection source are skipped outright), preventing self-triggered deadlock.
  2. To consume the event and skip the built-in handling, return Handled: true; to only modify the data, return the new value in Data and keep Handled as false.
  3. Only handled=true from this hook skips the built-in handling; handled from other hooks has no effect.

应用_OnTcpDataSend

Purpose: triggered before the host sends TCP data, so you can inspect/replace the content about to be sent.

Signature / trigger form:

//go:wasmexport 应用_OnTcpDataSend
func OnTcpDataSend() { /* ... */ }

Parameter table: the request body is likewise app.TcpDataRequest.

ParameterTypeWhat to passExample value
uuidstringCurrent node UUID"n-0a1b"
command1~command4stringThe four-level command of this send"relayData" / "TaskResult" / "gui" / "n-0a1b"
datastringContent about to be sent"{\"ok\":true}"
source / commandAstringUsually empty""

Return / response: app.TcpDataResponse; the host only reads data, and handled has no effect.

Request JSON injected by the host:

{"uuid":"n-0a1b","command1":"relayData","command2":"TaskResult","command3":"gui","command4":"n-0a1b","source":"","commandA":"","data":"{\"ok\":true}"}

Response JSON you should return (replacement):

{"handled":false,"error":"","data":"{\"ok\":true,\"signed\":true}"}

Code sample:

//go:wasmexport 应用_OnTcpDataSend
func OnTcpDataSend() {
    var req app.TcpDataRequest
    _ = app.LoadRequest(&req)
    if strings.Contains(req.Data, "need-sign") {
        app.WriteResp(app.TcpDataResponse{Data: req.Data + "|signed"})
        return
    }
    app.WriteResp(app.TcpDataResponse{}) // empty data → no modification, send as is
}

Notes:

  1. Returning empty data or data identical to the original → send as is; only non-empty and different data replaces it.
  2. Anti-reentrancy: if the plugin calls app.SendTcpData / app.SendTcpDataJson inside this hook it re-enters the send path, but the host skips the send hook directly, so there is no recursion.
  3. This hook only reads data; returning handled=true has no effect whatsoever.

应用_OnTaskStart

Purpose: triggered after the scan node has parsed the task configuration and before the task actually starts; it can modify fields related to task filtering/routing.

Signature / trigger form:

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() { /* ... */ }

Parameter table: the request body is app.TaskStartRequest.

ParameterTypeWhat to passModifiable?Example value
taskIdestringUnique task identifierRead-only"t-20261005-01"
taskNamestringTask nameRead-only"内网巡检"
proxyModestringProxy mode auto/direct/tunnelRead-only"auto"
sourceUUIDstringTask source (GUI) UUIDRead-only"g-1234"
targetUUIDstringUUID of the task delivery targetRead-only"n-0a1b"
selectedVulnIds[]stringVulnerability POC list that will actually be scannedModifiable["poc-1","poc-2"]
selectedVulnIdsAdd[]stringVulnerabilities added beyond the templateModifiable["poc-9"]
httpSelectedScanUrlRowIde[]stringSelected HTTP packet identifiersModifiable["flow-a"]
wsSelectedScanUrlRowIde[]stringSelected WebSocket packet identifiersModifiable["ws-1"]
sseSelectedScanUrlRowIde[]stringSelected SSE packet identifiersModifiable["sse-1"]
hostsContentstringhosts mapping contentModifiable"10.0.0.1 a.example"
scanningRangestringRestrict the scanning rangeRead-only"10.0.0.0/24"
skipScanDomainIPstringDomains or IPs to skipRead-only"10.0.0.5"

Return / response: app.TaskStartResponse → {handled, error, task}, where task is an app.TaskStartModify.

Request JSON injected by the host:

{"taskIde":"t-20261005-01","taskName":"内网巡检","proxyMode":"auto","sourceUUID":"g-1234","targetUUID":"n-0a1b","selectedVulnIds":["poc-rce-1","poc-info-2"],"selectedVulnIdsAdd":[],"httpSelectedScanUrlRowIde":["flow-a","test-flow-b"],"wsSelectedScanUrlRowIde":[],"sseSelectedScanUrlRowIde":[],"hostsContent":"","scanningRange":"","skipScanDomainIP":""}

Response JSON you should return (after trimming):

{"handled":true,"error":"","task":{"selectedVulnIds":["poc-rce-1"],"httpSelectedScanUrlRowIde":["flow-a"]}}
FieldSemantics
taskNon-nil / non-empty fields replace the corresponding task fields; nil/empty means no change (omitempty drops empty slices, so they cannot be used to clear)
handledParsed but does not currently affect the flow (task modifications are always applied from task)

Code sample:

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    app.SetTimeout(120000) // 启动动作可能较慢,放大执行窗口
    var req app.TaskStartRequest
    _ = app.LoadRequest(&req)
    app.Logf("任务启动:%s(%s) 选中漏洞 %d 个", req.TaskName, req.TaskIde, len(req.SelectedVulnIds))

    // 只保留名称含 rce 的 POC
    keep := make([]string, 0, len(req.SelectedVulnIds))
    for _, id := range req.SelectedVulnIds {
        if strings.Contains(strings.ToLower(id), "rce") {
            keep = append(keep, id)
        }
    }
    // 剔除测试环境数据包
    httpKeep := make([]string, 0, len(req.HttpSelectedScanUrlRowIde))
    for _, ide := range req.HttpSelectedScanUrlRowIde {
        if !strings.HasPrefix(ide, "test-") {
            httpKeep = append(httpKeep, ide)
        }
    }
    app.WriteResp(app.TaskStartResponse{
        Handled: true,
        Task: app.TaskStartModify{
            SelectedVulnIds:           keep,
            HttpSelectedScanUrlRowIde: httpKeep,
        },
    })
}

Notes:

  1. TaskStartModify uses omitempty, so empty slices are dropped and cannot be used to "clear" a field; you can only replace with non-empty values. For a clearing requirement, filter inside the plugin and return the remaining items.
  2. SelectedVulnIds and SelectedVulnIdsAdd, the three *SelectedScanUrlRowIde fields, and HostsContent are nilable: only nil means "do not modify".
  3. handled does not affect the flow; do not use it to control behavior.

应用_OnTaskFilterVulns

Purpose: before the task starts, filter the vulnerabilities selected by the current task in bulk and return the vulnIdes to exclude.

Signature / trigger form:

//go:wasmexport 应用_OnTaskFilterVulns
func OnTaskFilterVulns() { /* ... */ }

Parameter table: the request body is {taskIde, vulns}, where each vulns element is an app.TaskVulnBrief.

ParameterTypeWhat to passExample value
taskIdestringTask identifier"t-20261005-01"
vulns[]TaskVulnBriefSummaries of all vulnerabilities selected by the current tasksee below
vulns[].vulnIdestringUnique vulnerability identifier"poc-1"
vulns[].namestringVulnerability name"远程命令执行"
vulns[].levelstringLevel 4/3/2/1"1"
vulns[].pocTypestringyaml/go/wasm"yaml"

Return / response: app.TaskFilterResponse → {handled, error, filterIds}.

Request JSON injected by the host:

{"taskIde":"t-20261005-01","vulns":[{"vulnIde":"poc-1","name":"远程命令执行","level":"4","pocType":"yaml"},{"vulnIde":"poc-2","name":"信息泄露","level":"1","pocType":"go"}]}

Response JSON you should return (excluding low-severity items):

{"handled":false,"error":"","filterIds":["poc-2"]}
FieldSemantics
filterIdsSet of vulnIdes to exclude; results from multiple plugins are unioned
handledNo effect; only filterIds is read

Code sample:

//go:wasmexport 应用_OnTaskFilterVulns
func OnTaskFilterVulns() {
    var req struct {
        TaskIde string              `json:"taskIde"`
        Vulns   []app.TaskVulnBrief `json:"vulns"`
    }
    _ = app.LoadRequest(&req)

    var drop []string
    for _, v := range req.Vulns {
        if v.Level == "1" || v.Level == "2" { // 剔除低危
            drop = append(drop, v.VulnIde)
        }
    }
    app.WriteResp(app.TaskFilterResponse{FilterIds: drop})
}

Notes:

  1. What you return are the identifiers to exclude, not to keep.
  2. Empty strings inside filterIds are ignored; returning nil/empty means no filtering.
  3. vulns in the request contains all vulnerabilities selected by the current task (not a difference set), so filtering decisions must be based on the full set.

应用_OnTaskFilterFlows

Purpose: before the task starts, exclude packets in bulk by domain/URL (e.g. skip static-resource sites).

Signature / trigger form:

//go:wasmexport 应用_OnTaskFilterFlows
func OnTaskFilterFlows() { /* ... */ }

Parameter table: the request body is {taskIde, flows}, whose elements are app.TaskFlowBrief.

ParameterTypeWhat to passExample value
taskIdestringTask identifier"t-20261005-01"
flows[]TaskFlowBriefSummaries of all packets selected by the current tasksee below
flows[].idestringUnique packet identifier (IdeTraffic)"flow-a"
flows[].typestringhttp/websocket/sse"http"
flows[].methodstringRequest method"GET"
flows[].urlstringRequest URL"/api/list"
flows[].domainstringDomain"static.example.com"
flows[].tlsstringHTTP/HTTPS/WSS"HTTPS"

Return / response: app.TaskFilterResponse → filterIds holds the ides to exclude.

Request JSON injected by the host:

{"taskIde":"t-20261005-01","flows":[{"ide":"flow-a","type":"http","method":"GET","url":"/index.html","domain":"example.com","tls":"HTTPS"},{"ide":"flow-b","type":"http","method":"GET","url":"/a.css","domain":"static.example.com","tls":"HTTPS"}]}

Response JSON you should return:

{"handled":false,"error":"","filterIds":["flow-b"]}

Code sample:

//go:wasmexport 应用_OnTaskFilterFlows
func OnTaskFilterFlows() {
    var req struct {
        TaskIde string              `json:"taskIde"`
        Flows   []app.TaskFlowBrief `json:"flows"`
    }
    _ = app.LoadRequest(&req)

    var drop []string
    for _, f := range req.Flows {
        d := strings.ToLower(f.Domain)
        if d == "example.com" || strings.HasSuffix(d, ".static.example.com") {
            drop = append(drop, f.Ide) // 剔除用 ide,不是 url
        }
    }
    app.WriteResp(app.TaskFilterResponse{FilterIds: drop})
}

Notes:

  1. filterIds must contain flows[].ide (IdeTraffic), not the URL or domain.
  2. As with vulnerability filtering, results are unioned across plugins and only filterIds is read.
  3. When excluding by domain, mind the suffix-match boundary (strings.HasSuffix(d, ".static.example.com") rather than a bare suffix).

应用_OnMitmHttpRequest

Purpose: triggered when MITM captures an HTTP request; the request method, URL, headers and body can be modified/replaced.

Signature / trigger form:

//go:wasmexport 应用_OnMitmHttpRequest
func OnMitmHttpRequest() { /* ... */ }

Parameter table: the request body is app.MitmHttpRequest.

ParameterTypeWhat to passExample value
taskIdestringTask identifier"t-20261005-01"
methodstringRequest method"POST"
urlstringFull URL"https://example.com/api/login"
protostringProtocol version"HTTP/1.1"
hoststringHost"example.com"
headersmap[string][]stringRequest headers{"User-Agent":["curl/8"]}
bodystringDecompressed request body"u=admin&p=123"
remoteIpstringRemote server IP"93.184.216.34"
tlsstringHTTP/HTTPS"HTTPS"

Return / response: the response structure the host parses is an anonymous struct, not an SDK type:

var resp struct {
    Handled bool           `json:"handled"`
    Error   string         `json:"error"`
    Request MitmHttpModify `json:"request"`
}

Request JSON injected by the host:

{"taskIde":"t-20261005-01","method":"POST","url":"https://example.com/api/login","proto":"HTTP/1.1","host":"example.com","headers":{"User-Agent":["curl/8"]},"body":"u=admin&p=123","remoteIp":"93.184.216.34","tls":"HTTPS"}

Response JSON you should return (adding one request header):

{"handled":false,"error":"","request":{"headers":{"User-Agent":["curl/8"],"X-Tss-Audit":["hook-audit"]}}}
FieldSemantics
requestapp.MitmHttpModify, merged into this request (later writes override earlier ones; empty fields do not override)
request.method / request.urlNon-empty replaces the method / URL
request.headersWhen length > 0, replaces the whole request-header map
request.bodyNon-empty replaces the request body
handledParsed but does not participate in the flow at present

Code sample:

//go:wasmexport 应用_OnMitmHttpRequest
func OnMitmHttpRequest() {
    var req app.MitmHttpRequest
    _ = app.LoadRequest(&req)

    headers := map[string][]string{}
    for k, v := range req.Headers {
        headers[k] = v
    }
    headers["X-Tss-Powered-By"] = []string{"TestSecScan-Plugin"}
    headers["X-Original-URL"] = []string{req.URL}

    resp := struct {
        Handled bool               `json:"handled"`
        Error   string             `json:"error"`
        Request app.MitmHttpModify `json:"request"`
    }{
        Request: app.MitmHttpModify{Headers: headers},
    }
    app.WriteResp(resp)
}

Notes:

  1. The returned key must be request (not httpRequest/modify); if the field name is wrong the response parses fine but no modification takes effect.
  2. headers is a map[string][]string, and replacement is "whole-map replacement": to keep the original headers you must copy them first and then modify.
  3. To make the modification visible to the next plugin, the host applies it back to the request snapshot; whether it is ultimately written back to real traffic depends on how the caller uses that modify.

应用_OnMitmHttpResponse

Purpose: triggered when MITM captures an HTTP response; statusCode, headers and body can be modified/replaced.

Signature / trigger form:

//go:wasmexport 应用_OnMitmHttpResponse
func OnMitmHttpResponse() { /* ... */ }

Parameter table: the request body is app.MitmHttpResponse.

ParameterTypeWhat to passExample value
taskIdestringTask identifier"t-20261005-01"
urlstringRequest URL"https://example.com/api/login"
methodstringRequest method"POST"
statusCodeintResponse status code200
headersmap[string][]stringResponse headers{"Content-Type":["application/json"]}
bodystringDecompressed response body"{\"ok\":true}"
remoteIpstringRemote server IP"93.184.216.34"
tlsstringHTTP/HTTPS"HTTPS"

Return / response: {handled, error, response}, where response is an app.MitmHttpModify (which may carry a statusCode).

Request JSON injected by the host:

{"taskIde":"t-20261005-01","url":"https://example.com/api/login","method":"POST","statusCode":200,"headers":{"Content-Type":["application/json"]},"body":"{\"ok\":true}","remoteIp":"93.184.216.34","tls":"HTTPS"}

Response JSON you should return (changing the status code and body):

{"handled":false,"error":"","response":{"statusCode":403,"body":"{\"ok\":false}"}}
FieldSemantics
responseapp.MitmHttpModify, merged into the response
response.statusCodeWhen > 0, replaces the status code (used by responses only)
response.headersWhen length > 0, replaces the whole response-header map
response.bodyNon-empty replaces the response body
handledParsed but does not participate in the flow at present

Code sample:

//go:wasmexport 应用_OnMitmHttpResponse
func OnMitmHttpResponse() {
    var resp app.MitmHttpResponse
    _ = app.LoadRequest(&resp)

    mod := app.MitmHttpModify{}
    if strings.Contains(resp.Body, "internal error") {
        code := 502
        mod.StatusCode = code
        mod.Body = `{"masked":true}`
    }
    out := struct {
        Handled  bool               `json:"handled"`
        Error    string             `json:"error"`
        Response app.MitmHttpModify `json:"response"`
    }{
        Response: mod,
    }
    app.WriteResp(out)
}

Notes:

  1. statusCode of 0 (or not returned) means no modification; only positive values override.
  2. The returned key is response; method/url inside MitmHttpModify are meaningless on the response side (the response side does not use them).
  3. omitempty: an empty body or empty headers will not override the original value.

应用_OnMitmWsMessage

Purpose: triggered when MITM captures a WebSocket message frame; the message content can be replaced.

Signature / trigger form:

//go:wasmexport 应用_OnMitmWsMessage
func OnMitmWsMessage() { /* ... */ }

Parameter table: the request body is app.MitmWsMessage.

ParameterTypeWhat to passExample value
taskIdestringTask identifier"t-20261005-01"
urlstringConnection URL"wss://example.com/ws"
domainstringDomain"example.com"
fromClientbooltrue=client→server, false=server→clienttrue
statusTypeint1=send 2=receive1
contentstringMessage content"{\"cmd\":\"ping\"}"

Return / response: {handled, error, message}, where message is an app.MitmWsModify{content}.

Request JSON injected by the host:

{"taskIde":"t-20261005-01","url":"wss://example.com/ws","domain":"example.com","fromClient":true,"statusType":1,"content":"{\"cmd\":\"ping\"}"}

Response JSON you should return:

{"handled":false,"error":"","message":{"content":"{\"cmd\":\"pong\"}"}}
FieldSemantics
message.contentNon-empty replaces the message content
handledParsed but does not participate in the flow at present

Code sample:

//go:wasmexport 应用_OnMitmWsMessage
func OnMitmWsMessage() {
    var msg app.MitmWsMessage
    _ = app.LoadRequest(&msg)
    if strings.Contains(msg.Content, `"ping"`) {
        out := struct {
            Handled bool            `json:"handled"`
            Error   string          `json:"error"`
            Message app.MitmWsModify `json:"message"`
        }{
            Message: app.MitmWsModify{Content: strings.ReplaceAll(msg.Content, `"ping"`, `"pong"`)},
        }
        app.WriteResp(out)
        return
    }
    app.WriteResp(app.MitmWsModify{}) // 不修改
}

Notes:

  1. The returned key is message; an empty message.content does not override.
  2. fromClient and statusType carry overlapping semantics but both come from the host; using fromClient to judge direction is more intuitive.
  3. content may be the textual form of a binary frame, so do not attempt JSON parsing on non-JSON content.

应用_OnMitmSseEvent

Purpose: triggered when MITM captures an SSE (Server-Sent Events) event; the event data can be replaced.

Signature / trigger form:

//go:wasmexport 应用_OnMitmSseEvent
func OnMitmSseEvent() { /* ... */ }

Parameter table: the request body is app.MitmSseEvent.

ParameterTypeWhat to passExample value
taskIdestringTask identifier"t-20261005-01"
urlstringConnection URL"https://example.com/sse"
eventstringThe event: field (default message)"message"
idstringThe id: field"42"
datastringThe data: field"{\"price\":10}"
retryintThe retry: field (milliseconds)3000

Return / response: {handled, error, event}, where event is an app.MitmSseModify{data}.

Request JSON injected by the host:

{"taskIde":"t-20261005-01","url":"https://example.com/sse","event":"message","id":"42","data":"{\"price\":10}","retry":3000}

Response JSON you should return:

{"handled":false,"error":"","event":{"data":"{\"price\":99}"}}
FieldSemantics
event.dataNon-empty replaces the event data
handledParsed but does not participate in the flow at present

Code sample:

//go:wasmexport 应用_OnMitmSseEvent
func OnMitmSseEvent() {
    var evt app.MitmSseEvent
    _ = app.LoadRequest(&evt)

    mod := app.MitmSseModify{}
    if strings.Contains(evt.Data, "secret") {
        mod.Data = strings.ReplaceAll(evt.Data, "secret", "***")
    }
    out := struct {
        Handled bool            `json:"handled"`
        Error   string          `json:"error"`
        Event   app.MitmSseModify `json:"event"`
    }{
        Event: mod,
    }
    app.WriteResp(out)
}

Notes:

  1. The returned key is event; an empty data does not override.
  2. id, retry and event are read-only; MitmSseModify can only change data.
  3. SSE is a long-lived connection and this event hook is invoked frequently, so the logic must be fast.

应用_OnTaskPacket

Purpose: called once for every raw packet in the scan dispatch loop, handing task traffic to the plugin (typically: replay via app_http_replay to an external detection tool).

Signature / trigger form:

//go:wasmexport 应用_OnTaskPacket
func OnTaskPacket() { /* ... */ }

Parameter table: the request body is {taskIde, flow}, where flow holds the trimmed request-side fields.

ParameterTypeWhat to passExample value
taskIdestringTask identifier"t-20261005-01"
flow.methodstringRequest method"GET"
flow.urlstringRequest URL"/api/user"
flow.domainstringDomain"example.com"
flow.reqHeadersstringRaw request-header text"Host: example.com\nUser-Agent: curl/8"
flow.reqBodystringRequest body text""

Return / response: ignored entirely (notification-only). It is still recommended to write one [RESP] line so the host can confirm the execution completed.

Request JSON injected by the host:

{"taskIde":"t-20261005-01","flow":{"method":"GET","url":"/api/user","domain":"example.com","reqHeaders":"Host: example.com\nUser-Agent: curl/8","reqBody":""}}

Suggested return (ignored by the host; only a signal that execution completed):

{"handled":false}

Code sample:

//go:wasmexport 应用_OnTaskPacket
func OnTaskPacket() {
    app.SetTimeout(30000)
    var req struct {
        TaskIde string `json:"taskIde"`
        Flow    struct {
            Method     string `json:"method"`
            URL        string `json:"url"`
            Domain     string `json:"domain"`
            ReqHeaders string `json:"reqHeaders"`
            ReqBody    string `json:"reqBody"`
        } `json:"flow"`
    }
    _ = app.LoadRequest(&req)
    if req.Flow.URL == "" {
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    // 快进快出:重放转后台(async=true),重活交给 应用_OnTaskEnd
    _ = replayAsync(req.Flow.Method, req.Flow.URL, req.Flow.ReqHeaders, req.Flow.ReqBody)
    app.WriteResp(map[string]any{"handled": false})
}

Notes:

  1. Must be fast in and fast out: this path runs for every packet, and blocking synchronously will stall the dispatch loop; use async=true of app_http_replay for replays.
  2. The return value is ignored entirely; do not rely on it to influence the flow.
  3. When this hook is not exported the host does not call it at all, at zero cost.

应用_OnTaskEnd

Purpose: task finalization. It has two phases: drain, where the host calls in a loop (2s between rounds) until all plugins report done=true or drainLimit is exceeded; and close, a final one-shot call for final cleanup (killing processes, etc.).

Signature / trigger form:

//go:wasmexport 应用_OnTaskEnd
func OnTaskEnd() { /* ... */ }

Parameter table: the request body is

ParameterTypeWhat to passExample value
taskIdestringTask identifier"t-20261005-01"
cancelledbooltrue=the user stopped/cancelled (the finalization window should be shortened)false
phasestringdrain=one harvest round / close=final cleanup"drain"

Return / response: {handled, done} (the host parses a TaskEndResponse).

Request JSON injected by the host:

{"taskIde":"t-20261005-01","cancelled":false,"phase":"drain"}

Response JSON you should return (work remains; keep polling):

{"handled":true,"done":false}

Response in the close phase:

{"handled":true,"done":true}
FieldSemantics
doneIn the drain phase, true=no tasks remain to harvest and the host stops polling; as long as one plugin reports done=false, the next round continues
handledDoes not participate in the finalization decision (the host only reads done)

Code sample:

//go:wasmexport 应用_OnTaskEnd
func OnTaskEnd() {
    app.SetTimeout(290000) // 接近宿主硬上限 5 分钟:一轮内完成收割/上报
    var req struct {
        TaskIde   string `json:"taskIde"`
        Cancelled bool   `json:"cancelled"`
        Phase     string `json:"phase"`
    }
    _ = app.LoadRequest(&req)

    if req.Phase == "close" {
        // 最终清理:停掉本插件拉起的全部工具进程(tool 空=全部)
        stopAllTools()
        app.WriteResp(map[string]any{"handled": true, "done": true})
        return
    }
    // drain:检查是否还有进程未退出
    if stillRunning() {
        app.Log("仍有工具进程未退出,等待下一轮")
        app.WriteResp(map[string]any{"handled": true, "done": false})
        return
    }
    reportResults() // 收集结果并上报
    app.WriteResp(map[string]any{"handled": true, "done": true})
}

Notes:

  1. drain works by returning done=false to request the next round: do not block with sleep waiting for processes here; return quickly and let the host call again 2s later.
  2. Within a single round you can enlarge the execution window with app.SetTimeout(ms), up to the hard limit of 5 minutes; close is called only once, so make sure to kill processes there.
  3. Plugins that do not implement this hook do not participate in finalization; when cancelled=true the window should be shortened (the user has already cancelled).

4. The 20 Host Functions, Explained Function by Function

The host injects the following 20 functions into the wasm env module. The SDK (app.go) wraps only 12 of them; the other 8 are exported by the host but not wrapped by the SDK, so you must declare them yourself with //go:wasmimport env <name>.

All "output-type" functions share the same ABI convention:

func appXxx(in..., out unsafe.Pointer, outCap uint32) uint32

The host writes the result JSON into the linear memory pointed to by out (at most outCap bytes) and returns the number of bytes actually written; you take the string with buf[:n] and then json.Unmarshal it.

说明

Size the buffer according to the expected result: app_task_env, app_list_dir, and app_exec_tool may return large payloads (the sample plugin uses 256KB); app_read_file has a 4MB per-file limit, which needs about 5.6MB of buffer after base64 encoding.

app_log

Purpose: emit debug logs; the host collects them into this execution's context, where they are visible in task/GUI debugging.

Signature / trigger form:

//go:wasmimport env app_log
func appLog(msg string)

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
msgstringLog textYour code"收到 TCP 数据"

Return / response: no return value. The host collects it into this execution's log buffer and, after execution, writes it into the scan node log as 插件[<uuid>] 日志: .... It is not JSON.

Code sample:

//go:wasmimport env app_log
func appLog(msg string)

func LogInfo(msg string) { appLog(msg) }

// 调用
func demo() { appLog("工具已启动:nuclei pid=" + strconv.Itoa(pid)) }

Notes:

  1. The SDK already wraps it as app.Log / app.Logf; prefer those.
  2. Write each log line only once and avoid high-frequency flooding (especially inside OnTaskPacket).
  3. It does not go through stdout; it is an independent log channel and does not need the [LOG] prefix.

app_send_tcp

Purpose: send raw TCP data to the controller/GUI/other nodes (four-level command + raw data section).

Signature / trigger form:

//go:wasmimport env app_send_tcp
func appSendTcp(cmd1, cmd2, cmd3, cmd4, data string)

The host sends the five parameters as-is, as a four-level command plus the raw data section.

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
cmd1stringLevel-1 commandYour code"relayData"
cmd2stringLevel-2 commandYour code"Heartbeat"
cmd3stringTarget (gui/scan/all/node UUID)Your code"gui"
cmd4stringReply UUID (empty fills in this node automatically)Your code""
datastringRaw data sectionYour code"{\"tick\":1}"

Return / response: no return value. SDK: app.SendTcpData(cmd1, cmd2, cmd3, cmd4, data).

Code sample:

//go:wasmimport env app_send_tcp
func appSendTcp(cmd1, cmd2, cmd3, cmd4, data string)

func notifyGui(data string) { appSendTcp("relayData", "MyEvent", "gui", "", data) }

Notes:

  1. When cmd3 is empty the message goes only to the controller; write all to send to every node.
  2. It transmits a raw string — for business data, prefer app_send_tcp_json so the protocol wrapping is done for you.
  3. If you call this function inside 应用_OnTcpDataSend, the host skips the send hook to prevent reentrancy.

app_send_tcp_json

Purpose: send TCP JSON data with the complete {CommandA,Data} two-layer protocol wrapping.

Signature / trigger form:

//go:wasmimport env app_send_tcp_json
func appSendTcpJson(cmd1, cmd2, cmd3, cmd4, commandA, data string)

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
cmd1~cmd4stringFour-level command/target/replyYour code"scan" / "SaveScanVuln" / "" / ""
commandAstringBusiness action nameYour code"SaveScanVuln"
datastringJSON string (the host parses it into an object again)Your code"{\"VulnName\":\"x\"}"

Return / response: no return value. SDK: app.SendTcpDataJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any).

Code sample:

// SDK 用法:直接传结构体/值,SDK 内部 json.Marshal
func reportVuln(v any) {
    app.SendTcpDataJson("scan", "SaveScanVuln", "", "", "SaveScanVuln", v)
}

Notes:

  1. Do not json.Marshal into []byte yourself and pass that: a []byte is encoded as a base64 string and the host never receives an object. Pass a struct or any.
  2. If you only declare the low-level function, the data parameter must be JSON text; the host json.Unmarshals it into any and re-wraps it.
  3. commandA and cmd2 usually share the same name (e.g. SaveScanVuln); keeping them consistent makes routing on the peer side easier.

app_tcp_received_data

Purpose: inject one TCP message back into the host's receive flow. The host reassembles it in the 7-segment wire format and then runs the normal parsing and dispatching.

Signature / trigger form:

//go:wasmimport env app_tcp_received_data
func appTcpReceivedData(tcpDataJSON string)

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
tcpDataJSONstringJSON for one TCP message (fields below)Your codesee below

The host parses by Go field names (no json tags), taking UUID / Command1Str / Command2Str / Command3Str / Command4Str / Source / Data, joins them with the separator <!|!> into uuid<!|!>cmd1<!|!>cmd2<!|!>cmd3<!|!>cmd4<!|!>source<!|!>data, and feeds the result into the normal receive parsing flow.

JSON accepted by the host:

{"UUID":"n-0a1b","Command1Str":"relayData","Command2Str":"Heartbeat","Command3Str":"gui","Command4Str":"n-0a1b","Source":"2","Data":"eyJ0aWNrIjoxfQ=="}

(Data is a []byte and is represented as base64 in JSON.)

Return / response: no return value. SDK: app.ReinjectTcpData(td TcpDataRequest), which internally marshals the SDK's app.TcpDataRequest (fields uuid/command1..4/source/commandA/data).

Code sample:

//go:wasmimport env app_tcp_received_data
func appTcpReceivedData(tcpDataJSON string)

func inject(data []byte) {
    // 直接按宿主结构注入(注意 Data 是 []byte,base64 表示)
    td := map[string]any{
        "UUID": "n-0a1b", "Command1Str": "relayData", "Command2Str": "MyEvent",
        "Command3Str": "gui", "Command4Str": "n-0a1b", "Source": "2",
        "Data": data, // Go json.Marshal([]byte) → base64 字符串
    }
    b, _ := json.Marshal(td)
    appTcpReceivedData(string(b))
}

Notes:

  1. The re-injection depth is capped at 5 levels: injections beyond the limit are discarded outright, preventing plugins from forwarding to each other in an infinite loop.
  2. During re-injection 应用_OnTcpDataReceived is not called again (messages from the re-injection source are skipped outright), avoiding self-triggered deadlock.
  3. Data is a []byte: when passing text, use base64; otherwise the host's json.Unmarshal may fail and nothing is injected.

app_call

Purpose: the unified entry point for built-in tool functions, reusing the host's built-in table of safe functions (encoding/hashing/JSON reading, etc.).

Signature / trigger form:

//go:wasmimport env app_call
func appCall(funcID uint32, args string, out unsafe.Pointer, outCap uint32) uint32

Parameter table:

ParameterTypeWhat to passExample value
funcIDuint32Built-in function ID, see the table below10
argsstringJSON array of arguments"[\"hello\"]"
outunsafe.PointerOutput bufferunsafe.Pointer(&buf[0])
outCapuint32Buffer capacityuint32(len(buf))

Built-in function IDs (those wrapped by the SDK):

funcIDSDK wrapperPurpose
10app.Base64EncodeBase64 encoding
11app.Base64DecodeBase64 decoding
20app.MD5MD5 (lowercase hex)
21app.SHA1SHA1 hash
23app.SHA256SHA256 hash
50app.JSONGetRead a JSON value by path (e.g. a.b[0].c)

Return / response: returns the number of bytes written. The content is the function result string; on error the host writes {"error":"..."}.

{"error":"未知的内置函数 ID: 99"}

Code sample:

//go:wasmimport env app_call
func appCall(funcID uint32, args string, out unsafe.Pointer, outCap uint32) uint32

func callBuiltin(id uint32, args ...any) (string, bool) {
    b, _ := json.Marshal(args)
    var buf [64 * 1024]byte
    n := appCall(id, string(b), unsafe.Pointer(&buf[0]), uint32(len(buf)))
    if n == 0 {
        return "", false
    }
    return string(buf[:n]), true
}

// 调用(等价 app.MD5)
func demo() { s, _ := callBuiltin(20, "hello") }

Notes:

  1. The general-purpose SDK entry point is app.CallBuiltin(funcID, args...) (string, bool); returning false means the host produced no result.
  2. On error the returned text is JSON {"error":...} rather than an empty string, so check for that.
  3. args must be a JSON array whose element order matches the built-in function's parameters.

app_t

Purpose: read language-pack text; the key space is <current language>.<plugin UUID>.<key>.

Signature / trigger form:

//go:wasmimport env app_t
func appT(key string, out unsafe.Pointer, outCap uint32) uint32

Parameter table:

ParameterTypeWhat to passExample value
keystringLanguage-pack key (without the language and UUID prefix)"VulnName"
out / outCapunsafe.Pointer / uint32Output buffer—

Return / response: returns the number of bytes written; the content is plain text (not JSON). When the key is not found it falls back to returning the key itself.

远程命令执行

Code sample:

//go:wasmimport env app_t
func appT(key string, out unsafe.Pointer, outCap uint32) uint32

func T(key string) string {
    var buf [4096]byte
    n := appT(key, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    return string(buf[:n])
}

func demo() { name := T("VulnName") }

Notes:

  1. The language-pack files are language-<lang>.json in the plugin directory (one each for -cn / -en), read by the host when the plugin is loaded.
  2. The key space includes the plugin UUID, so in the source file you only write VulnName; the host automatically prefixes <lang>.<uuid>..
  3. In English mode, when an English key is missing it falls back to the key itself, so all four ends' language packs must be complete (see the workspace conventions).

app_config

Purpose: read Key/Value entries from the plugin configuration plugin.config.json (flat static keys).

Signature / trigger form:

//go:wasmimport env app_config
func appConfig(key string, out unsafe.Pointer, outCap uint32) uint32

Parameter table:

ParameterTypeWhat to passExample value
keystringConfiguration key"Enabled"
out / outCapunsafe.Pointer / uint32Output buffer—

Return / response: returns the number of bytes written; the content is the plain text of the configuration value; a missing key returns an empty string.

true

Code sample:

//go:wasmimport env app_config
func appConfig(key string, out unsafe.Pointer, outCap uint32) uint32

func ConfigGet(key string) string {
    var buf [4096]byte
    n := appConfig(key, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    return string(buf[:n])
}

func demo() { enabled := ConfigGet("Enabled") == "true" }

Notes:

  1. plugin.config.json is a flat Key/Value map (map[string]string); complex configuration is often serialized into one key (e.g. ConfigJson).
  2. Configuration is saved in the GUI plugin page → controller → delivered to the node, and takes effect after a restart/reload.
  3. Values are not JSON, so parsing numbers/booleans requires your own conversion.

app_node_info

Purpose: obtain information about the current scan node, for status reporting and distinguishing multiple nodes.

Signature / trigger form:

//go:wasmimport env app_node_info
func appNodeInfo(out unsafe.Pointer, outCap uint32) uint32

Parameter table:

ParameterTypeWhat to passExample value
out / outCapunsafe.Pointer / uint32Output buffer—

Return / response: returns the number of bytes written; the content is JSON:

{"uuid":"n-0a1b","nodeName":"node-01","language":"cn","appId":"testsecscan","pluginId":"plugin-exttools"}
FieldMeaning
uuidNode UUID
nodeNameNode name
languageCurrent language
appIdApplication ID
pluginIdCurrent plugin UUID (used by the plugin to identify itself in status reports)

Code sample:

//go:wasmimport env app_node_info
func appNodeInfo(out unsafe.Pointer, outCap uint32) uint32

// SDK 已封装为 app.GetNodeInfo(),但 SDK 的 NodeInfo 不含 pluginId,需自行声明结构体
type nodeInfo struct {
    UUID     string `json:"uuid"`
    NodeName string `json:"nodeName"`
    PluginID string `json:"pluginId"`
}

func fetchNodeInfo() *nodeInfo {
    var buf [4096]byte
    n := appNodeInfo(unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var info nodeInfo
    if json.Unmarshal(buf[:n], &info) != nil {
        return nil
    }
    return &info
}

Notes:

  1. The host returns 5 fields (one more than the SDK's app.NodeInfo: pluginId); using the SDK's app.GetNodeInfo() loses pluginId.
  2. Status reports must carry pluginId and uuid; otherwise the controller discards them as invalid.

app_free_port

Purpose: allocate a currently free local port so an external tool can put a "listening port" into the command template, avoiding port collisions between concurrent tasks/multiple instances.

Signature / trigger form:

//go:wasmimport env app_free_port
func appFreePort(out unsafe.Pointer, outCap uint32) uint32

Parameter table: no input parameters (besides the output buffer).

Return / response: returns the number of bytes written; the content is JSON:

{"port":34567}

On failure:

{"error":"连续 20 次未取到未占用的空闲端口"}

The host really binds once on 127.0.0.1:0, reads the kernel-assigned port and releases it immediately; the same port is not handed out again within 30s (TOCTOU mitigation).

Code sample:

//go:wasmimport env app_free_port
func appFreePort(out unsafe.Pointer, outCap uint32) uint32

func allocPort() int {
    var buf [4096]byte
    n := appFreePort(unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Port  int    `json:"port"`
        Error string `json:"error"`
    }
    if json.Unmarshal(buf[:n], &resp) != nil || resp.Port <= 0 {
        return 0
    }
    return resp.Port
}

Notes:

  1. The port is the result of an instantaneous bind-then-release; hand it to the external tool as soon as possible — the 30s deduplication window only mitigates, it does not reserve exclusively.
  2. Each process instance (Count>1) should allocate its own port.
  3. It only binds+closes on the loopback interface, accepts no input parameters and sends no data, so it does not constitute an arbitrary network capability.

app_set_timeout

Purpose: dynamically set this plugin's per-hook execution timeout (in milliseconds); if never called, the default is 30s.

Signature / trigger form:

//go:wasmimport env app_set_timeout
func appSetTimeout(ms uint32)

Parameter table:

ParameterTypeWhat to passExample value
msuint32Timeout in milliseconds; <=0 is ignored120000

Return / response: no return value. The hard limit is 5 minutes; values beyond it are capped. SDK: app.SetTimeout(ms int64).

Code sample:

//go:wasmimport env app_set_timeout
func appSetTimeout(ms uint32)

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    appSetTimeout(120000) // 本钩子单次最多跑 2 分钟
    // ...
}

Notes:

  1. Before every hook execution the host resets the timeout to the default 30s, so each hook must call this on its own.
  2. What you set is the timeout of "this one hook execution", not a global plugin property.
  3. The host also enforces a 5-minute hard limit; even without setting anything the ceiling is 5 minutes.

app_exec_tool

Purpose: synchronously execute an external tool inside the plugin directory and collect stdout/stderr (a command-line tool that exits when done, e.g. a one-shot nuclei scan).

Signature / trigger form:

//go:wasmimport env app_exec_tool
func appExecTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
toolstringTool name (tools/<name>/)Your code"nuclei"
runtimestringpython/java/""Your code""
argsJSONstringJSON array of argumentsYour code"[\"-silent\"]"
out / outCapunsafe.Pointer / uint32Output buffer——

Return / response: returns the number of bytes written; the content is ExecResult JSON:

{"exitCode":0,"stdout":"[info] scan done\n","stderr":"","error":""}

Execution failure/timeout:

{"exitCode":0,"stdout":"","stderr":"","error":"外部工具执行超时(1m0s)"}

Permission / capability requirements: you must declare tools.exec (app.CapToolsExec) in [INIT], the tool must be authorized in the GUI, and the call is scheduled through the global task queue.

Code sample (already wrapped by the SDK):

result := app.ExecTool("nuclei", "", "-l", "targets.txt", "-o", "out.txt")
var res struct {
    ExitCode int    `json:"exitCode"`
    Stdout   string `json:"stdout"`
    Stderr   string `json:"stderr"`
    Error    string `json:"error"`
}
_ = json.Unmarshal([]byte(result), &res)
if res.Error != "" { app.Logf("nuclei 失败: %s", res.Error) }

Notes:

  1. Without the tools.exec declaration it returns {"error":"插件未声明 tools.exec 能力,禁止调用外部工具"}; without authorization it returns "未获用户授权".
  2. The tool must be under tools/<name>/; invoking terminals is forbidden (cmd/powershell/sh/bash and other blacklisted names), and no shell is used.
  3. The default timeout is 60s, with stdout/stderr capped at 4MB each; the queue defaults to concurrency 2, queue depth 32, wait 30s.

app_read_file

Purpose: read a file inside the plugin sandbox (relative path); returns an envelope containing base64 content.

Signature / trigger form:

//go:wasmimport env app_read_file
func appReadFile(path string, out unsafe.Pointer, outCap uint32) uint32

Parameter table:

ParameterTypeWhat to passExample value
pathstringPath relative to the plugin directory"data/toollogs/sqlmapapi.log"
out / outCapunsafe.Pointer / uint32Output buffer—

Return / response: returns the number of bytes written; the content is JSON:

{"ok":true,"data":"aGVsbG8=","size":5,"error":""}

On failure:

{"ok":false,"size":0,"error":"路径穿越被拒绝: ../secret"}

Code sample:

raw := app.ReadFile("data/ext-results/t-1/out.txt")
var env struct {
    OK    bool   `json:"ok"`
    Data  string `json:"data"`
    Error string `json:"error"`
}
_ = json.Unmarshal([]byte(raw), &env)
if env.OK {
    b, _ := base64.StdEncoding.DecodeString(env.Data)
    _ = b // 文件内容
}

Notes:

  1. data is base64; you must decode it to get the file content.
  2. The per-file limit is 4MB, and anything beyond is truncated; only relative paths are accepted — absolute paths, .. and symbolic links are rejected.
  3. Capability name fs.read (app.CapFSRead); the hard constraint is the directory sandbox (see chapter 8).

app_write_file

Purpose: write a file inside the plugin sandbox (parent directories are created automatically).

Signature / trigger form:

//go:wasmimport env app_write_file
func appWriteFile(path string, data unsafe.Pointer, dataLen uint32, out unsafe.Pointer, outCap uint32) uint32

Parameter table:

ParameterTypeWhat to passExample value
pathstringPath relative to the plugin directory"data/state-t1.json"
dataunsafe.PointerPointer to the bytes to writeunsafe.Pointer(&b[0])
dataLenuint32Byte countuint32(len(b))
out / outCapunsafe.Pointer / uint32Output buffer—

Return / response: returns the number of bytes written; the content is JSON:

{"ok":true,"size":128,"error":""}

Code sample:

b, _ := json.Marshal(state)
var obuf [4096]byte
n := appWriteFile("data/exttools-state-t1.json",
    unsafe.Pointer(&b[0]), uint32(len(b)),
    unsafe.Pointer(&obuf[0]), uint32(len(obuf)))
_ = n

Notes:

  1. The host has no "delete file" interface; to clear state, write {} and treat it as empty on the reading side (the sample plugin's clearState does exactly this).
  2. Parent directories are created automatically; symbolic links and out-of-bounds paths are rejected.
  3. Capability name fs.write (app.CapFSWrite).

app_spawn_tool

Purpose: start a tool process under tools/<tool>/ in the background (without waiting for it to exit); stdout/stderr are appended to data/toollogs/<key>.log. Suitable for resident service-type tools (sqlmapapi, an xray listener).

Signature / trigger form:

//go:wasmimport env app_spawn_tool
func appSpawnTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32

Parameter table: argsJSON is a spawnRequest.

ParameterTypeWhat to passExample value
toolstringTool name"xray"
runtimestringpython/java/""""
entrystringEntry file (relative to tools/<tool>/; empty=default lookup); cwd switches to the entry's directory"sqlmapapi.py"
args[]stringCommand-line argument array["--listen","127.0.0.1:1800"]
keystringProcess instance key; multiple instances of the same tool must use different keys"xray#1-1"
dirstringWorking directory (absolute path, must be inside the plugin sandbox)"<output_dir>"
envmap[string]stringAdditional environment variables{"X":"1"}
timeoutSecintTimeout in seconds; the process is killed on timeout; <=0 means no limit900

Return / response: spawnResult:

{"pid":32140,"key":"xray#1-1","log":"data/toollogs/xray#1-1.log","error":""}

Permission / capability requirements: the same as app_exec_tool (tools.exec + GUI authorization); processes are managed by the plugin itself and do not occupy queue slots.

Code sample:

//go:wasmimport env app_spawn_tool
func appSpawnTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32

func spawn(tool, runtime string, args map[string]any) (pid int, key, logRel, errMsg string) {
    b, _ := json.Marshal(args)
    var buf [256 * 1024]byte
    n := appSpawnTool(tool, runtime, string(b), unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Pid   int    `json:"pid"`
        Key   string `json:"key"`
        Log   string `json:"log"`
        Error string `json:"error"`
    }
    _ = json.Unmarshal(buf[:n], &resp)
    return resp.Pid, resp.Key, resp.Log, resp.Error
}

Notes:

  1. Starting the same tool again with the same key stops the old process first ("later start kills earlier start"); multiple instances must use different keys (e.g. nuclei#1/nuclei#2).
  2. entry must still resolve inside tools/<tool>/ (guarding against ..); dir must be inside the plugin sandbox, otherwise it is ignored.
  3. The log file path is returned in log; read it with app_read_file (for example to parse the token printed by sqlmapapi at startup).

app_stop_tool

Purpose: stop tool processes previously started by this plugin.

Signature / trigger form:

//go:wasmimport env app_stop_tool
func appStopTool(tool string, out unsafe.Pointer, outCap uint32) uint32

Parameter table:

ParameterTypeWhat to passExample value
toolstringEmpty=all; otherwise an exact key or a tool#/tool/ prefix"xray"
out / outCapunsafe.Pointer / uint32Output buffer—

Return / response: {"stopped":n}.

{"stopped":2}

Code sample:

//go:wasmimport env app_stop_tool
func appStopTool(tool string, out unsafe.Pointer, outCap uint32) uint32

func stopTool(key string) int {
    var buf [4096]byte
    n := appStopTool(key, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Stopped int `json:"stopped"`
    }
    _ = json.Unmarshal(buf[:n], &resp)
    return resp.Stopped
}

Notes:

  1. Passing a tool name stops all of its #n instances; passing an exact key stops only that single instance; an empty string stops all processes of this plugin.
  2. It can only stop processes started by this plugin; it cannot touch other plugins or system processes.
  3. When the plugin is reloaded or the node exits, the host stops all of this plugin's tool processes as a fallback.

app_tool_status

Purpose: return the liveness status of this plugin's processes (used in the drain phase to determine "are any processes still running?").

Signature / trigger form:

//go:wasmimport env app_tool_status
func appToolStatus(tool string, out unsafe.Pointer, outCap uint32) uint32

Parameter table: tool empty=all; a tool name matches all of its instances, an exact key matches only a single instance.

Return / response:

{"running":1,"exited":1,"list":[{"key":"xray#1-1","tool":"xray","pid":32140,"running":true,"startedAt":1759600000},{"key":"nuclei#2-1","tool":"nuclei","pid":32141,"running":false,"startedAt":1759599000}]}

Code sample:

//go:wasmimport env app_tool_status
func appToolStatus(tool string, out unsafe.Pointer, outCap uint32) uint32

func anyRunning(waitKeys map[string]bool) bool {
    var buf [256 * 1024]byte
    n := appToolStatus("", unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var st struct {
        List []struct {
            Key     string `json:"key"`
            Running bool   `json:"running"`
        } `json:"list"`
    }
    _ = json.Unmarshal(buf[:n], &st)
    for _, p := range st.List {
        if p.Running && waitKeys[p.Key] {
            return true
        }
    }
    return false
}

Notes:

  1. This is the host-side "source of truth" and is more reliable than the plugin keeping its own bookkeeping.
  2. running=false means the process has exited (the host has finished collecting it).
  3. startedAt is in unix seconds and can be compared against the task start time.

app_http_request

Purpose: send an HTTP request to the local loopback address, for plugin interaction with a self-started local service (e.g. the sqlmapapi REST API).

Signature / trigger form:

//go:wasmimport env app_http_request
func appHTTPRequest(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

Parameter table: reqJSON is an httpRequestRequest.

FieldTypeWhat to passExample value
methodstringMethod; empty=GET"POST"
urlstringLoopback URL"http://127.0.0.1:8775/task/new"
headersmap[string]stringRequest headers{"Content-Type":"application/json"}
bodystringRequest body"{}"
timeoutMsintTimeout in milliseconds; default 30s, max 10 minutes1500

Return / response: httpRequestResult.

{"status":200,"body":"{\"success\":true}","error":""}

Code sample:

//go:wasmimport env app_http_request
func appHTTPRequest(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

func probe(host string, port int) bool {
    req, _ := json.Marshal(map[string]any{
        "method": "GET", "url": "http://" + host + ":" + strconv.Itoa(port) + "/", "timeoutMs": 1500,
    })
    var buf [256 * 1024]byte
    n := appHTTPRequest(string(req), unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        Status int    `json:"status"`
        Error  string `json:"error"`
    }
    _ = json.Unmarshal(buf[:n], &resp)
    return resp.Status > 0
}

Notes:

  1. Only 127.0.0.1 / localhost / ::1 are allowed; every other address is rejected with "仅允许本机回环地址".
  2. Request/response bodies are capped at 8MB; the default timeout is 30s (max 10 minutes).
  3. To hit external sites use app_http_replay instead.

app_http_replay

Purpose: replay task traffic through a proxy to the scanned site or to an external passive analyzer (e.g. xray listening on 7777). The target is an external site (not restricted to loopback), an explicit proxy is used, response bodies are discarded, and HTTPS ignores certificate errors.

Signature / trigger form:

//go:wasmimport env app_http_replay
func appHTTPReplay(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

Parameter table: reqJSON is a replayRequest.

FieldTypeWhat to passExample value
methodstringMethod; empty=GET"GET"
urlstringTarget URL (must be http/https)"https://example.com/a"
headersmap[string]stringRestored request headers (Host/Content-Length/Connection are handled by the library){"User-Agent":"curl/8"}
bodystringRequest body, max 4MB""
proxystringReplay proxy; empty=direct"http://127.0.0.1:7777"
timeoutMsintDefault 30s, max 30s30000
asyncbooltrue=send in the background and return immediatelytrue

Return / response: replayResult.

{"started":true,"status":0,"error":""}

Synchronous mode (async=false) on success:

{"started":false,"status":200,"error":""}

Code sample:

//go:wasmimport env app_http_replay
func appHTTPReplay(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

func replayAsync(method, target string, headers map[string]string, body string) {
    req, _ := json.Marshal(map[string]any{
        "method": method, "url": target, "headers": headers,
        "proxy": "http://127.0.0.1:7777", "timeoutMs": 30000, "async": true,
    })
    var buf [64 * 1024]byte
    _ = appHTTPReplay(string(req), unsafe.Pointer(&buf[0]), uint32(len(buf)))
}

Notes:

  1. 应用_OnTaskPacket must use async=true: synchronous waiting would stall the scan dispatch loop.
  2. A URL without http(s):// reports an error; a request body over 4MB is rejected; the timeout ceiling is 30s.
  3. A proxy that is not ready or an unreachable target is normal; the plugin should stay silent (the sample plugin does not treat failures as log noise).

app_task_env

Purpose: return the runtime environment facts of the current task (target list, result directory, path anchors, runtimes).

Signature / trigger form:

//go:wasmimport env app_task_env
func appTaskEnv(taskIde string, out unsafe.Pointer, outCap uint32) uint32

Parameter table:

ParameterTypeWhat to passExample value
taskIdestringTask identifier"t-20261005-01"
out / outCapunsafe.Pointer / uint32Output buffer—

Return / response: taskEnvResult:

{"taskIde":"t-20261005-01","taskName":"内网巡检","nodeUuid":"n-0a1b","nodeName":"node-01","domain":"example.com","targetUrl":"https://example.com/a","domainsFile":"<pluginDir>/data/ext-results/t-20261005-01/targets-domains.txt","urlsFile":"<pluginDir>/data/ext-results/t-20261005-01/targets-urls.txt","outputDir":"<pluginDir>/data/ext-results/t-20261005-01","outputDirRel":"data/ext-results/t-20261005-01","pluginDir":"<pluginDir>","toolsDir":"<pluginDir>/tools","python":"C:\\py\\3.12\\python.exe","java":"java","secTestProxy":"http://127.0.0.1:8080"}
FieldMeaning
taskIde / taskNameTask identifier/name
nodeUuid / nodeNameNode identifier/name
domain / targetUrlPrimary domain / first URL (with scheme)
domainsFile / urlsFileAbsolute paths of the deduplicated domain/URL list files (one per line, for tool -l/-iL)
outputDir / outputDirRelAbsolute path / plugin-relative path of this task's result directory (the latter for app_read_file)
pluginDir / toolsDirAbsolute paths of the plugin root directory / tools directory
python / javaExecutable paths (the external-tool environment takes precedence, falling back to a bare name from PATH)
secTestProxyAddress from this task's "security test proxy settings" (empty when not configured)

Code sample:

//go:wasmimport env app_task_env
func appTaskEnv(taskIde string, out unsafe.Pointer, outCap uint32) uint32

type taskEnv struct {
    TaskIde      string `json:"taskIde"`
    OutputDir    string `json:"outputDir"`
    OutputDirRel string `json:"outputDirRel"`
    DomainsFile  string `json:"domainsFile"`
    ToolsDir     string `json:"toolsDir"`
    Python       string `json:"python"`
}

func fetchTaskEnv(taskIde string) *taskEnv {
    var buf [256 * 1024]byte
    n := appTaskEnv(taskIde, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var env taskEnv
    if json.Unmarshal(buf[:n], &env) != nil {
        return nil
    }
    return &env
}

Notes:

  1. The result directory is fixed at data/ext-results/<taskIde>/ inside the plugin sandbox, and directories older than 48h are cleaned up along the way.
  2. outputDir/domainsFile are absolute paths (for tool arguments), while outputDirRel is a relative path (for app_read_file/app_list_dir).
  3. When a task has no traffic the list files are empty files, and the other fields are still returned.

app_list_dir

Purpose: list the direct children of a directory inside the plugin sandbox (non-recursive, sorted by name), for result collection/status checks.

Signature / trigger form:

//go:wasmimport env app_list_dir
func appListDir(path string, out unsafe.Pointer, outCap uint32) uint32

Parameter table:

ParameterTypeWhat to passExample value
pathstringDirectory path relative to the plugin directory"data/ext-results/t-1"
out / outCapunsafe.Pointer / uint32Output buffer—

Return / response: {ok,entries,error}, where each entry is {name,size,modTime,isDir}.

{"ok":true,"entries":[{"name":"out.html","size":2048,"modTime":1759600100,"isDir":false},{"name":"sub","size":0,"modTime":1759600000,"isDir":true}]}

Code sample:

//go:wasmimport env app_list_dir
func appListDir(path string, out unsafe.Pointer, outCap uint32) uint32

func listDir(rel string) ([]struct{ Name string; Size int64; IsDir bool }, bool) {
    var buf [256 * 1024]byte
    n := appListDir(rel, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    var resp struct {
        OK      bool `json:"ok"`
        Entries []struct {
            Name    string `json:"name"`
            Size    int64  `json:"size"`
            ModTime int64  `json:"modTime"`
            IsDir   bool   `json:"isDir"`
        } `json:"entries"`
        Error string `json:"error"`
    }
    if json.Unmarshal(buf[:n], &resp) != nil || !resp.OK {
        return nil, false
    }
    return resp.Entries, true
}

Notes:

  1. It lists direct children only (non-recursive); modTime is in unix seconds.
  2. Capability name fs.read; paths are constrained by the directory sandbox.
  3. When collecting results, size==0 is commonly used to filter out empty files (an empty file does not count as a vulnerability).

5. All Exported SDK Identifiers

The app.go inside the SDK package (Go version, package app). Copy it into the app/ subdirectory of your plugin project, add replace app => ./app to go.mod, and then import "app".

5.1 Constants

HookTcpDataReceived

Purpose: the hook export name for receiving TCP data. The value is "应用_OnTcpDataReceived".

app.Capability(app.HookTcpDataReceived)
_ = app.HookTcpDataReceived // "应用_OnTcpDataReceived"

It is a capability-name constant, declared with Capability; to actually be called, a function with the same name must still be exported.

HookTcpDataSend

Purpose: the hook export name for sending TCP data. The value is "应用_OnTcpDataSend".

app.Capability(app.HookTcpDataSend)

It pairs with HookTcpDataReceived; be careful not to mix them up.

HookTaskStart

Purpose: the hook export name for task start. The value is "应用_OnTaskStart".

app.Capability(app.HookTaskStart)

Task hooks are usually used together with app.SetTimeout.

HookTaskFilterVulns

Purpose: the hook export name for filtering vulnerabilities before the task starts. The value is "应用_OnTaskFilterVulns".

app.Capability(app.HookTaskFilterVulns)

Together with HookTaskFilterFlows, it covers the two filter kinds: vulnerabilities/traffic.

HookTaskFilterFlows

Purpose: the hook export name for filtering packets before the task starts. The value is "应用_OnTaskFilterFlows".

app.Capability(app.HookTaskFilterFlows)

filterIds must contain flows[].ide.

HookMitmHttpRequest

Purpose: the hook export name for MITM HTTP requests. The value is "应用_OnMitmHttpRequest".

app.Capability(app.HookMitmHttpRequest)

The returned key is request.

HookMitmHttpResponse

Purpose: the hook export name for MITM HTTP responses. The value is "应用_OnMitmHttpResponse".

app.Capability(app.HookMitmHttpResponse)

The returned key is response.

HookMitmWsMessage

Purpose: the hook export name for MITM WebSocket messages. The value is "应用_OnMitmWsMessage".

app.Capability(app.HookMitmWsMessage)

The returned key is message.

HookMitmSseEvent

Purpose: the hook export name for MITM SSE events. The value is "应用_OnMitmSseEvent".

app.Capability(app.HookMitmSseEvent)

The returned key is event.

CapToolsExec

Purpose: the capability name "tools.exec"; only after declaring it are you allowed to call external tools.

app.Capability(app.CapToolsExec)
app.DeclareTool("nuclei", "")

The declaration is only a ticket; GUI authorization is still required for actual execution.

CapFSRead

Purpose: the capability name "fs.read", declaring reads of files inside the plugin directory.

app.Capability(app.CapFSRead)

The hard constraint on file access is the directory sandbox; see chapter 8.

CapFSWrite

Purpose: the capability name "fs.write", declaring writes of files inside the plugin directory.

app.Capability(app.CapFSWrite)

Declaring it together with CapFSRead lets you externalize state across hooks.

5.2 Types

ToolDecl

Purpose: an external tool declaration entry, used in the tools array of [INIT] and in the GUI authorization inventory.

FieldjsonTypeMeaning
NamenamestringTool name (the tools/<name>/ directory)
Runtimeruntimestringpython / java / ""
app.DeclareTool("sqlmap", "python")
_ = app.ToolDecl{Name: "sqlmap", Runtime: "python"}

Runtime determines which runtime environment directory the host prepends to PATH.

TcpDataRequest

Purpose: the request body of the TCP receive/send hooks.

FieldjsonTypeMeaning
UUIDuuidstringIdentifier of this connection/this node
Command1command1stringLevel-1 command
Command2command2stringLevel-2 command
Command3command3stringLevel-3 command (target UUID)
Command4command4stringLevel-4 command (source UUID)
Sourcesourcestring0=GUI 1=controller 2=scan node
CommandAcommandAstringBusiness action name
DatadatastringRaw Data section
var req app.TcpDataRequest
_ = app.LoadRequest(&req)
app.Log(req.Command2 + ":" + req.Data)

In the send hook, source/commandA are usually empty.

TcpDataResponse

Purpose: the response body of the TCP receive/send hooks.

FieldjsonTypeMeaning
Handledhandledbooltrue=consumed (only effective in the receive hook)
ErrorerrorstringError message (logged only)
DatadatastringNon-empty and different → replace/re-parse
app.WriteResp(app.TcpDataResponse{Data: req.Data + "_x"})

Empty Data means no modification.

TaskStartRequest

Purpose: the request body of 应用_OnTaskStart (containing only filter/routing-related fields).

FieldjsonTypeMeaning
TaskIdetaskIdestringTask identifier
TaskNametaskNamestringTask name
ProxyModeproxyModestringauto/direct/tunnel
SourceUUIDsourceUUIDstringSource (GUI) UUID
TargetUUIDtargetUUIDstringDelivery target UUID
SelectedVulnIdsselectedVulnIds[]stringVulnerabilities to scan
SelectedVulnIdsAddselectedVulnIdsAdd[]stringAdditionally added vulnerabilities
HttpSelectedScanUrlRowIdehttpSelectedScanUrlRowIde[]stringSelected HTTP packets
WsSelectedScanUrlRowIdewsSelectedScanUrlRowIde[]stringSelected WS packets
SSESelectedScanUrlRowIdesseSelectedScanUrlRowIde[]stringSelected SSE packets
HostsContenthostsContentstringhosts content
ScanningRangescanningRangestringRestrict the scanning range
SkipScanDomainIPskipScanDomainIPstringDomains/IPs to skip
var req app.TaskStartRequest
_ = app.LoadRequest(&req)
app.Logf("任务 %s 漏洞数=%d", req.TaskName, len(req.SelectedVulnIds))

Field names are case-sensitive; in JSON it is selectedVulnIds.

TaskStartModify

Purpose: the fields 应用_OnTaskStart can modify (omitempty, nil/empty=no modification).

FieldjsonTypeMeaning
SelectedVulnIdsselectedVulnIds,omitempty[]stringReplace the vulnerability list
SelectedVulnIdsAddselectedVulnIdsAdd,omitempty[]stringReplace the additional vulnerabilities
HttpSelectedScanUrlRowIdehttpSelectedScanUrlRowIde,omitempty[]stringReplace the HTTP selection
WsSelectedScanUrlRowIdewsSelectedScanUrlRowIde,omitempty[]stringReplace the WS selection
SSESelectedScanUrlRowIdesseSelectedScanUrlRowIde,omitempty[]stringReplace the SSE selection
HostsContenthostsContent,omitemptystringReplace hosts
app.WriteResp(app.TaskStartResponse{
    Handled: true,
    Task:    app.TaskStartModify{SelectedVulnIds: keep},
})

With omitempty an empty slice is dropped, so an empty slice cannot be used to "clear" a field.

TaskStartResponse

Purpose: the response body of 应用_OnTaskStart.

FieldjsonTypeMeaning
HandledhandledboolParsed but does not affect the flow at present
ErrorerrorstringError message
TasktaskTaskStartModifyThe task modifications to apply
app.WriteResp(app.TaskStartResponse{Handled: true, Task: mod})

Task modifications are always applied from task.

TaskVulnBrief

Purpose: the vulnerability summary used by the vulnerability filter hook.

FieldjsonTypeMeaning
VulnIdevulnIdestringUnique vulnerability identifier
NamenamestringVulnerability name
LevellevelstringLevel 4/3/2/1
PocTypepocTypestringyaml/go/wasm
for _, v := range req.Vulns { app.Logf("%s %s", v.VulnIde, v.Name) }

VulnIde is exactly the value to put into filterIds.

TaskFlowBrief

Purpose: the packet summary used by the packet filter hook.

FieldjsonTypeMeaning
IdeidestringPacket identifier (IdeTraffic)
Typetypestringhttp/websocket/sse
MethodmethodstringRequest method
URLurlstringRequest URL
DomaindomainstringDomain
TLStlsstringHTTP/HTTPS/WSS
for _, f := range req.Flows { app.Logf("%s %s", f.Ide, f.Domain) }

When excluding, put f.Ide into filterIds.

TaskFilterResponse

Purpose: the unified response for filter hooks.

FieldjsonTypeMeaning
HandledhandledboolNo effect
ErrorerrorstringError message
FilterIdsfilterIds[]stringSet of identifiers to exclude
app.WriteResp(app.TaskFilterResponse{FilterIds: drop})

Return the ones to exclude, not the ones to keep.

MitmHttpRequest

Purpose: an MITM HTTP request snapshot.

FieldjsonTypeMeaning
TaskIdetaskIdestringTask identifier
MethodmethodstringRequest method
URLurlstringFull URL
ProtoprotostringProtocol version
HosthoststringHost
Headersheadersmap[string][]stringRequest headers
BodybodystringDecompressed request body
RemoteIPremoteIpstringRemote server IP
TLStlsstringHTTP/HTTPS
var req app.MitmHttpRequest
_ = app.LoadRequest(&req)
app.Logf("%s %s", req.Method, req.URL)

Headers is a map[string][]string.

MitmHttpModify

Purpose: the modifiable fields of an HTTP request/response (omitempty).

FieldjsonTypeMeaning
Methodmethod,omitemptystringReplace the method
URLurl,omitemptystringReplace the URL
Headersheaders,omitemptymap[string][]stringReplace the whole header map
Bodybody,omitemptystringReplace the body
StatusCodestatusCode,omitemptyintUsed by responses only (effective when >0)
_ = app.MitmHttpModify{URL: req.URL, Body: "x"}

Shared by both request and response sides, but the response side does not use method/url.

MitmHttpResponse

Purpose: an MITM HTTP response snapshot.

FieldjsonTypeMeaning
TaskIdetaskIdestringTask identifier
URLurlstringRequest URL
MethodmethodstringRequest method
StatusCodestatusCodeintResponse status code
Headersheadersmap[string][]stringResponse headers
BodybodystringDecompressed response body
RemoteIPremoteIpstringRemote server IP
TLStlsstringHTTP/HTTPS
var resp app.MitmHttpResponse
_ = app.LoadRequest(&resp)
app.Logf("status=%d", resp.StatusCode)

Compared with the request snapshot: no Proto/Host, plus StatusCode.

MitmWsMessage

Purpose: an MITM WebSocket message frame.

FieldjsonTypeMeaning
TaskIdetaskIdestringTask identifier
URLurlstringConnection URL
DomaindomainstringDomain
FromClientfromClientbooltrue=client→server
StatusTypestatusTypeint1=send 2=receive
ContentcontentstringMessage content
var msg app.MitmWsMessage
_ = app.LoadRequest(&msg)
app.Logf("fromClient=%v content=%s", msg.FromClient, msg.Content)

Prefer FromClient to judge direction.

MitmWsModify

Purpose: the modifiable fields of a WebSocket message.

FieldjsonTypeMeaning
Contentcontent,omitemptystringReplace the message content
_ = app.MitmWsModify{Content: "pong"}

An empty content does not override.

MitmSseEvent

Purpose: an MITM SSE event.

FieldjsonTypeMeaning
TaskIdetaskIdestringTask identifier
URLurlstringConnection URL
EventeventstringThe event: field (default message)
IDidstringThe id: field
DatadatastringThe data: field
RetryretryintThe retry: field (milliseconds)
var evt app.MitmSseEvent
_ = app.LoadRequest(&evt)
app.Logf("event=%s id=%s", evt.Event, evt.ID)

ID maps to the json id.

MitmSseModify

Purpose: the modifiable fields of an SSE event.

FieldjsonTypeMeaning
Datadata,omitemptystringReplace the event data
_ = app.MitmSseModify{Data: "new-data"}

An empty data does not override.

NodeInfo

Purpose: information about the current scan node (returned by GetNodeInfo).

FieldjsonTypeMeaning
UUIDuuidstringNode UUID
NodeNamenodeNamestringNode name
LanguagelanguagestringCurrent language
AppIDappIdstringApplication ID
info := app.GetNodeInfo()
app.Logf("node=%s lang=%s", info.NodeName, info.Language)

The host actually returns one extra field, pluginId, which the SDK's NodeInfo does not include.

5.3 Functions

Capability(name string)

Purpose: declare a single capability point (deduplicated automatically).

Signature / parameters / return: func Capability(name string); name is the capability name (a hook name or tools.exec etc.); no return value.

app.Capability(app.HookTaskStart)

An empty string is ignored.

Capabilities(names ...string)

Purpose: declare capability points in bulk.

Signature / parameters / return: func Capabilities(names ...string); no return value.

app.Capabilities(app.HookTaskStart, app.HookTaskPacket, app.HookTaskEnd)

Internally it calls Capability one by one; you can pass any number of names.

DeclareTool(name, runtime string)

Purpose: declare one external tool (deduplicated).

Signature / parameters / return: func DeclareTool(name, runtime string); runtime is python/java/""; no return value.

app.DeclareTool("sqlmap", "python")

The tool must live under tools/<name>/.

DeclareTools(pairs ...string)

Purpose: declare tools in bulk, in the format "name=runtime".

Signature / parameters / return: func DeclareTools(pairs ...string); no return value.

app.DeclareTools("sqlmap=python", "xray=java", "nuclei")

Without = the runtime is empty (a native executable).

Declare()

Purpose: serialize the already declared capabilities and tools into [INIT] lines written to stdout.

Signature / parameters / return: func Declare(); no return value; emits [INIT] {"capabilities":[...],"tools":[...]}.

app.Capability(app.HookTaskStart)
app.DeclareTool("nuclei", "")
app.Declare()

It must be called inside a hook function, otherwise this run's stdout has no [INIT].

LoadRequest(v any) error

Purpose: read the request JSON injected by the host from stdin and unmarshal it into v (called once per hook execution).

Signature / parameters / return: func LoadRequest(v any) error; v is a pointer to the request struct; returns an error on failure.

var req app.TcpDataRequest
if err := app.LoadRequest(&req); err != nil {
    app.WriteError(err)
    return
}

Each hook reads stdin only once (io.ReadAll(os.Stdin)).

WriteResp(v any)

Purpose: write the response to stdout as a single [RESP] <JSON> line (parsed by the host).

Signature / parameters / return: func WriteResp(v any); no return value.

app.WriteResp(app.TcpDataResponse{Data: "x"})

The value v is json.Marshaled; make sure it can be encoded as an object.

WriteHandled()

Purpose: declare that the plugin has consumed this event; equivalent to WriteResp({"handled":true}).

Signature / parameters / return: func WriteHandled(); no return value.

//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() { app.WriteHandled() }

Only handled=true from the receive hook skips the built-in handling.

WriteError(err error)

Purpose: return an error message (only logged; it does not affect the main program flow).

Signature / parameters / return: func WriteError(err error); no return value; emits {"error":"..."}.

app.WriteError(fmt.Errorf("请求解析失败:%v", err))

Do not simply return on an error path without writing any response, otherwise the host sees no [RESP].

Log(msg string)

Purpose: emit a debug log (through the app_log host function).

Signature / parameters / return: func Log(msg string); no return value.

app.Log("工具已启动")

It is unrelated to WriteResp and does not participate in the stdout line protocol.

Logf(format string, args ...any)

Purpose: formatted logging; equivalent to Log(fmt.Sprintf(...)).

Signature / parameters / return: func Logf(format string, args ...any); no return value.

app.Logf("pid=%d key=%s", pid, key)

On high-frequency paths, keep the log volume under control.

SetTimeout(ms int64)

Purpose: set this plugin's per-hook execution timeout (in milliseconds).

Signature / parameters / return: func SetTimeout(ms int64); no return value; the hard limit is 5 minutes.

app.SetTimeout(290000)

Before every hook execution the timeout is reset to the default 30s, so each hook must set it itself.

ExecTool(tool, runtime string, args ...string) string

Purpose: synchronously execute an external tool inside the plugin directory and return the host JSON {exitCode,stdout,stderr,error}.

Signature / parameters / return: func ExecTool(tool, runtime string, args ...string) string; returns the result JSON text.

raw := app.ExecTool("nuclei", "", "-l", env.DomainsFile, "-o", out)
var res struct {
    ExitCode int    `json:"exitCode"`
    Stdout   string `json:"stdout"`
    Error    string `json:"error"`
}
_ = json.Unmarshal([]byte(raw), &res)

Requires tools.exec + GUI authorization + global queue scheduling.

ReadFile(path string) string

Purpose: read a file inside the plugin sandbox; returns {ok,data(base64),size,error}.

Signature / parameters / return: func ReadFile(path string) string; returns the envelope JSON.

raw := app.ReadFile("data/out.txt")
var env struct{ OK bool `json:"ok"`; Data string `json:"data"`; Error string `json:"error"` }
_ = json.Unmarshal([]byte(raw), &env)

data is base64 and must be decoded.

WriteFile(path string, data []byte) string

Purpose: write a file inside the plugin sandbox; returns {ok,size,error}.

Signature / parameters / return: func WriteFile(path string, data []byte) string; returns the envelope JSON.

_ = app.WriteFile("data/state.json", []byte(`{"n":1}`))

Parent directories are created automatically; there is no delete interface.

SendTcpData(cmd1, cmd2, cmd3, cmd4, data string)

Purpose: send raw TCP data (four-level command + raw data section).

Signature / parameters / return: func SendTcpData(cmd1, cmd2, cmd3, cmd4, data string); no return value.

app.SendTcpData("relayData", "MyEvent", "gui", "", "raw-data")

cmd3 is the target; when cmd4 is empty this node is filled in automatically.

SendTcpDataJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any)

Purpose: send TCP JSON data (with the {CommandA,Data} wrapping done automatically).

Signature / parameters / return: func SendTcpDataJson(cmd1, cmd2, cmd3, cmd4, commandA string, data any); no return value.

app.SendTcpDataJson("scan", "SaveScanVuln", "", "", "SaveScanVuln", vuln)

Pass a struct as data, do not marshal it into []byte yourself.

ReinjectTcpData(td TcpDataRequest)

Purpose: inject one TCP message back into the host's receive flow (depth limited to 5 levels).

Signature / parameters / return: func ReinjectTcpData(td TcpDataRequest); no return value.

app.ReinjectTcpData(app.TcpDataRequest{
    UUID: "n-0a1b", Command1: "relayData", Command2: "MyEvent",
    Command3: "gui", Command4: "n-0a1b", Source: "2", Data: "raw",
})

During re-injection this hook is not called again; injections beyond the depth limit are discarded.

ConfigGet(key string) string

Purpose: read a Key/Value entry from plugin.config.json.

Signature / parameters / return: func ConfigGet(key string) string; returns plain text (not JSON).

if app.ConfigGet("Enabled") == "true" { /* ... */ }

A missing key returns an empty string.

T(key string) string

Purpose: read language-pack text (key space <lang>.<uuid>.<key>).

Signature / parameters / return: func T(key string) string; returns the text; falls back to the key when missing.

title := app.T("VulnName")

The language-pack files are language-cn.json / language-en.json.

GetNodeInfo() NodeInfo

Purpose: obtain information about the current scan node.

Signature / parameters / return: func GetNodeInfo() NodeInfo; returns app.NodeInfo.

info := app.GetNodeInfo()
app.Logf("node=%s", info.NodeName)

The SDK struct does not include the pluginId the host returns; when you need it, declare your own struct and read app_node_info.

CallBuiltin(funcID uint32, args ...any) (string, bool)

Purpose: call a host built-in tool function (the unified app_call entry point).

Signature / parameters / return: func CallBuiltin(funcID uint32, args ...any) (string, bool); false means no result.

s, ok := app.CallBuiltin(20, "hello") // MD5

For funcID see the table in 4.5; on error it returns {"error":...} text.

Base64Encode(data string) string

Purpose: Base64 encoding (funcID 10).

Signature / parameters / return: func Base64Encode(data string) string.

enc := app.Base64Encode("hello")

Internally it is CallBuiltin(10, data).

Base64Decode(data string) string

Purpose: Base64 decoding (funcID 11).

Signature / parameters / return: func Base64Decode(data string) string.

plain := app.Base64Decode(enc)

Invalid input is answered by the host with error text.

MD5(data string) string

Purpose: MD5 hash (lowercase hex, funcID 20).

Signature / parameters / return: func MD5(data string) string.

sum := app.MD5("hello")

In-memory hashing.

SHA1(data string) string

Purpose: SHA1 hash (funcID 21).

Signature / parameters / return: func SHA1(data string) string.

sum := app.SHA1(token)

Used for deduplication hashes and similar scenarios.

SHA256(data string) string

Purpose: SHA256 hash (funcID 23).

Signature / parameters / return: func SHA256(data string) string.

sum := app.SHA256(payload)

Consistent with how the host computes SHA-256 over tool files.

JSONGet(data []byte, path string) string

Purpose: read a JSON value by path (funcID 50; paths such as a.b[0].c).

Signature / parameters / return: func JSONGet(data []byte, path string) string.

name := app.JSONGet([]byte(`{"a":{"b":[{"c":"x"}]}}`), "a.b[0].c")

Returns the value in string form.


6. Build, Directory and go.mod

6.1 Plugin Directory Structure

scan-poc/plugin/<uuid>/
├── build/
│   └── scan.wasm              # build artifact (required; the host locates build/*.wasm, preferring scan.wasm)
├── plugin.config.json         # plugin Key/Value config (optional)
├── language-cn.json           # Chinese language pack (optional; key space <lang>.<uuid>.<key>)
├── language-en.json           # English language pack (optional)
├── sig.json                   # optional Ed25519 signature information
└── tools/<name>/              # external tools (optional; usable after declaration and authorization)
    ├── bin/<name>[.exe]       # executable
    └── <name>.py / <name>.jar # python / java tool entry point

The host supports two layouts: the standard root/<uuid>/build/scan.wasm and the legacy root/<uuid>/Plugin/scan/<uuid>/build/scan.wasm. If sig.json exists and signature verification fails (verify_failed), the plugin is refused loading; a missing signature is recorded as unsigned (trusting the controller's AES-GCM delivery channel).

6.2 Build Command

GOWORK=off GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .

-buildmode=c-shared is mandatory: the host needs to call _initialize to initialize the Go runtime and only then call the 应用_xxx exports directly. func main() {} must be kept (required by c-shared mode; an empty implementation is enough).

6.3 go.mod and SDK Reference

module my-plugin

go 1.22

After placing the SDK package's app.go into the project's app/ subdirectory:

require app v0.0.0

replace app => ./app

Exported functions use //go:wasmexport with a Chinese-prefixed name:

//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() { /* ... */ }

func main() {}
警告

The exported function name must carry the 应用_ prefix (e.g. 应用_OnTaskStart) and must match the host constants exactly. Writing OnTaskStart (without the prefix) will not be recognized and the plugin will be treated as "implementing no capability at all". Moreover, the 应用_ prefix is the sole basis for capability detection, so it must be letter-perfect.


7. Advanced External-Tool Capabilities

An application plugin lets the platform integrate external scanners such as nuclei / xray / sqlmap with no external-tool-specific code on the platform side. The core gate is capability declaration + user authorization.

7.1 Declaration and Authorization

  1. In 应用_Init the plugin declares tools with DeclareTool / DeclareTools (or by directly building the tools array of [INIT]); tools must live under tools/<name>/;
  2. After the plugin is loaded, the host computes the SHA-256 of the tool executable and, together with the machine fingerprint, sends a ToolAuthRequest popup to the GUI;
  3. After the user confirms in the GUI, a ToolAuthConfirm comes back and the host records allowed/denied per pluginUUID/tool (persisted on the node side, with decisions kept per GUI user);
  4. Before any later call to app_exec_tool / app_spawn_tool, the host checks: is tools.exec declared + is the authorization state "allowed".

Authorization state machine:

StateTrigger condition
unauthorizedDefault; or the tool hash changed; or a different machine (fingerprint changed)
allowedThe user allowed it; on the same machine + same hash, the next start trusts the existing grant and skips the popup
deniedThe user refused; it is no longer executed from that user's perspective

Multi-user determination: if any GUI user trusts → allowed; if all refuse → denied. Without the tools.exec declaration it directly reports "插件未声明 tools.exec 能力,禁止调用外部工具".

7.2 Differences Between app_exec_tool and app_spawn_tool

Dimensionapp_exec_tool (synchronous, one-shot)app_spawn_tool (background, resident)
Waits for exit?YesNo (returns the pid immediately)
Output{exitCode,stdout,stderr,error} (stdout/stderr capped at 4MB each)Appends to data/toollogs/<key>.log (readable with app_read_file)
Suitable forCommand-line tools that exit when done (a one-shot nuclei scan)Resident service-type tools (sqlmapapi, an xray listener)
Process managementNone (ends when execution ends)app_stop_tool / app_tool_status / auto-kill on timeout
Multiple instancesNot applicableCoexist with different keys (nuclei#1, nuclei#2)

7.3 Global Task Queue Scheduling

All app_exec_tool calls are scheduled through the host's global task queue so a plugin cannot start a large number of processes at once and overwhelm the machine:

  • The global concurrency cap is 2 by default;
  • The queue depth cap is 32 by default; anything beyond is rejected outright;
  • Queue waiting is 30s by default; on timeout it returns "queue busy";
  • app_spawn_tool goes through the same authorization checks, but processes are managed by the plugin itself (it does not occupy a queue slot).

7.4 Result Collection

External tool result files must land inside {output_dir} (returned by app_task_env, i.e. data/ext-results/<taskIde>/ inside the plugin sandbox); the plugin collects them with app_read_file / app_list_dir using relative paths, then reports through app_send_tcp_json(..., "SaveScanVuln", ...). Placeholders are expanded in the command template and in result paths; a result path that escapes {output_dir} is ignored.

7.5 The 18 Command-Template Placeholders

PlaceholderMeaning
{task_id}Task taskIde
{task_name}Task name
{node_id}Node UUID
{node_name}Node name
{domain}Primary domain
{target_url}First URL (with scheme)
{domains_file}Absolute path of the deduplicated domain list file
{urls_file}Absolute path of the deduplicated URL list file
{output_dir}Absolute path of this task's result output directory
{tools_dir}Absolute path of the plugin tools directory
{plugin_dir}Absolute path of the plugin root directory
{python}python executable (the external-tool environment takes precedence)
{java}java executable (the external-tool environment takes precedence)
{proxy}This tool's replay proxy (e.g. http://127.0.0.1:{port})
{sec_proxy}Upstream proxy from the task's "security test proxy settings" (empty when not configured)
{seq}Process sequence number
{port}Free port for this process (allocated by app_free_port)
{timestamp}Start timestamp (20060102-150405)
提示

{port} and {seq} are instance-level placeholders: when the same tool runs with Count>1, each process instance should be allocated its own port to avoid colliding with itself. Concurrent tasks also stay out of each other's way because ports and state files are isolated per task.

7.6 Runtime Status Reporting (Optional)

The sample plugin plugin-exttools demonstrates generic PluginStatusReport reporting: take pluginId/uuid from app_node_info, aggregate app_tool_status plus each task's state files, and report through app_send_tcp_json("scan","PluginStatusReport","","","PluginStatusReport", payload); the GUI plugin page then shows the running status of each tool on each scan node.

app.SendTcpDataJson("scan", "PluginStatusReport", "", "", "PluginStatusReport", map[string]any{
    "pluginId": info.PluginID, "nodeUuid": info.UUID, "nodeName": info.NodeName, "data": snapshot,
})

8. File Sandbox

app_read_file (fs.read), app_write_file (fs.write) and app_list_dir (fs.read) can only access relative paths inside the plugin's own directory:

  • Only relative paths are accepted; absolute paths and leading / are rejected;
  • ../ path traversal is rejected;
  • Symbolic links are rejected (escape prevention);
  • The final path is checked with filepath.Rel and must land inside the plugin directory; otherwise it reports "path out of bounds".

app_write_file creates parent directories automatically. app_read_file is capped at 4MB per file, and anything beyond is truncated. The dir parameter of app_spawn_tool (an absolute path) must likewise land inside the plugin sandbox, otherwise it is ignored.

注意

Sandbox rejection is a hard rejection: any attempt to read files outside the plugin directory with ..\\, an absolute path or a symbolic link returns an error directly, with no fallback. Do not rely on any use of "reading files outside the sandbox".


9. Timeouts and Stability

The host provides multiple layers of stability protection for application plugins:

MechanismDescription
Default timeoutA single hook execution defaults to 30s; the plugin can reset it with app_set_timeout(ms) (SetTimeout)
Hard limitapp_set_timeout has a 5-minute hard limit; values beyond it are capped
Serial executionOnly one execution per plugin at a time; a hook triggered again while one is running is skipped outright (anti-reentrancy/deadlock)
WatchdogAn independent timer per call; on timeout it returns an error immediately without blocking the scan process
Poisoned rebuildAfter a timeout the runtime is marked poisoned and rebuilt automatically on the next call (a stuck plugin does not affect subsequent ones)
Crash recoverrecover() inside the execution goroutine turns a plugin panic into an error, so it cannot bring down the host
Compile protectionCompilation also runs under a context with a timeout, so a malformed/malicious wasm cannot hang the scan process
Anti-reentrancyThe send hook triggered by app_send_tcp and the re-injection of app_tcp_received_data (depth 5) both have loop protection
Plugin capAt most 50 plugins are loaded; anything beyond is skipped
Process cleanupOn plugin reload/node exit, all resident tool processes it started are stopped automatically
警告

The re-injection depth limit (ReinjectTcpData) is 5 levels: if the plugin's forwarding logic forms a loop, the 6th level is discarded outright. Judge the message source inside the plugin yourself, and avoid injecting an "already processed" message again. Likewise, a hook of the same plugin triggered during hook execution is skipped, so do not design logic that depends on recursive callbacks.


10. Debugging Manual

10.1 Where to Find the Logs

Application plugin logs have two sources, and both end up in the scan node log:

SourceHow it is producedHost destination
app.Log / app.LogfCalls the app_log host functionCollected into this execution's log buffer, finally written into the scan node log as 插件[<uuid>] 日志: ...
[LOG] lines or other stdoutYou write os.Stdout.WriteString("[LOG] xxx\n") directlyThe host parses the [LOG] prefix and collects it into this execution's log, just like app_log

The host itself also emits logs about compile failures, hook execution failures, timeouts, skips, and so on, for example:

插件[plugin-x] 编译失败: 编译 wasm 模块失败: ...
插件[plugin-x] 应用_OnTaskStart 执行失败: 插件执行超时(默认 30s,可调用 app_set_timeout 调整)
插件[plugin-x] 声明了能力 应用_OnXxx 但未导出对应钩子函数,无法调用(跳过)

[RESP] is a parsing channel, not a log: as soon as the host parses [RESP] <JSON> it uses it immediately and does not print the raw text. So when debugging, besides reading logs, confirm that the plugin "really wrote [RESP]".

10.2 The 5-Minute Minimal Verification Flow

  1. Create the project: make a new directory, write module my-plugin + go 1.22 in go.mod, copy the SDK package's app.go into the app/ subdirectory, and add replace app => ./app.
  2. Write the minimal plugin (code below) and save it as main.go.
  3. Build: GOWORK=off GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm ..
  4. Place the files: put scan.wasm at scan-poc/plugin/plugin-demo-9999/build/scan.wasm (on a development machine you can place it manually; no store delivery needed).
  5. Trigger once: restart the scan node (or have the node reload plugins), then trigger a task / have the node receive one TCP message.
  6. Check the log and [RESP]: the log should contain "插件[plugin-demo-9999] 日志: [...]", showing the hook was executed; if your modified data also takes effect, the chain works end to end.
package main

import "app"

//go:wasmexport 应用_Init
func OnInit() {
    app.Capabilities(app.HookTcpDataReceived)
    app.Declare()
    app.WriteResp(map[string]any{"handled": false})
}

//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() {
    var req app.TcpDataRequest
    if app.LoadRequest(&req) != nil {
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    app.Logf("hook ok: command2=%s", req.Command2)
    app.WriteResp(app.TcpDataResponse{Handled: false, Data: req.Data})
}

func main() {}

10.3 Symptom → Cause → Fix

SymptomCauseFix
The plugin does nothing at all; the log shows no compile/execute at allForgot -buildmode=c-shared (a command module runs _start to completion and exits)Add -buildmode=c-shared to the build
Link failure / compile errorMissing func main() {}Keep an empty func main() {}
The plugin loads but no hook is ever calledThe exported function name lacks the 应用_ prefixUse //go:wasmexport 应用_OnXxx, letter-for-letter identical to the host constant
The capability is declared but the host does not call itOnly declared in [INIT], without exporting the corresponding 应用_xxx functionExport-table detection governs: you must really //go:wasmexport the hook
应用_Init does not run and tool declarations are lost应用_Init is not exported, or Declare() is written in package initialization/global variablesExport 应用_Init and call Declare() inside the hook function
app_exec_tool returns "tools.exec capability not declared"tools.exec was not declaredapp.Capability(app.CapToolsExec) (inside 应用_Init)
app_exec_tool returns "not authorized by the user"The tool was not authorized in the GUI, or the hash/machine changedConfirm in the GUI plugin page; a different machine or changed tool file requires re-authorization
The host cannot parse any result; it seems unresponsiveWriteResp did not emit the [RESP] prefix (or you assembled non-single-line JSON yourself)Use the SDK's app.WriteResp, which emits a single [RESP] <JSON> line
handled=true but the built-in handling still runsWrong hook: only 应用_OnTcpDataReceived's handled takes effectFollow the "does handled take effect" table in chapter 3
A field was changed but has no effectWrong JSON field name in the request/response (case/camelCase, e.g. selectedvulnids, filterids)Follow the SDK field names/json tags exactly (selectedVulnIds, filterIds, request, message)
Re-injected data is discardedThe re-injection depth exceeded 5 levelsJudge the message source and avoid ReinjectTcpData on already-processed messages; the depth limit is 5
app_http_request reports "only local loopback addresses are allowed"The target is not 127.0.0.1/localhost/::1For local services use app_http_request; for external sites use app_http_replay
app_read_file/app_list_dir reports "path out of bounds/absolute paths not allowed"An absolute path, .. or a symbolic link was usedUse only relative paths inside the plugin directory; when an absolute path is needed, pass app_task_env's outputDir to an external tool
A long task is interrupted with "plugin execution timeout"The default 30s timeoutCall app.SetTimeout(ms) at the start of the hook; the hard limit is 5 minutes; drain works in rounds by returning done=false, so do not sleep
Subsequent calls behave oddly or slow down after a timeoutThe runtime was marked poisoned and is being rebuiltNormal: the next call rebuilds the runtime automatically; keep investigating why it timed out
Multiple instances of the same tool "kill the earlier one on start"app_spawn_tool used the same key (bookkeeping defaults to the tool name)Pass different keys (nuclei#1/nuclei#2)
Scanning slows down and the dispatch loop stallsHeavy synchronous work inside 应用_OnTaskPacketBe fast in and fast out; use async=true for app_http_replay; leave heavy work to 应用_OnTaskEnd
The peer receives a base64 string instead of a JSON objectYou json.Marshaled the struct into a []byte before passing it to SendTcpDataJsonPass the struct/any directly and let the SDK marshal it
Result files cannot be collectedThe results were written outside {output_dir}Anchor the tool's dir and result paths to app_task_env's outputDir; collect with outputDirRel
重要

The host functions of an application plugin and the scan_* of a WASM POC do not overlap at all: using the wrong set results in an instantiation failure, not "a function returning an error". Before releasing, confirm that the plugin's effective path is the "plugin store/application plugin directory" rather than the "POC template list".


11. Complete Examples

All of the examples below can be built with the command from chapter 6. Examples one to three only need the SDK; example four is an external-tool advanced case (requires tools.exec and GUI authorization).

11.1 Example One: Minimal Plugin (implements only TCP receive)

package main

import (
    "errors"

    "app"
)

//go:wasmexport 应用_Init
func OnInit() {
    app.Capability(app.HookTcpDataReceived)
    app.Declare() // 输出 [INIT],声明本插件能力
    app.WriteResp(map[string]any{"handled": false})
}

//go:wasmexport 应用_OnTcpDataReceived
func OnTcpDataReceived() {
    var req app.TcpDataRequest
    if app.LoadRequest(&req) != nil {
        app.WriteError(errors.New("请求解析失败"))
        return
    }
    app.Log("收到 TCP 数据: " + req.Command2)
    // 在原数据后追加标记,宿主会用新数据重新解析后再分发
    app.WriteResp(app.TcpDataResponse{Data: req.Data + "_processed"})
}

func main() {}
提示

If you only want to "observe" rather than modify data, return app.WriteResp(app.TcpDataResponse{}) (an empty data means no change); to consume the event and skip the built-in handling, return app.WriteHandled().

11.2 Example Two: 应用_OnTaskStart Dynamically Trimming Vulnerabilities and Traffic

package main

import (
    "strings"

    "app"
)

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    app.SetTimeout(120000)
    app.Capability(app.HookTaskStart)
    app.Declare()
    var req app.TaskStartRequest
    _ = app.LoadRequest(&req)

    // 只扫描名称含 "rce" 的 POC
    keep := make([]string, 0, len(req.SelectedVulnIds))
    for _, id := range req.SelectedVulnIds {
        if strings.Contains(strings.ToLower(id), "rce") {
            keep = append(keep, id)
        }
    }
    // 剔除测试环境域名对应的数据包选择(按前缀判断)
    httpKeep := make([]string, 0, len(req.HttpSelectedScanUrlRowIde))
    for _, ide := range req.HttpSelectedScanUrlRowIde {
        if !strings.HasPrefix(ide, "test-") {
            httpKeep = append(httpKeep, ide)
        }
    }
    app.Logf("任务 %s:漏洞 %d→%d,HTTP 包 %d→%d",
        req.TaskName, len(req.SelectedVulnIds), len(keep),
        len(req.HttpSelectedScanUrlRowIde), len(httpKeep))
    app.WriteResp(app.TaskStartResponse{
        Handled: true,
        Task: app.TaskStartModify{
            SelectedVulnIds:           keep,
            HttpSelectedScanUrlRowIde: httpKeep,
        },
    })
}

func main() {}

11.3 Example Three: 应用_OnMitmHttpRequest Rewriting Request Headers

package main

import "app"

//go:wasmexport 应用_OnMitmHttpRequest
func OnMitmHttpRequest() {
    app.Capability(app.HookMitmHttpRequest)
    app.Declare()
    var req app.MitmHttpRequest
    _ = app.LoadRequest(&req)

    headers := map[string][]string{}
    for k, v := range req.Headers {
        headers[k] = v
    }
    headers["X-Tss-Powered-By"] = []string{"TestSecScan-Plugin"}
    headers["X-Original-URL"] = []string{req.URL}

    resp := struct {
        Handled bool               `json:"handled"`
        Request app.MitmHttpModify `json:"request"`
    }{Request: app.MitmHttpModify{Headers: headers}}
    app.WriteResp(resp)
}

func main() {}

11.4 Example Four: Calling an External Tool to Scan and Report Results (Advanced)

The example below shows the complete skeleton of "start a tool at task start → replay packets → collect and report results at task finalization", demonstrating app_task_env, app_free_port, app_spawn_tool, app_http_replay, app_tool_status, app_list_dir, app_read_file and the SaveScanVuln reporting channel (host functions not wrapped by the SDK are declared by yourself as in 4.14–4.20).

package main

import (
    "encoding/base64"
    "encoding/json"
    "unsafe"

    "app"
)

// ---- 未封装宿主函数声明 ----
//go:wasmimport env app_free_port
func hostFreePort(out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_spawn_tool
func hostSpawnTool(tool, runtime, argsJSON string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_stop_tool
func hostStopTool(tool string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_tool_status
func hostToolStatus(tool string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_http_replay
func hostHTTPReplay(reqJSON string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_task_env
func hostTaskEnv(taskIde string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env app_list_dir
func hostListDir(path string, out unsafe.Pointer, outCap uint32) uint32

const bufSize = 256 * 1024

func callOut(fn func(unsafe.Pointer, uint32) uint32) string {
    var buf [bufSize]byte
    n := fn(unsafe.Pointer(&buf[0]), uint32(len(buf)))
    if n > uint32(len(buf)) {
        n = uint32(len(buf))
    }
    return string(buf[:n])
}

func callIn(fn func(string, unsafe.Pointer, uint32) uint32, in string) string {
    var buf [bufSize]byte
    n := fn(in, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    return string(buf[:n])
}

type taskEnv struct {
    TaskIde      string `json:"taskIde"`
    OutputDir    string `json:"outputDir"`
    OutputDirRel string `json:"outputDirRel"`
    DomainsFile  string `json:"domainsFile"`
    ToolsDir     string `json:"toolsDir"`
    Python       string `json:"python"`
}

//go:wasmexport 应用_Init
func OnInit() {
    app.Capabilities(app.HookTaskStart, app.HookTaskPacket, app.HookTaskEnd, app.CapToolsExec, app.CapFSRead)
    app.DeclareTool("nuclei", "") // tools/nuclei/ 下的可执行文件
    app.Declare()
    app.WriteResp(map[string]any{"handled": false})
}

//go:wasmexport 应用_OnTaskStart
func OnTaskStart() {
    app.SetTimeout(120000)
    var req struct {
        TaskIde string `json:"taskIde"`
    }
    _ = app.LoadRequest(&req)

    var env taskEnv
    _ = json.Unmarshal([]byte(callIn(hostTaskEnv, req.TaskIde)), &env)
    if env.OutputDir == "" {
        app.Log("任务环境不可用,跳过硬扫描")
        app.WriteResp(map[string]any{"handled": false})
        return
    }

    // 拉起 nuclei:结果写入当前任务结果目录(必须在 {output_dir} 内)
    args, _ := json.Marshal(map[string]any{
        "entry":      "nuclei.exe",
        "args":       []string{"-l", env.DomainsFile, "-o", env.OutputDir + "/nuclei.txt", "-silent"},
        "key":        "nuclei#1",
        "dir":        env.OutputDir,
        "timeoutSec": 900,
    })
    var resp struct {
        Pid   int    `json:"pid"`
        Error string `json:"error"`
    }
    _ = json.Unmarshal([]byte(callIn3(hostSpawnTool, "nuclei", "", string(args))), &resp)
    if resp.Error != "" {
        app.Logf("nuclei 启动失败:%s", resp.Error)
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    app.Logf("nuclei 已启动 pid=%d", resp.Pid)
    app.WriteResp(map[string]any{"handled": true})
}

//go:wasmexport 应用_OnTaskPacket
func OnTaskPacket() {
    app.SetTimeout(30000)
    var req struct {
        TaskIde string `json:"taskIde"`
        Flow    struct {
            Method     string `json:"method"`
            URL        string `json:"url"`
            ReqHeaders string `json:"reqHeaders"`
            ReqBody    string `json:"reqBody"`
        } `json:"flow"`
    }
    _ = app.LoadRequest(&req)
    if req.Flow.URL == "" {
        app.WriteResp(map[string]any{"handled": false})
        return
    }
    // 把数据包异步重放给 xray(此处假设 xray 监听 127.0.0.1:7777)
    replay, _ := json.Marshal(map[string]any{
        "method": req.Flow.Method, "url": req.Flow.URL,
        "headers": headersOf(req.Flow.ReqHeaders), "body": req.Flow.ReqBody,
        "proxy": "http://127.0.0.1:7777", "async": true,
    })
    _ = callIn(hostHTTPReplay, string(replay))
    app.WriteResp(map[string]any{"handled": false}) // 通知型:不拦截
}

//go:wasmexport 应用_OnTaskEnd
func OnTaskEnd() {
    app.SetTimeout(290000)
    var req struct {
        TaskIde string `json:"taskIde"`
        Phase   string `json:"phase"`
    }
    _ = app.LoadRequest(&req)
    if req.Phase != "close" {
        app.WriteResp(map[string]any{"handled": true, "done": true})
        return
    }
    _ = callIn(hostStopTool, "") // 收尾停止本插件全部工具进程
    app.WriteResp(map[string]any{"handled": true, "done": true})
}

// collectResult 读取沙箱内结果文件(解 base64 信封)
func collectResult(rel string) string {
    raw := app.ReadFile(rel)
    var env struct {
        OK   bool   `json:"ok"`
        Data string `json:"data"`
    }
    if json.Unmarshal([]byte(raw), &env) != nil || !env.OK {
        return ""
    }
    b, _ := base64.StdEncoding.DecodeString(env.Data)
    return string(b)
}

func headersOf(raw string) map[string]string {
    out := map[string]string{}
    for _, line := range splitLines(raw) {
        if i := indexByte(line, ':'); i > 0 {
            out[trim(line[:i])] = trim(line[i+1:])
        }
    }
    return out
}

func callIn3(fn func(string, string, string, unsafe.Pointer, uint32) uint32, a, b, c string) string {
    var buf [bufSize]byte
    n := fn(a, b, c, unsafe.Pointer(&buf[0]), uint32(len(buf)))
    if n > uint32(len(buf)) {
        n = uint32(len(buf))
    }
    return string(buf[:n])
}

func main() {}
说明

The example above omits small string helpers such as splitLines / trim / indexByte, as well as the details of app_free_port (allocating a port) and app_list_dir/app_tool_status (waiting for processes and enumerating results in the drain phase); the sample plugin plugin-exttools provides a complete implementation whose "per-task isolated state files + drain polling + result collection" pattern you can follow directly.


12. Pitfall Checklist

PitfallSymptomHow to avoid it
Forgetting -buildmode=c-sharedThe host cannot _initialize or cannot find the export, and the plugin has no effectThe build must be GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .
Missing func main() {}Linking fails in c-shared modeKeep an empty func main() {}
The export name lacks the 应用_ prefixCapability detection fails and the hook is never calledUse //go:wasmexport 应用_OnXxx, letter-for-letter identical to the host constant
WriteResp does not write a [RESP] lineThe host cannot parse a result and treats it as no responseUse the SDK's app.WriteResp, which emits a single [RESP] <JSON> line
Assuming handled takes effect in every hookYou return handled=true but the built-in handling still runsOnly handled=true from 应用_OnTcpDataReceived skips the built-in handling
Calling an external tool without declaring the capabilityapp_exec_tool returns "tools.exec capability not declared"In 应用_Init, call app.Capability(app.CapToolsExec) and DeclareTool, then get GUI authorization
Declare() written in package initialization/global variables[INIT] is not written to a given execution's stdout and the declaration is lostCall Declare() inside a hook function (usually at the start of 应用_Init)
Declaring only in [INIT] without exporting the hookThe log says "declared capability but did not export", and it is never calledThe declaration is only metadata; you must really //go:wasmexport it
Using an empty slice with TaskStartModify to "clear"omitempty drops the empty slice and nothing is modifiedThis model only supports replacing with non-empty values; for a clearing requirement, filter inside the plugin and return the remaining items
Re-injection forms a loopThe 6th level is discarded, leaving the logic incomplete or spinningJudge the message source and avoid ReinjectTcpData on already-processed messages; the depth limit is 5
Doing heavy synchronous work in 应用_OnTaskPacketThe dispatch loop is blocked and scanning slows downBe fast in and fast out; leave heavy work to 应用_OnTaskEnd; use async=true of app_http_replay for replays
Result files written outside {output_dir}They cannot be read during collection and the results are lostAnchor both the tool's dir and the result paths to app_task_env's outputDir; collect with outputDirRel
Using scan_* host functionsInstantiation fails (the import cannot be resolved)Application plugins use only app_*; the two host-function sets are not interchangeable
Reading files outside the sandboxIt returns "path out of bounds/absolute paths not allowed"app_read_file/app_list_dir accept only relative paths inside the plugin directory
Multiple instances of the same tool sharing the default keyA later-starting process kills an earlier onePass different keys to app_spawn_tool (nuclei#1/nuclei#2)
Running a long task without setting a timeoutThe default 30s timeout interrupts the taskCall app.SetTimeout(ms) at the start of the hook; the hard limit is 5 minutes
Confusing app_http_request with app_http_replayThe former is rejected with "loopback only"; the latter is unsuitable for local servicesUse app_http_request for locally started services; use app_http_replay to replay to external sites
Wrong case/camelCase in field namesLoadRequest succeeds but every field is emptyCompare letter by letter with the SDK's json tags (selectedVulnIds/filterIds/request/message)
重要

The host functions of an application plugin and the scan_* of a WASM POC do not overlap at all: using the wrong set results in an instantiation failure, not "a function returning an error". Before releasing, confirm that the plugin's effective path is the "plugin store/application plugin directory" rather than the "POC template list".

本文档随 SDK 源码同步更新;新增或修改公开接口后会同步到此页。