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
| Dimension | WASM POC | Application Plugin |
|---|---|---|
| Directory | scan-poc/<lang>/wasm/<VulnIde>/ | scan-poc/plugin/<uuid>/ |
| Entry point | _start (func main), processes one packet per run | Exported functions with the 应用_ prefix, invoked per hook trigger |
| Capability detection | Fixed (stdin JSON + _start) | Export-table detection: the host only calls hooks that are actually exported |
| Build mode | Ordinary wasip1 command module | Must use -buildmode=c-shared (the host needs _initialize + direct calls to exported functions) |
| Host namespace | scan_http / scan_log / scan_report / scan_call / scan_t / scan_config / scan_set_timeout | app_log / app_send_tcp* / app_call / app_spawn_tool / … |
| Where it takes effect | Only entries with PocType=wasm in the POC template list | Scan-node application plugins installed from the plugin store |
| Use cases | Vulnerability detection, single request/response analysis | Task pre-processing, traffic filtering, MITM rewriting, TCP command extension, external tool integration |
| SDK file | scan.go | app.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 wasmenvmodule).
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:
| Channel | Source | Role |
|---|---|---|
Export table (Capabilities) | Enumerate 应用_-prefixed exports after compilation | Whether 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 hook | Used 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:
| Direction | Content |
|---|---|
| Host → plugin | The request JSON is written to stdin |
| Plugin → host | stdout 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 runsmain(), and as soon asmainreturns it callsproc_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应用_xxxexport. //go:wasmexportfor 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
应用_Initat load time: during the load phase, plugins that export应用_Initare 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
| Hook | How the host uses the return value |
|---|---|
应用_OnTcpDataReceived | handled=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 |
应用_OnTcpDataSend | Only data is used: if non-empty and different → replace the content to be sent; handled has no effect |
应用_OnTaskStart | Uses task (TaskStartModify): non-nil / non-empty fields replace the corresponding task fields |
应用_OnTaskFilterVulns | Uses filterIds: merged into the set of "vulnIde to exclude" (union across plugins) |
应用_OnTaskFilterFlows | Uses filterIds: merged into the set of "packet ide to exclude" |
应用_OnMitmHttpRequest | Uses request (MitmHttpModify) merged into the request (later writes override earlier ones; empty fields do not override) |
应用_OnMitmHttpResponse | Uses response (MitmHttpModify) merged into the response |
应用_OnMitmWsMessage | Uses message.content: if non-empty, replace the message content |
应用_OnMitmSseEvent | Uses event.data: if non-empty, replace the event data |
应用_OnTaskPacket | Ignored entirely (notification-only) |
应用_OnTaskEnd | Uses done: in the drain phase, polling stops only when all plugins report done=true; in the close phase done is ignored |
应用_Init | Uses 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 function | Trigger timing | Can modify data | Does handled=true take effect |
|---|---|---|---|
应用_Init | Probed once after the plugin is loaded | Emits capability/tool declarations | Not applicable |
应用_OnTcpDataReceived | After TCP data parsing, before built-in dispatching | data can be modified | Yes: consumes this event and skips built-in handling |
应用_OnTcpDataSend | Before data is sent | data can be modified | No (only data is used) |
应用_OnTaskStart | After the task is parsed | Task filter fields can be modified | Parsed but ineffective (only task is used) |
应用_OnTaskFilterVulns | Before the task starts | Returns vulnerability identifiers to exclude | No (only filterIds is used) |
应用_OnTaskFilterFlows | Before the task starts | Returns packet identifiers to exclude | No (only filterIds is used) |
应用_OnMitmHttpRequest | MITM captures an HTTP request | method/url/headers/body can be modified | Parsed but currently ineffective (only request is used) |
应用_OnMitmHttpResponse | MITM captures an HTTP response | statusCode/headers/body can be modified | Parsed but currently ineffective (only response is used) |
应用_OnMitmWsMessage | MITM captures a WS frame | Message content can be modified | Parsed but currently ineffective (only message is used) |
应用_OnMitmSseEvent | MITM captures an SSE event | Event data can be modified | Parsed but currently ineffective (only event is used) |
应用_OnTaskPacket | Every raw packet in the scan dispatch loop | Notification-only, no return semantics | The response is ignored entirely |
应用_OnTaskEnd | Task finalization (drain polling + close cleanup) | Report done / clean up | Not 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}
| Field | Meaning | Takes effect? |
|---|---|---|
capabilities | Declared 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 |
tools | Declares 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:
应用_Inititself must also exist in the export table (//go:wasmexport 应用_Init); otherwise the load-time probe will not call it, and declarations such astools.execcan never be collected.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.- An
[INIT]declaration alone is not enough to make a hook get called: the corresponding应用_xxxmust 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.
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
uuid | string | Identifier of this connection/this node | TCP packet field | "n-0a1b" |
command1 | string | Level-1 command (routing layer) | Level-1 command field | "relayData" |
command2 | string | Level-2 command (module/action) | Level-2 command field | "TaskStart" |
command3 | string | Level-3 command (target UUID) | Level-3 command field | "gui" |
command4 | string | Level-4 command (source UUID) | Level-4 command field | "n-0a1b" |
source | string | Source: 0=GUI 1=controller 2=scan node | Source identifier field | "1" |
commandA | string | Business action name (CommandA inside Data) | Field inside the Data section | "SaveScanVuln" |
data | string | Raw 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"}
| Field | Semantics |
|---|---|
handled | true → the plugin has consumed this event and the main program skips its built-in handling |
error | Processing error message; only logged, does not affect the main flow |
data | Non-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:
- 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. - To consume the event and skip the built-in handling, return
Handled: true; to only modify the data, return the new value inDataand keepHandledasfalse. - Only
handled=truefrom this hook skips the built-in handling;handledfrom 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.
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
uuid | string | Current node UUID | "n-0a1b" |
command1~command4 | string | The four-level command of this send | "relayData" / "TaskResult" / "gui" / "n-0a1b" |
data | string | Content about to be sent | "{\"ok\":true}" |
source / commandA | string | Usually 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:
- Returning empty
dataor data identical to the original → send as is; only non-empty and different data replaces it. - Anti-reentrancy: if the plugin calls
app.SendTcpData/app.SendTcpDataJsoninside this hook it re-enters the send path, but the host skips the send hook directly, so there is no recursion. - This hook only reads
data; returninghandled=truehas 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.
| Parameter | Type | What to pass | Modifiable? | Example value |
|---|---|---|---|---|
taskIde | string | Unique task identifier | Read-only | "t-20261005-01" |
taskName | string | Task name | Read-only | "内网巡检" |
proxyMode | string | Proxy mode auto/direct/tunnel | Read-only | "auto" |
sourceUUID | string | Task source (GUI) UUID | Read-only | "g-1234" |
targetUUID | string | UUID of the task delivery target | Read-only | "n-0a1b" |
selectedVulnIds | []string | Vulnerability POC list that will actually be scanned | Modifiable | ["poc-1","poc-2"] |
selectedVulnIdsAdd | []string | Vulnerabilities added beyond the template | Modifiable | ["poc-9"] |
httpSelectedScanUrlRowIde | []string | Selected HTTP packet identifiers | Modifiable | ["flow-a"] |
wsSelectedScanUrlRowIde | []string | Selected WebSocket packet identifiers | Modifiable | ["ws-1"] |
sseSelectedScanUrlRowIde | []string | Selected SSE packet identifiers | Modifiable | ["sse-1"] |
hostsContent | string | hosts mapping content | Modifiable | "10.0.0.1 a.example" |
scanningRange | string | Restrict the scanning range | Read-only | "10.0.0.0/24" |
skipScanDomainIP | string | Domains or IPs to skip | Read-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"]}}
| Field | Semantics |
|---|---|
task | Non-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) |
handled | Parsed 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:
TaskStartModifyusesomitempty, 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.SelectedVulnIdsandSelectedVulnIdsAdd, the three*SelectedScanUrlRowIdefields, andHostsContentare nilable: onlynilmeans "do not modify".handleddoes 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.
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
taskIde | string | Task identifier | "t-20261005-01" |
vulns | []TaskVulnBrief | Summaries of all vulnerabilities selected by the current task | see below |
vulns[].vulnIde | string | Unique vulnerability identifier | "poc-1" |
vulns[].name | string | Vulnerability name | "远程命令执行" |
vulns[].level | string | Level 4/3/2/1 | "1" |
vulns[].pocType | string | yaml/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"]}
| Field | Semantics |
|---|---|
filterIds | Set of vulnIdes to exclude; results from multiple plugins are unioned |
handled | No 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:
- What you return are the identifiers to exclude, not to keep.
- Empty strings inside
filterIdsare ignored; returning nil/empty means no filtering. vulnsin 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.
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
taskIde | string | Task identifier | "t-20261005-01" |
flows | []TaskFlowBrief | Summaries of all packets selected by the current task | see below |
flows[].ide | string | Unique packet identifier (IdeTraffic) | "flow-a" |
flows[].type | string | http/websocket/sse | "http" |
flows[].method | string | Request method | "GET" |
flows[].url | string | Request URL | "/api/list" |
flows[].domain | string | Domain | "static.example.com" |
flows[].tls | string | HTTP/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:
filterIdsmust containflows[].ide(IdeTraffic), not the URL or domain.- As with vulnerability filtering, results are unioned across plugins and only
filterIdsis read. - 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.
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
taskIde | string | Task identifier | "t-20261005-01" |
method | string | Request method | "POST" |
url | string | Full URL | "https://example.com/api/login" |
proto | string | Protocol version | "HTTP/1.1" |
host | string | Host | "example.com" |
headers | map[string][]string | Request headers | {"User-Agent":["curl/8"]} |
body | string | Decompressed request body | "u=admin&p=123" |
remoteIp | string | Remote server IP | "93.184.216.34" |
tls | string | HTTP/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"]}}}
| Field | Semantics |
|---|---|
request | app.MitmHttpModify, merged into this request (later writes override earlier ones; empty fields do not override) |
request.method / request.url | Non-empty replaces the method / URL |
request.headers | When length > 0, replaces the whole request-header map |
request.body | Non-empty replaces the request body |
handled | Parsed 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:
- The returned key must be
request(nothttpRequest/modify); if the field name is wrong the response parses fine but no modification takes effect. headersis amap[string][]string, and replacement is "whole-map replacement": to keep the original headers you must copy them first and then modify.- 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.
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
taskIde | string | Task identifier | "t-20261005-01" |
url | string | Request URL | "https://example.com/api/login" |
method | string | Request method | "POST" |
statusCode | int | Response status code | 200 |
headers | map[string][]string | Response headers | {"Content-Type":["application/json"]} |
body | string | Decompressed response body | "{\"ok\":true}" |
remoteIp | string | Remote server IP | "93.184.216.34" |
tls | string | HTTP/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}"}}
| Field | Semantics |
|---|---|
response | app.MitmHttpModify, merged into the response |
response.statusCode | When > 0, replaces the status code (used by responses only) |
response.headers | When length > 0, replaces the whole response-header map |
response.body | Non-empty replaces the response body |
handled | Parsed 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:
statusCodeof 0 (or not returned) means no modification; only positive values override.- The returned key is
response;method/urlinsideMitmHttpModifyare meaningless on the response side (the response side does not use them). omitempty: an emptybodyor emptyheaderswill 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.
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
taskIde | string | Task identifier | "t-20261005-01" |
url | string | Connection URL | "wss://example.com/ws" |
domain | string | Domain | "example.com" |
fromClient | bool | true=client→server, false=server→client | true |
statusType | int | 1=send 2=receive | 1 |
content | string | Message 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\"}"}}
| Field | Semantics |
|---|---|
message.content | Non-empty replaces the message content |
handled | Parsed 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:
- The returned key is
message; an emptymessage.contentdoes not override. fromClientandstatusTypecarry overlapping semantics but both come from the host; usingfromClientto judge direction is more intuitive.contentmay 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.
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
taskIde | string | Task identifier | "t-20261005-01" |
url | string | Connection URL | "https://example.com/sse" |
event | string | The event: field (default message) | "message" |
id | string | The id: field | "42" |
data | string | The data: field | "{\"price\":10}" |
retry | int | The 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}"}}
| Field | Semantics |
|---|---|
event.data | Non-empty replaces the event data |
handled | Parsed 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:
- The returned key is
event; an emptydatadoes not override. id,retryandeventare read-only;MitmSseModifycan only changedata.- 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.
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
taskIde | string | Task identifier | "t-20261005-01" |
flow.method | string | Request method | "GET" |
flow.url | string | Request URL | "/api/user" |
flow.domain | string | Domain | "example.com" |
flow.reqHeaders | string | Raw request-header text | "Host: example.com\nUser-Agent: curl/8" |
flow.reqBody | string | Request 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:
- Must be fast in and fast out: this path runs for every packet, and blocking synchronously will stall the dispatch loop; use
async=trueofapp_http_replayfor replays. - The return value is ignored entirely; do not rely on it to influence the flow.
- 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
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
taskIde | string | Task identifier | "t-20261005-01" |
cancelled | bool | true=the user stopped/cancelled (the finalization window should be shortened) | false |
phase | string | drain=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}
| Field | Semantics |
|---|---|
done | In 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 |
handled | Does 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:
drainworks by returningdone=falseto request the next round: do not block withsleepwaiting for processes here; return quickly and let the host call again 2s later.- Within a single round you can enlarge the execution window with
app.SetTimeout(ms), up to the hard limit of 5 minutes;closeis called only once, so make sure to kill processes there. - Plugins that do not implement this hook do not participate in finalization; when
cancelled=truethe 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:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
msg | string | Log text | Your 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:
- The SDK already wraps it as
app.Log/app.Logf; prefer those. - Write each log line only once and avoid high-frequency flooding (especially inside
OnTaskPacket). - 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:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
cmd1 | string | Level-1 command | Your code | "relayData" |
cmd2 | string | Level-2 command | Your code | "Heartbeat" |
cmd3 | string | Target (gui/scan/all/node UUID) | Your code | "gui" |
cmd4 | string | Reply UUID (empty fills in this node automatically) | Your code | "" |
data | string | Raw data section | Your 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:
- When
cmd3is empty the message goes only to the controller; writeallto send to every node. - It transmits a raw string — for business data, prefer
app_send_tcp_jsonso the protocol wrapping is done for you. - 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:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
cmd1~cmd4 | string | Four-level command/target/reply | Your code | "scan" / "SaveScanVuln" / "" / "" |
commandA | string | Business action name | Your code | "SaveScanVuln" |
data | string | JSON 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:
- Do not
json.Marshalinto[]byteyourself and pass that: a[]byteis encoded as a base64 string and the host never receives an object. Pass a struct orany. - If you only declare the low-level function, the
dataparameter must be JSON text; the hostjson.Unmarshals it intoanyand re-wraps it. commandAandcmd2usually 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:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
tcpDataJSON | string | JSON for one TCP message (fields below) | Your code | see 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:
- 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.
- During re-injection
应用_OnTcpDataReceivedis not called again (messages from the re-injection source are skipped outright), avoiding self-triggered deadlock. Datais a[]byte: when passing text, use base64; otherwise the host'sjson.Unmarshalmay 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:
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
funcID | uint32 | Built-in function ID, see the table below | 10 |
args | string | JSON array of arguments | "[\"hello\"]" |
out | unsafe.Pointer | Output buffer | unsafe.Pointer(&buf[0]) |
outCap | uint32 | Buffer capacity | uint32(len(buf)) |
Built-in function IDs (those wrapped by the SDK):
| funcID | SDK wrapper | Purpose |
|---|---|---|
| 10 | app.Base64Encode | Base64 encoding |
| 11 | app.Base64Decode | Base64 decoding |
| 20 | app.MD5 | MD5 (lowercase hex) |
| 21 | app.SHA1 | SHA1 hash |
| 23 | app.SHA256 | SHA256 hash |
| 50 | app.JSONGet | Read 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:
- The general-purpose SDK entry point is
app.CallBuiltin(funcID, args...) (string, bool); returningfalsemeans the host produced no result. - On error the returned text is JSON
{"error":...}rather than an empty string, so check for that. argsmust 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:
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
key | string | Language-pack key (without the language and UUID prefix) | "VulnName" |
out / outCap | unsafe.Pointer / uint32 | Output 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:
- The language-pack files are
language-<lang>.jsonin the plugin directory (one each for-cn/-en), read by the host when the plugin is loaded. - The key space includes the plugin UUID, so in the source file you only write
VulnName; the host automatically prefixes<lang>.<uuid>.. - 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:
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
key | string | Configuration key | "Enabled" |
out / outCap | unsafe.Pointer / uint32 | Output 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:
plugin.config.jsonis a flat Key/Value map (map[string]string); complex configuration is often serialized into one key (e.g.ConfigJson).- Configuration is saved in the GUI plugin page → controller → delivered to the node, and takes effect after a restart/reload.
- 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:
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
out / outCap | unsafe.Pointer / uint32 | Output 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"}
| Field | Meaning |
|---|---|
uuid | Node UUID |
nodeName | Node name |
language | Current language |
appId | Application ID |
pluginId | Current 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:
- The host returns 5 fields (one more than the SDK's
app.NodeInfo:pluginId); using the SDK'sapp.GetNodeInfo()losespluginId. - Status reports must carry
pluginIdanduuid; 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:
- 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.
- Each process instance (
Count>1) should allocate its own port. - 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:
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
ms | uint32 | Timeout in milliseconds; <=0 is ignored | 120000 |
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:
- Before every hook execution the host resets the timeout to the default 30s, so each hook must call this on its own.
- What you set is the timeout of "this one hook execution", not a global plugin property.
- 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:
| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
tool | string | Tool name (tools/<name>/) | Your code | "nuclei" |
runtime | string | python/java/"" | Your code | "" |
argsJSON | string | JSON array of arguments | Your code | "[\"-silent\"]" |
out / outCap | unsafe.Pointer / uint32 | Output 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:
- Without the
tools.execdeclaration it returns{"error":"插件未声明 tools.exec 能力,禁止调用外部工具"}; without authorization it returns "未获用户授权". - The tool must be under
tools/<name>/; invoking terminals is forbidden (cmd/powershell/sh/bashand other blacklisted names), and no shell is used. - 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:
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
path | string | Path relative to the plugin directory | "data/toollogs/sqlmapapi.log" |
out / outCap | unsafe.Pointer / uint32 | Output 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:
datais base64; you must decode it to get the file content.- The per-file limit is 4MB, and anything beyond is truncated; only relative paths are accepted — absolute paths,
..and symbolic links are rejected. - 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:
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
path | string | Path relative to the plugin directory | "data/state-t1.json" |
data | unsafe.Pointer | Pointer to the bytes to write | unsafe.Pointer(&b[0]) |
dataLen | uint32 | Byte count | uint32(len(b)) |
out / outCap | unsafe.Pointer / uint32 | Output 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:
- The host has no "delete file" interface; to clear state, write
{}and treat it as empty on the reading side (the sample plugin'sclearStatedoes exactly this). - Parent directories are created automatically; symbolic links and out-of-bounds paths are rejected.
- 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.
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
tool | string | Tool name | "xray" |
runtime | string | python/java/"" | "" |
entry | string | Entry file (relative to tools/<tool>/; empty=default lookup); cwd switches to the entry's directory | "sqlmapapi.py" |
args | []string | Command-line argument array | ["--listen","127.0.0.1:1800"] |
key | string | Process instance key; multiple instances of the same tool must use different keys | "xray#1-1" |
dir | string | Working directory (absolute path, must be inside the plugin sandbox) | "<output_dir>" |
env | map[string]string | Additional environment variables | {"X":"1"} |
timeoutSec | int | Timeout in seconds; the process is killed on timeout; <=0 means no limit | 900 |
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:
- 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). entrymust still resolve insidetools/<tool>/(guarding against..);dirmust be inside the plugin sandbox, otherwise it is ignored.- The log file path is returned in
log; read it withapp_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:
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
tool | string | Empty=all; otherwise an exact key or a tool#/tool/ prefix | "xray" |
out / outCap | unsafe.Pointer / uint32 | Output 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:
- Passing a tool name stops all of its
#ninstances; passing an exact key stops only that single instance; an empty string stops all processes of this plugin. - It can only stop processes started by this plugin; it cannot touch other plugins or system processes.
- 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:
- This is the host-side "source of truth" and is more reliable than the plugin keeping its own bookkeeping.
running=falsemeans the process has exited (the host has finished collecting it).startedAtis 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.
| Field | Type | What to pass | Example value |
|---|---|---|---|
method | string | Method; empty=GET | "POST" |
url | string | Loopback URL | "http://127.0.0.1:8775/task/new" |
headers | map[string]string | Request headers | {"Content-Type":"application/json"} |
body | string | Request body | "{}" |
timeoutMs | int | Timeout in milliseconds; default 30s, max 10 minutes | 1500 |
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:
- Only
127.0.0.1/localhost/::1are allowed; every other address is rejected with "仅允许本机回环地址". - Request/response bodies are capped at 8MB; the default timeout is 30s (max 10 minutes).
- To hit external sites use
app_http_replayinstead.
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.
| Field | Type | What to pass | Example value |
|---|---|---|---|
method | string | Method; empty=GET | "GET" |
url | string | Target URL (must be http/https) | "https://example.com/a" |
headers | map[string]string | Restored request headers (Host/Content-Length/Connection are handled by the library) | {"User-Agent":"curl/8"} |
body | string | Request body, max 4MB | "" |
proxy | string | Replay proxy; empty=direct | "http://127.0.0.1:7777" |
timeoutMs | int | Default 30s, max 30s | 30000 |
async | bool | true=send in the background and return immediately | true |
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:
应用_OnTaskPacketmust useasync=true: synchronous waiting would stall the scan dispatch loop.- A URL without
http(s)://reports an error; a request body over 4MB is rejected; the timeout ceiling is 30s. - 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:
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
taskIde | string | Task identifier | "t-20261005-01" |
out / outCap | unsafe.Pointer / uint32 | Output 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"}
| Field | Meaning |
|---|---|
taskIde / taskName | Task identifier/name |
nodeUuid / nodeName | Node identifier/name |
domain / targetUrl | Primary domain / first URL (with scheme) |
domainsFile / urlsFile | Absolute paths of the deduplicated domain/URL list files (one per line, for tool -l/-iL) |
outputDir / outputDirRel | Absolute path / plugin-relative path of this task's result directory (the latter for app_read_file) |
pluginDir / toolsDir | Absolute paths of the plugin root directory / tools directory |
python / java | Executable paths (the external-tool environment takes precedence, falling back to a bare name from PATH) |
secTestProxy | Address 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:
- 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. outputDir/domainsFileare absolute paths (for tool arguments), whileoutputDirRelis a relative path (forapp_read_file/app_list_dir).- 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:
| Parameter | Type | What to pass | Example value |
|---|---|---|---|
path | string | Directory path relative to the plugin directory | "data/ext-results/t-1" |
out / outCap | unsafe.Pointer / uint32 | Output 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:
- It lists direct children only (non-recursive);
modTimeis in unix seconds. - Capability name
fs.read; paths are constrained by the directory sandbox. - When collecting results,
size==0is 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)
filterIdsmust containflows[].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
CapFSReadlets 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.
| Field | json | Type | Meaning |
|---|---|---|---|
Name | name | string | Tool name (the tools/<name>/ directory) |
Runtime | runtime | string | python / java / "" |
app.DeclareTool("sqlmap", "python")
_ = app.ToolDecl{Name: "sqlmap", Runtime: "python"}
Runtimedetermines which runtime environment directory the host prepends to PATH.
TcpDataRequest
Purpose: the request body of the TCP receive/send hooks.
| Field | json | Type | Meaning |
|---|---|---|---|
UUID | uuid | string | Identifier of this connection/this node |
Command1 | command1 | string | Level-1 command |
Command2 | command2 | string | Level-2 command |
Command3 | command3 | string | Level-3 command (target UUID) |
Command4 | command4 | string | Level-4 command (source UUID) |
Source | source | string | 0=GUI 1=controller 2=scan node |
CommandA | commandA | string | Business action name |
Data | data | string | Raw Data section |
var req app.TcpDataRequest
_ = app.LoadRequest(&req)
app.Log(req.Command2 + ":" + req.Data)
In the send hook,
source/commandAare usually empty.
TcpDataResponse
Purpose: the response body of the TCP receive/send hooks.
| Field | json | Type | Meaning |
|---|---|---|---|
Handled | handled | bool | true=consumed (only effective in the receive hook) |
Error | error | string | Error message (logged only) |
Data | data | string | Non-empty and different → replace/re-parse |
app.WriteResp(app.TcpDataResponse{Data: req.Data + "_x"})
Empty
Datameans no modification.
TaskStartRequest
Purpose: the request body of 应用_OnTaskStart (containing only filter/routing-related fields).
| Field | json | Type | Meaning |
|---|---|---|---|
TaskIde | taskIde | string | Task identifier |
TaskName | taskName | string | Task name |
ProxyMode | proxyMode | string | auto/direct/tunnel |
SourceUUID | sourceUUID | string | Source (GUI) UUID |
TargetUUID | targetUUID | string | Delivery target UUID |
SelectedVulnIds | selectedVulnIds | []string | Vulnerabilities to scan |
SelectedVulnIdsAdd | selectedVulnIdsAdd | []string | Additionally added vulnerabilities |
HttpSelectedScanUrlRowIde | httpSelectedScanUrlRowIde | []string | Selected HTTP packets |
WsSelectedScanUrlRowIde | wsSelectedScanUrlRowIde | []string | Selected WS packets |
SSESelectedScanUrlRowIde | sseSelectedScanUrlRowIde | []string | Selected SSE packets |
HostsContent | hostsContent | string | hosts content |
ScanningRange | scanningRange | string | Restrict the scanning range |
SkipScanDomainIP | skipScanDomainIP | string | Domains/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).
| Field | json | Type | Meaning |
|---|---|---|---|
SelectedVulnIds | selectedVulnIds,omitempty | []string | Replace the vulnerability list |
SelectedVulnIdsAdd | selectedVulnIdsAdd,omitempty | []string | Replace the additional vulnerabilities |
HttpSelectedScanUrlRowIde | httpSelectedScanUrlRowIde,omitempty | []string | Replace the HTTP selection |
WsSelectedScanUrlRowIde | wsSelectedScanUrlRowIde,omitempty | []string | Replace the WS selection |
SSESelectedScanUrlRowIde | sseSelectedScanUrlRowIde,omitempty | []string | Replace the SSE selection |
HostsContent | hostsContent,omitempty | string | Replace hosts |
app.WriteResp(app.TaskStartResponse{
Handled: true,
Task: app.TaskStartModify{SelectedVulnIds: keep},
})
With
omitemptyan empty slice is dropped, so an empty slice cannot be used to "clear" a field.
TaskStartResponse
Purpose: the response body of 应用_OnTaskStart.
| Field | json | Type | Meaning |
|---|---|---|---|
Handled | handled | bool | Parsed but does not affect the flow at present |
Error | error | string | Error message |
Task | task | TaskStartModify | The 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.
| Field | json | Type | Meaning |
|---|---|---|---|
VulnIde | vulnIde | string | Unique vulnerability identifier |
Name | name | string | Vulnerability name |
Level | level | string | Level 4/3/2/1 |
PocType | pocType | string | yaml/go/wasm |
for _, v := range req.Vulns { app.Logf("%s %s", v.VulnIde, v.Name) }
VulnIdeis exactly the value to put intofilterIds.
TaskFlowBrief
Purpose: the packet summary used by the packet filter hook.
| Field | json | Type | Meaning |
|---|---|---|---|
Ide | ide | string | Packet identifier (IdeTraffic) |
Type | type | string | http/websocket/sse |
Method | method | string | Request method |
URL | url | string | Request URL |
Domain | domain | string | Domain |
TLS | tls | string | HTTP/HTTPS/WSS |
for _, f := range req.Flows { app.Logf("%s %s", f.Ide, f.Domain) }
When excluding, put
f.IdeintofilterIds.
TaskFilterResponse
Purpose: the unified response for filter hooks.
| Field | json | Type | Meaning |
|---|---|---|---|
Handled | handled | bool | No effect |
Error | error | string | Error message |
FilterIds | filterIds | []string | Set 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.
| Field | json | Type | Meaning |
|---|---|---|---|
TaskIde | taskIde | string | Task identifier |
Method | method | string | Request method |
URL | url | string | Full URL |
Proto | proto | string | Protocol version |
Host | host | string | Host |
Headers | headers | map[string][]string | Request headers |
Body | body | string | Decompressed request body |
RemoteIP | remoteIp | string | Remote server IP |
TLS | tls | string | HTTP/HTTPS |
var req app.MitmHttpRequest
_ = app.LoadRequest(&req)
app.Logf("%s %s", req.Method, req.URL)
Headersis amap[string][]string.
MitmHttpModify
Purpose: the modifiable fields of an HTTP request/response (omitempty).
| Field | json | Type | Meaning |
|---|---|---|---|
Method | method,omitempty | string | Replace the method |
URL | url,omitempty | string | Replace the URL |
Headers | headers,omitempty | map[string][]string | Replace the whole header map |
Body | body,omitempty | string | Replace the body |
StatusCode | statusCode,omitempty | int | Used 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.
| Field | json | Type | Meaning |
|---|---|---|---|
TaskIde | taskIde | string | Task identifier |
URL | url | string | Request URL |
Method | method | string | Request method |
StatusCode | statusCode | int | Response status code |
Headers | headers | map[string][]string | Response headers |
Body | body | string | Decompressed response body |
RemoteIP | remoteIp | string | Remote server IP |
TLS | tls | string | HTTP/HTTPS |
var resp app.MitmHttpResponse
_ = app.LoadRequest(&resp)
app.Logf("status=%d", resp.StatusCode)
Compared with the request snapshot: no
Proto/Host, plusStatusCode.
MitmWsMessage
Purpose: an MITM WebSocket message frame.
| Field | json | Type | Meaning |
|---|---|---|---|
TaskIde | taskIde | string | Task identifier |
URL | url | string | Connection URL |
Domain | domain | string | Domain |
FromClient | fromClient | bool | true=client→server |
StatusType | statusType | int | 1=send 2=receive |
Content | content | string | Message content |
var msg app.MitmWsMessage
_ = app.LoadRequest(&msg)
app.Logf("fromClient=%v content=%s", msg.FromClient, msg.Content)
Prefer
FromClientto judge direction.
MitmWsModify
Purpose: the modifiable fields of a WebSocket message.
| Field | json | Type | Meaning |
|---|---|---|---|
Content | content,omitempty | string | Replace the message content |
_ = app.MitmWsModify{Content: "pong"}
An empty
contentdoes not override.
MitmSseEvent
Purpose: an MITM SSE event.
| Field | json | Type | Meaning |
|---|---|---|---|
TaskIde | taskIde | string | Task identifier |
URL | url | string | Connection URL |
Event | event | string | The event: field (default message) |
ID | id | string | The id: field |
Data | data | string | The data: field |
Retry | retry | int | The retry: field (milliseconds) |
var evt app.MitmSseEvent
_ = app.LoadRequest(&evt)
app.Logf("event=%s id=%s", evt.Event, evt.ID)
IDmaps to the jsonid.
MitmSseModify
Purpose: the modifiable fields of an SSE event.
| Field | json | Type | Meaning |
|---|---|---|---|
Data | data,omitempty | string | Replace the event data |
_ = app.MitmSseModify{Data: "new-data"}
An empty
datadoes not override.
NodeInfo
Purpose: information about the current scan node (returned by GetNodeInfo).
| Field | json | Type | Meaning |
|---|---|---|---|
UUID | uuid | string | Node UUID |
NodeName | nodeName | string | Node name |
Language | language | string | Current language |
AppID | appId | string | Application 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'sNodeInfodoes 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
Capabilityone 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
visjson.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=truefrom 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
returnon 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
WriteRespand 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)
datais 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")
cmd3is the target; whencmd4is 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
pluginIdthe host returns; when you need it, declare your own struct and readapp_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
- In
应用_Initthe plugin declares tools withDeclareTool/DeclareTools(or by directly building thetoolsarray of[INIT]); tools must live undertools/<name>/; - After the plugin is loaded, the host computes the SHA-256 of the tool executable and, together with the machine fingerprint, sends a
ToolAuthRequestpopup to the GUI; - After the user confirms in the GUI, a
ToolAuthConfirmcomes back and the host recordsallowed/deniedperpluginUUID/tool(persisted on the node side, with decisions kept per GUI user); - Before any later call to
app_exec_tool/app_spawn_tool, the host checks: istools.execdeclared + is the authorization state "allowed".
Authorization state machine:
| State | Trigger condition |
|---|---|
unauthorized | Default; or the tool hash changed; or a different machine (fingerprint changed) |
allowed | The user allowed it; on the same machine + same hash, the next start trusts the existing grant and skips the popup |
denied | The 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
| Dimension | app_exec_tool (synchronous, one-shot) | app_spawn_tool (background, resident) |
|---|---|---|
| Waits for exit? | Yes | No (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 for | Command-line tools that exit when done (a one-shot nuclei scan) | Resident service-type tools (sqlmapapi, an xray listener) |
| Process management | None (ends when execution ends) | app_stop_tool / app_tool_status / auto-kill on timeout |
| Multiple instances | Not applicable | Coexist 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_toolgoes 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
| Placeholder | Meaning |
|---|---|
{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.Reland 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:
| Mechanism | Description |
|---|---|
| Default timeout | A single hook execution defaults to 30s; the plugin can reset it with app_set_timeout(ms) (SetTimeout) |
| Hard limit | app_set_timeout has a 5-minute hard limit; values beyond it are capped |
| Serial execution | Only one execution per plugin at a time; a hook triggered again while one is running is skipped outright (anti-reentrancy/deadlock) |
| Watchdog | An independent timer per call; on timeout it returns an error immediately without blocking the scan process |
| Poisoned rebuild | After a timeout the runtime is marked poisoned and rebuilt automatically on the next call (a stuck plugin does not affect subsequent ones) |
| Crash recover | recover() inside the execution goroutine turns a plugin panic into an error, so it cannot bring down the host |
| Compile protection | Compilation also runs under a context with a timeout, so a malformed/malicious wasm cannot hang the scan process |
| Anti-reentrancy | The send hook triggered by app_send_tcp and the re-injection of app_tcp_received_data (depth 5) both have loop protection |
| Plugin cap | At most 50 plugins are loaded; anything beyond is skipped |
| Process cleanup | On 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:
| Source | How it is produced | Host destination |
|---|---|---|
app.Log / app.Logf | Calls the app_log host function | Collected into this execution's log buffer, finally written into the scan node log as 插件[<uuid>] 日志: ... |
[LOG] lines or other stdout | You write os.Stdout.WriteString("[LOG] xxx\n") directly | The 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
- Create the project: make a new directory, write
module my-plugin+go 1.22ingo.mod, copy the SDK package'sapp.gointo theapp/subdirectory, and addreplace app => ./app. - Write the minimal plugin (code below) and save it as
main.go. - Build:
GOWORK=off GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm .. - Place the files: put
scan.wasmatscan-poc/plugin/plugin-demo-9999/build/scan.wasm(on a development machine you can place it manually; no store delivery needed). - Trigger once: restart the scan node (or have the node reload plugins), then trigger a task / have the node receive one TCP message.
- 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
| Symptom | Cause | Fix |
|---|---|---|
| The plugin does nothing at all; the log shows no compile/execute at all | Forgot -buildmode=c-shared (a command module runs _start to completion and exits) | Add -buildmode=c-shared to the build |
| Link failure / compile error | Missing func main() {} | Keep an empty func main() {} |
| The plugin loads but no hook is ever called | The exported function name lacks the 应用_ prefix | Use //go:wasmexport 应用_OnXxx, letter-for-letter identical to the host constant |
| The capability is declared but the host does not call it | Only declared in [INIT], without exporting the corresponding 应用_xxx function | Export-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 variables | Export 应用_Init and call Declare() inside the hook function |
app_exec_tool returns "tools.exec capability not declared" | tools.exec was not declared | app.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 changed | Confirm in the GUI plugin page; a different machine or changed tool file requires re-authorization |
| The host cannot parse any result; it seems unresponsive | WriteResp 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 runs | Wrong hook: only 应用_OnTcpDataReceived's handled takes effect | Follow the "does handled take effect" table in chapter 3 |
| A field was changed but has no effect | Wrong 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 discarded | The re-injection depth exceeded 5 levels | Judge 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/::1 | For 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 used | Use 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 timeout | Call 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 timeout | The runtime was marked poisoned and is being rebuilt | Normal: 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 stalls | Heavy synchronous work inside 应用_OnTaskPacket | Be 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 object | You json.Marshaled the struct into a []byte before passing it to SendTcpDataJson | Pass the struct/any directly and let the SDK marshal it |
| Result files cannot be collected | The 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
| Pitfall | Symptom | How to avoid it |
|---|---|---|
Forgetting -buildmode=c-shared | The host cannot _initialize or cannot find the export, and the plugin has no effect | The build must be GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o scan.wasm . |
Missing func main() {} | Linking fails in c-shared mode | Keep an empty func main() {} |
The export name lacks the 应用_ prefix | Capability detection fails and the hook is never called | Use //go:wasmexport 应用_OnXxx, letter-for-letter identical to the host constant |
WriteResp does not write a [RESP] line | The host cannot parse a result and treats it as no response | Use the SDK's app.WriteResp, which emits a single [RESP] <JSON> line |
Assuming handled takes effect in every hook | You return handled=true but the built-in handling still runs | Only handled=true from 应用_OnTcpDataReceived skips the built-in handling |
| Calling an external tool without declaring the capability | app_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 lost | Call Declare() inside a hook function (usually at the start of 应用_Init) |
Declaring only in [INIT] without exporting the hook | The log says "declared capability but did not export", and it is never called | The 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 modified | This 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 loop | The 6th level is discarded, leaving the logic incomplete or spinning | Judge the message source and avoid ReinjectTcpData on already-processed messages; the depth limit is 5 |
Doing heavy synchronous work in 应用_OnTaskPacket | The dispatch loop is blocked and scanning slows down | Be 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 lost | Anchor both the tool's dir and the result paths to app_task_env's outputDir; collect with outputDirRel |
Using scan_* host functions | Instantiation fails (the import cannot be resolved) | Application plugins use only app_*; the two host-function sets are not interchangeable |
| Reading files outside the sandbox | It 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 key | A later-starting process kills an earlier one | Pass different keys to app_spawn_tool (nuclei#1/nuclei#2) |
| Running a long task without setting a timeout | The default 30s timeout interrupts the task | Call app.SetTimeout(ms) at the start of the hook; the hard limit is 5 minutes |
Confusing app_http_request with app_http_replay | The former is rejected with "loopback only"; the latter is unsuitable for local services | Use app_http_request for locally started services; use app_http_replay to replay to external sites |
| Wrong case/camelCase in field names | LoadRequest succeeds but every field is empty | Compare 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".