Scan Node WASM POC Template Development

Write vulnerability detection templates in WASM: scan_* host functions, VulnFinding reporting, meta.yaml and signing.

This document is written for third-party developers: with nothing more than the official distribution package plus this document, you can write a WASM vulnerability POC template from scratch that the scan node can load and execute.

This version splits every public function/type into its own section, explaining item by item "what it does / signature / where each argument comes from / what it returns / how to use the return value / code snippet / what goes wrong if you misuse it". All parameter names, field names and types are taken verbatim from the scan.go shipped in the official SDK package, so you can copy them with confidence.

1. What It Is

A WASM POC template is a class of vulnerability template in the "POC template list" whose PocType = wasm. It compiles detection logic into a WASI module (output.wasm), which the scan node loads and runs as host. Its runtime model is passive scanning:

扫描任务启动(扫描节点)
  ├─ 先把任务里的流量包去重(按包 Hash)
  └─ 加载 / 编译一个插件(先校验签名,只加载 verified 的模板)
        ──▶ 依次对该插件扫全部去重流量包(每包一次执行)
        ──▶ 卸载该插件 ──▶ 加载下一个插件
  • One packet = one WASM execution: the host serializes the packet and writes it to the WASM stdin, where the plugin retrieves it through scan.LoadFlow(); SCAN_* environment variables are set at the same time as a fallback.
  • One execution corresponds to one Flow structure. Inside func main() (the WASI export _start) the plugin performs detection, sends requests with scan.HTTP, reports findings with scan.Report, and writes logs with scan.Log.
  • Each plugin is loaded/compiled only once and is unloaded only after all packets have been processed this way — so thousands of 2~3MB plugins never reside in memory at the same time.
重要

A WASM POC is a passive scanner: it does not decide which targets to scan; instead the scan node feeds it deduplicated packets one by one. Your plugin's job is "given one packet, decide whether extra requests are needed, how to judge, and how to report a hit".

How it differs from the neighboring forms (one-liner version):

FormCarrierEntry pointQuestion it answers
YAML templateDeclarative YAMLEngine parsingDetection expressible with rules
Go hot-reload POCPure Go source (interpreted in-process)@meta + func RunYou need a Turing-complete script but do not want a WASM build chain
WASM POC template (this document)output.wasm_start / func mainDetection that needs cross-language support, binary distribution and strong signature control
Application pluginscan.wasm (c-shared)Exported functions prefixed with 应用_Resident hooks into tasks/packets/MITM

2. Applicability Boundaries (Important): scan_* and app_* Are Not Interchangeable

The scan node injects two completely disjoint sets of host functions for the two kinds of WASM module. The scan_* host functions are injected only on the "POC template execution" call path.

DimensionWASM POC template (scan_*)Application plugin (app_*)
Host function namespacescan_http / scan_log / scan_report / scan_call / scan_t / scan_config / scan_set_timeoutapp_log / app_send_tcp / app_send_tcp_json / app_tcp_received_data / app_call / app_t / app_config / app_node_info / app_set_timeout
Entry point_start (func main), processes one packet per callExported functions prefixed with 应用_, invoked by hook
Build modeOrdinary wasip1 command moduleMust use -buildmode=c-shared
When it takes effectOnly entries with PocType=wasm in the POC template listScan node application plugins installed from the plugin store
Directorypoc/<lang>/wasm/<VulnIde>/poc/plugin/<uuid>/
SDK filescan.goapp.go
Use caseVulnerability detectionTask preprocessing / packet processing / TCP command extension

Both SDK files in the table (scan.go / app.go) are included in the official SDK package you download; just take the one for your target language.

How to decide: if your artifact is scanned as a vulnerability entry in the POC template list → use scan.*; if it runs as a resident plugin in the plugin store / application plugin directory → use app.*.

注意

Writing scan.HTTP(...) into an application plugin, or app.SpawnTool(...) into a WASM POC, will cause instantiation failure because the host does not export the corresponding function (instantiate module: ... unknown import). This is not an error that appears only at runtime — the plugin simply cannot start at all. Likewise, controller plugins use ctl_* and AiAgent external tools use stdin/stdout JSON — the four SDKs are mutually incompatible.

Choose one of three: how to pick YAML / Go hot-reload / WASM

Your situationRecommendation
Expressible with a request + match rules + regex / timing / callbackYAML template (first choice: no SDK, no build)
Complex algorithms, loops, custom parsing, and your team writes GoGo hot-reload POC (import "scan", interpreted in-process)
Cross-language implementation, binary distribution, strong signature control, size/performance sensitiveWASM POC (this document)

3. Where the Entry Point Is: From the POC Template List to _start Being Called

This chapter answers the question third-party developers ask most: "Who actually calls the func main() I wrote? What happens in between? Why does it run fine locally but do nothing after I upload it?" The real flow is described step by step below.

3.1 The Complete Flow (Step by Step)

  1. Select the WASM type in the GUI: open "Add Vulnerability / POC Template List", create or edit a vulnerability, and set the template type to WASM. This step determines that the vulnerability will eventually be written as PocType: "wasm" and will follow the execution path described in this document (rather than YAML or Go hot-reload).
  2. Source code goes to the build service for compilation: submit your source (source/, multiple files including the SDK file) together with metadata to the TestSecVulnService build service (the GUI upload/build entry). The build service picks the toolchain according to meta.yaml.SourceLanguage (go / c / cpp / rust / tinygo) and produces a WASI command module output.wasm. Key requirement: the artifact must export _start (Go's package main provides it automatically; do not use -buildmode=c-shared).
  3. Ed25519 signature sig.json: after a successful build, the server signs the output.wasm bytes with Ed25519 using the private key uploaded by the controller and writes sig.json (userSignature / userPubkey), or the POC marketplace public key produces serverSignature / serverPubkey. In the end signStatus becomes verified or official. Artifacts without a valid signature will not be loaded later (see Chapter 2 and Chapter 11).
  4. Controller loads it: the controller syncs the POC from the POC marketplace/upload directory into its own poc/<lang>/wasm/<VulnIde>/ directory (<lang> is the language directory, e.g. cn; <VulnIde> is the unique vulnerability identifier) and parses meta.yaml to create a vulnerability entry with PocType="wasm".
  5. Task is dispatched to the scan node: when a scan task starts, the controller sends the matched vulnerability list (including the WASM entries and their template directories) to the scan node together with the task.
  6. The node loads only verified plugins: after receiving the template directory, the scan node first reads meta.yaml + output.wasm + sig.json and re-verifies the signature; only plugins whose signStatus is verified proceed to execution — all others are skipped after a log entry is recorded (they are not even compiled).
  7. Batch streaming execution: for each plugin that passes verification, the node loads it once and compiles it once, then runs the plugin against all deduplicated packets in sequence, unloads it, and moves on to the next plugin. Only one plugin's compiled artifact resides in memory at any moment.
  8. What a single execution does (one packet per execution):
    1. Serialize the packet to JSON and write it to a temporary file as the WASM stdin (the Flow fields are also set as SCAN_* environment variables as a fallback);
    2. Create a brand-new module instance (resetting wasm global state) and complete initialization;
    3. Take the exported function _start — this is your func main();
    4. Call _start(); your code starts running: scan.LoadFlow() reads stdin → scan_* host functions perform detection → scan.Report / scan.Log send results back;
    5. After _start returns, the host collects the findings, logs and request counters of this execution, then parses stdout [LOG] / [FIND] lines as a fallback, and finally destroys the module instance.
  9. Results are sent back: findings from this execution are reported to the controller (which applies the 404-baseline false-positive gate and deduplication), logs go to the task log, and request counts are counted toward progress.
重要

func main() is the entry point. The host will not call any other function of yours, and you do not need to read anything other than stdin — the packet JSON is already in stdin and scan.LoadFlow() has read it and populated scan.Flow for you. Inside main you just read values from scan.Flow.* and call scan.* to send requests / report findings.

说明

Every execution is a brand-new instance. The host creates a new module instance for each packet, so wasm global variables are reset. Therefore: the Cookie session within one plugin instance spans its entire batch (it comes from the Cookie session the host maintains for the plugin instance, not from wasm global state); but package-level variables you define in wasm are not preserved when the next packet executes.

4. Directory Structure and meta.yaml / sig.json

poc/<lang>/wasm/<VulnIde>/
├── source/              # 编译前源码(多文件,含 SDK 文件)
├── output.wasm          # 编译产物(WASI 模块,必须导出 _start)
├── meta.yaml            # 漏洞元数据(多语言)
├── sig.json             # 签名信息
├── plugin.config.json   # 可选:插件配置(Key/Value),供 ConfigGet 读取
└── build_history.json   # 构建/签名历史(最多 100 条,工具生成)

What each file is for:

File / directoryPurposeRequired
source/Human-readable source (Go/C/C++/Rust/TinyGo all acceptable), distributed with the package for auditingRecommended
output.wasmThe file that is actually loaded and executed; _start is the entry pointRequired
meta.yamlVulnerability metadata; after loading it becomes a vulnerability entry with PocType="wasm"Required
sig.jsonSignature and verification status; without a valid signature it is not executedRequired
plugin.config.jsonKey/value configuration, read inside the plugin via scan.ConfigGet(key)Optional

4.1 meta.yaml Field-by-Field Table

It shares the same multilingual convention with YAML POCs: Languages + DefaultLanguage are the single source of truth, while flat fields (Name/Description, etc.) are runtime-parsed fields. For every field the table below gives "type / required / meaning / example value / consequence of a mistake".

FieldTypeRequiredMeaningExample valueConsequence of a mistake
VulnIdestringYesUnique vulnerability identifier, globally unique048d825b807f4215a705d42a18dba527Colliding with another vulnerability → overwrite/dedup anomalies; if empty it falls back to the directory name and may not match the controller index
NamestringYesVulnerability name (flat field, can be overridden by Languages)WASM-go-反射型XSS检测If left blank, the vulnerability list/card name is empty
LanguagesmapNoMultilingual text map (key → text), the single source of truth{cn: {...}, en: {...}}Keys that do not match DefaultLanguage fall back to flat fields; providing only Chinese makes the English UI show Chinese
DefaultLanguagestringNoDefault language key (e.g. cn / en)cnFalls back to cn when missing; a non-existent language key cannot resolve text
LevelstringYesSeverity level: critical / high / medium / lowhighIllegal values such as High/严重 → level mapping fails and the vulnerability card shows an abnormal level
PocTypestringYesFixed to wasm; decides which execution path is takenwasmA wrong value (e.g. yaml) → it never enters the WASM execution chain at all
CVEIdstringNoCVE identifierCVE-2021-44228No impact (may be empty)
CweIdstringNoCWE identifierCWE-79No impact (may be empty)
CvssScorestringNoCVSS score9.8No impact (may be empty)
CvssVectorstringNoCVSS vectorAV:N/AC:L/...No impact (may be empty)
SourceLanguagestringYesSource language go / c / cpp / rust / tinygo; the build service selects the toolchain from itgoMismatch with the actual language in source/ → build failure or unusable artifact
DescriptionstringNoVulnerability description反射型 XSS 载荷回显检测If blank, the detail page has no description
FingerprintstringNoFingerprint—No impact
AffectedProductsstringNoAffected products—No impact
ReferencesstringNoReference links—No impact
SolutionstringNoRemediation advice—No impact
ConfidenceintNoConfidence (0~100)80Out-of-range values may be displayed as anomalies
AuthorstringNoAuthorTestSecScanNo impact
EnabledboolNoWhether the template is enabledtrueIf false, the template is not dispatched

Minimal usable meta.yaml:

VulnIde: "048d825b807f4215a705d42a18dba527"
Name: "WASM-go-反射型XSS检测"
Level: "medium"
PocType: "wasm"
SourceLanguage: "go"
Description: "反射型 XSS 载荷回显检测"
Enabled: true
Author: "TestSecScan"
Confidence: 80
提示

TestDepth / Verification / AddTime and the like are extra fields on the build/controller side (a real marketplace meta.yaml carries them). A third-party hand-written template only needs the minimal set in the table above; when in doubt, take the meta.yaml exported by the build service as the reference.

4.2 sig.json Field-by-Field Table

FieldTypeRequiredMeaningExample valueConsequence of a mistake
userPubkeystringDepends on the signing methodPublic key for the private signature (base64, the controller-local controller_ed25519.pub)MFkwEwYH...Truncated / not base64 → verification verify_failed, not loaded
userSignaturestringDepends on the signing methodEd25519 signature (base64) over the output.wasm bytes made with the private key abovePZydTvki...Change one character by hand → verification fails
serverPubkeystringDepends on the signing methodPublic key for the official signature (base64, the POC marketplace public key TestSecScanPoc.pub)+napZUOk...The official path fails to verify, so it falls back to the private path or is judged a failure
serverSignaturestringDepends on the signing methodOfficial Ed25519 signature over the output.wasm bytesPZydTvki...Same as above
wasmHashstringRecommendedSHA-256 hex digest of output.wasm2ce8970f6e30...A mismatch with output.wasm means the artifact was swapped; the verification flow will fail
signStatusstringYesVerification status (see the table below)verifiedAnything other than verified/official → the node skips it directly
updateTimestringNoSigning/update time2026-08-15T21:05:34+08:00No impact

Values of signStatus:

ValueMeaningExecutable
verifiedVerification passed (either the official or the private path)✅
officialVerification came from the POC marketplace public key✅
unsignedNo signature❌
verify_failedA signature exists but verification failed (treated as tampered)❌
警告

Verification trusts only the public keys held by the host (official takes precedence over private); the public key bundled inside the package is never trusted. A self-signed key pair will be judged verify_failed and will not enter execution at all — it is not "seen but refused". Executing only verified is a hard gate. The decision values of signStatus are exactly verified / verify_failed / unsigned, and the scan node only lets verified through.

5. Quick Start (Go, Complete and Runnable)

5.1 Project Structure

my-wasm-poc/
├── go.mod
├── main.go
└── scan/
    └── scan.go        # 从官方 SDK 包里复制 scan.go 而来

go.mod:

module my-wasm-poc

go 1.22

require scan v0.0.0

replace scan => ./scan

The scan/ directory simply holds the scan.go from the official SDK package (it carries //go:build wasm and only participates in compilation when GOARCH=wasm). Its package scan name is aligned with your import "scan" through the replace directive.

5.2 main.go

package main

import (
	"strings"

	"scan"
)

func main() {
	// 1) 设置本插件超时(毫秒);不调用默认 30s
	scan.SetTimeout(60000)

	// 2) 必须先调用:从 stdin 读取当前流量包,填充全局 scan.Flow
	scan.LoadFlow()
	if scan.Flow.URL == "" {
		return // 没有有效流量包,直接结束
	}

	scan.Log("当前目标: " + scan.Flow.URL)

	// 3) 发起检测请求(自动 Cookie 会话、经任务代理、限时、响应上限 1MB)
	resp := scan.HTTPGet(scan.Flow.URL)
	if resp.Error != "" {
		scan.Log("请求失败: " + resp.Error)
		return
	}

	// 4) 命中判定
	if strings.Contains(strings.ToLower(resp.Body), "root:x:0:0") {
		scan.Report(scan.VulnFinding{
			Name:     "敏感文件泄露",
			Detail:   "响应中命中 /etc/passwd 特征",
			Level:    "high",
			URL:      scan.Flow.URL,
			Evidence: resp.Body[:min(len(resp.Body), 500)],
		})
	}
}

5.3 Build

GOOS=wasip1 GOARCH=wasm go build -o output.wasm .

The artifact must be a WASI command module and export _start (Go's package main provides it automatically). Do not use -buildmode=c-shared — that is how application plugins are built.

5.4 Placement and Signing

Place output.wasm / meta.yaml / sig.json according to the directory structure in Chapter 4, then complete signing through the GUI "Sign" entry or the TestSecVulnService build service, so that sig.json.signStatus becomes verified. Unsigned artifacts are not loaded by the scan node.

5.5 Debugging

  • In the POC "Test Verification / Debug" panel of the GUI, choose a target URL or a packet and run it; you will see the scan.Log output, the findings from scan.Report, and the real requests/responses;
  • The debug panel is equivalent to the scan node running a single-packet execution with one Flow, so the results match production. For detailed steps see Chapter 10, "Debugging Handbook".

6. Public API: Function-by-Function Guide

All symbols below come from the scan.go in the official SDK package. Each section gives, in order: what it does → signature → parameter table → return → code snippet → notes. The snippets can be copied directly into your POC project.

6.1 Types and Globals

Flow

What it does: the Flow type describes "the single packet currently being fed in". It is the input of the passive scanning model, and you must call scan.LoadFlow() before using any field. The SDK also declares a package-level global variable with the same name, var Flow Flow, so you read it as scan.Flow.XXX.

Signature:

type Flow struct {
	URL        string            `json:"url"`
	Method     string            `json:"method"`
	Host       string            `json:"host"`
	Scheme     string            `json:"scheme"`
	Path       string            `json:"path"`
	Query      string            `json:"query"`
	Headers    map[string]string `json:"headers"`
	Body       string            `json:"body"`
	RawHeaders string            `json:"rawHeaders"`
}

Field table:

FieldTypeMeaningWhere it comes from / formatHow to use it
URLstringFull request URLThe host orchestration side serializes the packet to JSON and writes it to stdin; field name urlMost common: use it directly as the detection target scan.Flow.URL; if empty, no packet was received
MethodstringRequest methodSame JSON, field name method, value GET/POST/PUT… uppercaseReplicate the original request method, e.g. scan.HTTP(scan.Flow.Method, u, "")
HoststringTarget host nameJSON field host, in the form example.com:8080 (may include a port)Build virtual host headers, filter by domain
SchemestringSchemeJSON field scheme, value http or httpsBuild URLs, test whether it is https
PathstringRequest pathJSON field path, starts with /, excludes the query stringReplace paths, filter by suffix (e.g. only handle .php)
QuerystringQuery parametersJSON field query, without the leading ?When appending parameters, decide between ? and &
Headersmap[string]stringRequest headersJSON field headers, keys are already lowercasedRead scan.Flow.Headers["cookie"], ["content-type"]; remember the keys are lowercase
BodystringRequest bodyJSON field body, raw text (may be a form or JSON)Extract parameter values from a POST body and rewrite them
RawHeadersstringRaw request header textJSON field rawHeaders, multi-line text (each line Name: Value)Use when the original casing/order must be kept; use Headers in ordinary cases

Code snippet:

scan.LoadFlow()

// 判空:stdin 没数据或流量包为空时直接结束,避免后续空指针式逻辑
if scan.Flow.URL == "" {
	scan.Log("没有拿到流量包")
	return
}

// 组合使用各字段
scan.Log("方法: " + scan.Flow.Method)
scan.Log("协议: " + scan.Flow.Scheme + "  主机: " + scan.Flow.Host)
scan.Log("路径: " + scan.Flow.Path + "  查询: " + scan.Flow.Query)

// Headers 的 key 是小写
if ct := scan.Flow.Headers["content-type"]; ct != "" {
	scan.Log("Content-Type: " + ct)
}

// 遍历所有请求头
for k, v := range scan.Flow.Headers {
	scan.Log(k + ": " + v)
}

Notes:

  1. If you do not call LoadFlow(), every field is empty — scan.Flow.URL == "", and every later request goes nowhere or to an empty URL. This is the most common trap.
  2. The Headers keys are lowercase; scan.Flow.Headers["Cookie"] returns nothing.
  3. The global variable shares the type's name (both are Flow) and is the package-level scan.Flow variable; LoadFlow() reads stdin once and overwrites it each time.

HTTPResponse

What it does: the return structure of scan.HTTP / scan.HTTPGet / scan.HTTPPost / scan.HTTPJSONPost, carrying the result of one HTTP request. It is what you use to judge whether a request succeeded and to run feature matching against the response body.

Signature:

type HTTPResponse struct {
	StatusCode int               `json:"statusCode"`
	Body       string            `json:"body"`
	Headers    map[string]string `json:"headers"`
	Cookies    []string          `json:"cookies"`
	URL        string            `json:"url"`
	TimeMs     int64             `json:"timeMs"`
	Error      string            `json:"error"`
}

Field table:

FieldTypeMeaningHow to use it
StatusCodeintHTTP status code; 0 means the request never succeededCheck Error == "" first, then whether the code equals the expected value (e.g. 200)
BodystringResponse body text; the host reads at most 1MB and discards the restUse for keyword/regex/JSON checks, e.g. strings.Contains(resp.Body, ...)
Headersmap[string]stringResponse headers, lowercase keys, multiple values joined with , resp.Headers["content-type"], resp.Headers["server"]
Cookies[]stringList of raw Set-Cookie strings from the responseIterate when you need an explicit cookie value; the session is maintained automatically by the host
URLstringFinal request URL (including the address after redirects)Detect whether it was redirected to a login page
TimeMsint64Duration of this request (milliseconds)Time-based blind injection decisions, performance logging
ErrorstringFailure reason; empty string on successAlways check resp.Error == "" before checking the status code

Code snippet:

resp := scan.HTTPGet(scan.Flow.URL)

// 1) 先判错误:网络失败/构造失败时 StatusCode 为 0、Error 非空
if resp.Error != "" {
	scan.Log("请求失败: " + resp.Error)
	return
}

// 2) 再看状态码
scan.Log("状态码: " + scan.TimestampToStr(resp.TimeMs, true)) // 注意:时间格式化见 TimestampToStr
if resp.StatusCode != 200 {
	scan.Log("非 200,放弃")
	return
}

// 3) 取响应头(key 小写)
ct := resp.Headers["content-type"]
scan.Log("content-type: " + ct)

// 4) 做判定
if strings.Contains(strings.ToLower(resp.Body), "root:x:0:0") {
	scan.Report(scan.VulnFinding{Name: "疑似命令执行", Level: "high", URL: resp.URL})
}

Notes:

  1. Reading Body without checking resp.Error: when the connection fails, Body is an empty string, which is easily misread as "no vulnerability", causing a missed report or silence.
  2. The response body limit is 1MB, so trailing features may be unavailable; keep checks near the beginning of the body, or switch to streaming-friendly features.
  3. Headers keys are lowercase and multiple values are merged into a single string, so do not treat them as arrays.

VulnFinding

What it does: the reporting structure used by scan.Report. Construct it and report when a vulnerability is hit, and the GUI generates a vulnerability card; the three fields such as SensitiveText are dedicated to highlighting "sensitive information discovery" findings.

Signature:

type VulnFinding struct {
	Name     string `json:"name"`
	Detail   string `json:"detail"`
	Evidence string `json:"evidence"`
	Level    string `json:"level"`
	URL      string `json:"url"`
	CVEId    string `json:"cveId"`
	Request  string `json:"request"`
	Response string `json:"response"`

	// 敏感信息高亮(vuln-000007 专用,GUI 纯文本查看器按关键字高亮)
	SensitiveText      string   `json:"sensitiveText"`      // 原始文本片段(命中内容前后各 200 字符)
	SensitiveKeywords  []string `json:"sensitiveKeywords"`  // 实际命中的关键字列表(GUI 高亮用)
	SensitiveMatchRule string   `json:"sensitiveMatchRule"` // 实际匹配的公式/规则
}

Field table:

FieldTypeMeaningHow to use it
NamestringVulnerability name (card title)Fill in a Chinese default name or use scan.T("vuln_name") to read from the language pack; leaving it blank produces a blank card
DetailstringVulnerability detail (card body)Explain the evidence hit and the decision logic
EvidencestringEvidence of the hit (response snippet / packet text)Truncating to a few hundred characters is recommended, e.g. resp.Body[:min(len(resp.Body), 500)]
LevelstringLevel: critical / high / medium / lowThe value must be exact; if left blank it falls back to the template's meta.yaml.Level
URLstringHit URLIf left blank, the current packet's Flow.URL is used; when detecting across targets it is better to fill it explicitly
CVEIdstringOptional CVE identifierIf blank, the template metadata is used
RequeststringOptional: the triggering request message (text)Lets the GUI display the raw request
ResponsestringOptional: the hit response message (text)Lets the GUI display the raw response; truncation is recommended
SensitiveTextstringSensitive information highlight: raw text snippetFor sensitive-information findings only; take 200 characters before and after the hit
SensitiveKeywords[]stringSensitive information highlight: list of keywords hitThe GUI plain-text viewer highlights the hit positions from it
SensitiveMatchRulestringSensitive information highlight: the formula/rule matchedShows the basis of the decision

Code snippet:

resp := scan.HTTPGet(scan.Flow.URL)
if resp.Error == "" && strings.Contains(resp.Body, "AKIA") {
	evidence := resp.Body
	if len(evidence) > 500 {
		evidence = evidence[:500]
	}
	scan.Report(scan.VulnFinding{
		Name:     "云密钥泄露",
		Detail:   "响应体中出现疑似 AWS Access Key",
		Evidence: evidence,          // 截断后的证据
		Level:    "high",            // 只能是 critical/high/medium/low
		URL:      resp.URL,          // 留空也会回退 Flow.URL
		Request:  scan.Flow.Method + " " + scan.Flow.URL,
		Response: evidence,
		// 敏感信息三件套(普通漏洞可留空)
		SensitiveText:      resp.Body,
		SensitiveKeywords:  []string{"AKIA"},
		SensitiveMatchRule: "strings.Contains(resp.Body, \"AKIA\")",
	})
}

Notes:

  1. A wrong Level (such as High or 高危) makes level mapping fail and the card level abnormal; always use the four lowercase English values.
  2. If Name / Detail are left blank, the card title/body is empty and it looks like "something was reported but there is no content".
  3. Evidence / Response without length truncation inflate the report payload; truncate them consistently.

6.2 Host API

LoadFlow()

What it does: reads the current packet JSON from stdin and populates the global scan.Flow; this is the only entry point through which the plugin and the host exchange input, and it must be called before reading scan.Flow.*.

Signature:

func LoadFlow()

Parameter table: none.

Return: none. It writes the parse result directly into the package-level variable scan.Flow (on parse failure, Flow keeps its zero value).

Code snippet:

func main() {
	scan.LoadFlow()
	if scan.Flow.URL == "" {
		return // 空包直接退出,避免后续对空 URL 发请求
	}
	scan.Log("收到流量包: " + scan.Flow.Method + " " + scan.Flow.URL)
}

Notes:

  1. It must be the very first thing you call; if you omit it, scan.Flow is entirely zero-valued and the detection logic silently fails.
  2. Calling it repeatedly within the same instance overwrites Flow (usually unnecessary).
  3. It reads stdin only and does not read environment variables — SCAN_* is the fallback the host provides, and the SDK does not parse it for you.

SetTimeout(ms int64)

What it does: dynamically sets the execution timeout for this plugin (this batch), replacing the default 30s. Suited to plugins that need long polling or multi-step requests.

Signature:

func SetTimeout(ms int64)

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
msint64Maximum runtime of this plugin in milliseconds; must be > 0Estimate it yourself from the detection complexity60000 (60 seconds)

Return: none.

Code snippet:

func main() {
	scan.SetTimeout(60000) // 本插件最多运行 60 秒(不调用默认 30s)
	scan.LoadFlow()
	// ... 多步登录 + 检测
}

Notes:

  1. What it resets is the timeout timer of the entire batch, not one per packet; leave enough total time within the batch.
  2. Passing 0 or a negative number is ignored by the host, which still uses the default 30s.
  3. Setting it too large while the plugin hangs occupies that plugin's batch time for a long while and eats into the task's time budget.

T(key string) string

What it does: reads the language-pack text of the current plugin in the current language; used to make UI/log strings bilingual (part of the four-component language packs).

Signature:

func T(key string) string

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
keystringKey in the language pack (without the plugin ID prefix)Your language pack filevuln_name

Return: string. Internally the host assembles the key as <current language>.<plugin ID>.<key> and looks it up; if it is not found, the host falls back to returning key itself (so a failure does not raise an error — it just displays the raw key name).

Code snippet:

scan.Report(scan.VulnFinding{
	Name:   scan.T("vuln_name"),   // 命中语言包 → "反射型XSS"; 未配置 → "vuln_name"
	Detail: scan.T("vuln_detail"),
	Level:  "high",
	URL:    scan.Flow.URL,
})

Notes:

  1. Pass only key — do not pass the plugin ID or the language; the host already prefixes <language>.<plugin ID>. automatically.
  2. When the language pack is missing, what is returned is the key name itself (e.g. vuln_name), not an empty string; do not treat it as "having a value".
  3. The key space includes the plugin ID, so keys with the same name in different plugins never collide.

ConfigGet(key string) string

What it does: reads a configuration value of the current plugin; used to pull tunable parameters (switches, thresholds) out of the code.

Signature:

func ConfigGet(key string) string

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
keystringKey of the configuration entryplugin.config.json (Key/Value; values are all strings)enable_deep

Return: string. If the key does not exist or the configuration is empty, an empty string is returned.

Code snippet:

if scan.ConfigGet("enable_deep") == "1" {
	scan.Log("深度检测已开启")
	for _, p := range []string{"1", "2", "3"} {
		r := scan.HTTPGet(scan.Flow.URL + "?id=" + p)
		_ = r
	}
}
depth := scan.ConfigGet("depth") // 取不到就是 "",自行给默认值
if depth == "" {
	depth = "5"
}

Notes:

  1. If the value cannot be read, an empty string is returned and no error is raised; test for the empty string and supply your own default.
  2. Configuration values are all strings, so numbers must be converted yourself (e.g. == "1" rather than == 1).
  3. The host reads the configuration when the plugin is loaded; when running a single execution from the debug panel without a packaged configuration file, the value may not be readable.

HTTP(method, url, body string) HTTPResponse

What it does: the only outlet through which a plugin accesses the network. Use it when you need something other than GET, or a custom method/request body; underneath it goes through the task proxy, with time and size limits.

Signature:

func HTTP(method, url, body string) HTTPResponse

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
methodstringHTTP method (uppercase)You specify it, or use scan.Flow.Method"POST"
urlstringFull target URL (with scheme)You build it, or base it on scan.Flow.URL"https://a.com/api"
bodystringRaw request body text; pass "" for GETYou build it{"user":"admin"}

Return: HTTPResponse (field by field in 6.1). On request failure, StatusCode=0, Error is non-empty and Body is empty.

Code snippet:

resp := scan.HTTP("POST", scan.Flow.URL, `{"user":"admin","pwd":"123456"}`)
if resp.Error != "" {
	scan.Log("请求失败: " + resp.Error)
	return
}
if resp.StatusCode == 200 && strings.Contains(resp.Body, "token") {
	scan.Report(scan.VulnFinding{
		Name: "登录接口弱口令", Level: "high", URL: scan.Flow.URL,
		Evidence: resp.Body[:min(len(resp.Body), 300)],
		Request:  "POST " + scan.Flow.URL,
		Response: resp.Body[:min(len(resp.Body), 300)],
	})
}

Notes:

  1. Custom request headers are not possible — neither the SDK nor the host provides an entry point for setting headers. When a specific header is required, put the information into the URL/query string or the request body.
  2. The response body limit is 1MB; anything beyond is truncated.
  3. When the method name is invalid or the URL cannot be parsed, an Error is returned instead of a panic — always check for errors.

HTTPGet(url string) HTTPResponse

What it does: a convenience wrapper for GET requests, equivalent to HTTP("GET", url, ""). The most commonly used probing method.

Signature:

func HTTPGet(url string) HTTPResponse

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
urlstringFull target URLYou build it"https://a.com/robots.txt"

Return: HTTPResponse, the same as HTTP.

Code snippet:

resp := scan.HTTPGet(scan.Flow.URL + "/.git/config")
if resp.Error == "" && resp.StatusCode == 200 && strings.Contains(resp.Body, "[core]") {
	scan.Report(scan.VulnFinding{Name: "Git 泄露", Level: "high", URL: scan.Flow.URL})
}

Notes:

  1. It follows redirects automatically (at most 10 times), and resp.URL is the final address.
  2. It is equivalent to body="", so do not expect it to send a POST.
  3. There is no TLS-skip option; a self-signed target shows up through resp.Error.

HTTPPost(url, body string) HTTPResponse

What it does: a convenience wrapper for POST requests, equivalent to HTTP("POST", url, body).

Signature:

func HTTPPost(url, body string) HTTPResponse

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
urlstringFull target URLYou build it"https://a.com/login"
bodystringRaw request bodyYou build ituser=admin&pwd=admin

Return: HTTPResponse, the same as HTTP.

Code snippet:

resp := scan.HTTPPost(scan.Flow.URL, "user=admin&pwd=admin")
if resp.Error == "" && resp.StatusCode == 200 {
	scan.Log("POST 完成, 长度=" + scan.TimestampToStr(int64(len(resp.Body)), false))
}

Notes:

  1. The host does not add Content-Type automatically; form/JSON encoding must be expressed by you in the body.
  2. It is likewise part of the automatic Cookie session, so the login state is preserved.
  3. When the body is empty it degenerates to an empty-body POST — be aware of the difference.

HTTPJSONPost(url, body string) HTTPResponse

What it does: a semantic "POST JSON" convenience call that makes code more readable.

Signature:

func HTTPJSONPost(url, body string) HTTPResponse

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
urlstringFull target URLYou build it"https://a.com/api/user"
bodystringJSON textYou build it{"id":1}

Return: HTTPResponse, the same as HTTP.

Code snippet:

resp := scan.HTTPJSONPost(scan.Flow.URL, `{"id":1}`)
if resp.Error == "" && strings.Contains(strings.ToLower(resp.Headers["content-type"]), "json") {
	user := scan.JSONGet([]byte(resp.Body), "data.username")
	scan.Log("拿到用户名: " + user)
}

Notes:

  1. It is equivalent to HTTPPost in implementation: the host does not additionally inject a Content-Type: application/json header (the SDK comment describes it that way, but the host does not set that header automatically). If the target insists on that header, verify it yourself or switch to a form that can express it.
  2. The limitation on custom headers applies here as well.
  3. Return handling is exactly the same as HTTP.

Log(msg string)

What it does: outputs a debug log line, visible in the GUI debug panel. The most direct way to find out "whether the plugin ran at all and how far it got".

Signature:

func Log(msg string)

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
msgstringAny readable textYou build it"开始检测: " + scan.Flow.URL

Return: none. Logs are collected by the host and sent upward with the result of this execution to the task log and the debug panel.

Code snippet:

scan.Log("=== 开始检测 ===")
scan.Log("目标: " + scan.Flow.URL)
resp := scan.HTTPGet(scan.Flow.URL)
scan.Log("状态码: " + scan.TimestampToStr(int64(resp.StatusCode), false))
scan.Log("响应长度: " + scan.TimestampToStr(int64(len(resp.Body)), false))

Notes:

  1. Logs accumulate per batch and are reset before each single-packet execution; multiple calls within the same execution are all kept.
  2. Do not emit huge numbers of log lines inside very high-frequency loops — it slows things down and floods the log panel.
  3. Log content is plain text, and embedded newlines are rendered as-is; single-line short messages are recommended.

Report(f VulnFinding)

What it does: reports one vulnerability finding (internally serialized to JSON and passed to scan_report). The final action when you hit something.

Signature:

func Report(f VulnFinding)

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
fVulnFindingA filled-in vulnerability structureYou build it (fields in 6.1)scan.VulnFinding{Name:"XSS", Level:"high"}

Return: none. After a successful parse, the host appends it to this execution's finding list and sends it upward; a JSON parse failure is silently dropped.

Code snippet:

// 一次执行可以上报多条
scan.Report(scan.VulnFinding{Name: "反射型XSS", Detail: "参数回显", Level: "medium", URL: scan.Flow.URL})
scan.Report(scan.VulnFinding{Name: "SQL注入", Detail: "报错回显", Level: "high", URL: scan.Flow.URL})

Notes:

  1. The fields of Report must serialize to JSON properly; the field values themselves are fine, but a semantically wrong Level is not caught here — it affects how the card is displayed.
  2. Reporting several findings from the same execution produces several cards/detail entries; keep deduplication semantics in mind (the host deduplicates by vulnerability hash).
  3. If you hit something but Name is empty, the card title is blank — always fill in the name.

6.3 Built-in Function Wrappers

All wrappers below are implemented by the scan node itself (the host's built-in subset of safe functions); the plugin only passes arguments, so the compiled size is smaller and behavior is consistent. Unless stated otherwise, a failed underlying call always returns an empty string — no panic, no error.

Base64Encode(data string) string

  • What it does: Base64 encoding; used to build Basic authentication headers and to turn binary data into text that fits in URLs/JSON.
  • Parameter: data string, the raw text to encode; example "admin:admin".
  • Return: string, the encoded result; an empty input or a failed underlying call returns an empty string.
enc := scan.Base64Encode("admin:admin") // "YWRtaW46YWRtaW4="
scan.Log("Authorization: Basic " + enc)
  • Note: a failed underlying call returns an empty string (no error), so do not use it as proof that there is content.

Base64Decode(data string) string

  • What it does: Base64 decoding; restores encoded payloads/credentials.
  • Parameter: data string, valid Base64 text; example "YWRtaW46YWRtaW4=".
  • Return: string, the decoded plain text; invalid input returns an empty string.
plain := scan.Base64Decode("YWRtaW46YWRtaW4=") // "admin:admin"
if plain == "" {
	scan.Log("解码失败或原文为空")
}
  • Note: a decode failure and "the plain text was empty to begin with" both return an empty string and cannot be distinguished.

HexEncode(data string) string

  • What it does: converts a byte string into hexadecimal text (no spaces), which makes binary data easy to compare/display.
  • Parameter: data string, any byte string.
  • Return: string, a lowercase hexadecimal string; an empty string on failure.
scan.Log(scan.HexEncode("AB")) // "4142"
  • Note: encoding works per byte, so a Chinese character becomes 6 hexadecimal characters rather than 2.

HexDecode(hex string) string

  • What it does: restores hexadecimal text to a byte string.
  • Parameter: hex string, hexadecimal text of even length.
  • Return: string, the restored byte string; an odd length or illegal characters return an empty string.
scan.Log(scan.HexDecode("4142")) // "AB"
  • Note: the length must be even, otherwise decoding fails and returns an empty string.

URLEncode(input string) string

  • What it does: URL encoding; puts parameters/payloads safely into a query string or path.
  • Parameter: input string, the raw text.
  • Return: string, the encoded result; an empty string on failure.
scan.Log(scan.URLEncode("a b&c")) // "a+b%26c"
u := scan.Flow.URL + "?q=" + scan.URLEncode("'; SELECT 1--")
  • Note: spaces are encoded as +, while some targets expect %20 — be aware of the difference.

URLDecode(input string) string

  • What it does: URL decoding; restores encoded parameters from responses/requests.
  • Parameter: input string, URL-encoded text.
  • Return: string, the decoded result; an empty string on failure.
scan.Log(scan.URLDecode("a%20b%26c")) // "a b&c"
  • Note: an illegal percent sequence fails and returns an empty string.

HTMLEncode(data string) string

  • What it does: HTML entity encoding; builds XSS payloads or display text that needs escaping.
  • Parameter: data string, the raw text.
  • Return: string, the entity-encoded result; an empty string on failure.
scan.Log(scan.HTMLEncode("<script>")) // "&lt;script&gt;"
  • Note: this is entity encoding — do not mistake it for URL encoding.

HTMLDecode(data string) string

  • What it does: HTML entity decoding; restores entity text in a response before matching.
  • Parameter: data string, text containing HTML entities.
  • Return: string, the decoded result; an empty string on failure.
scan.Log(scan.HTMLDecode("&lt;title&gt;")) // "<title>"
  • Note: decoding before matching avoids missed detections caused by entity-based bypasses.

CharsetConvert(data, targetEncoding string, autoDetect bool) string

  • What it does: character encoding conversion (for example GBK responses to UTF-8); common with Chinese-language targets.
  • Parameters: data string, the bytes to convert; targetEncoding string, the target encoding (e.g. "UTF-8"); autoDetect bool, whether to auto-detect the source encoding.
  • Return: string, the converted text; an empty string on failure.
body := scan.CharsetConvert(resp.Body, "UTF-8", true)
if strings.Contains(body, "管理后台") {
	scan.Log("命中中文关键字(已转码)")
}
  • Note: passing true for autoDetect is easier; a wrong source-encoding detection produces garbled text, so you can scan.Log it first before making a decision.

MD5(data string) string

  • What it does: computes MD5 (lowercase hexadecimal) for fingerprint/signature matching.
  • Parameter: data string, the raw text to hash.
  • Return: string, a 32-character lowercase hexadecimal string; an empty string on failure.
if scan.MD5(resp.Body) == "098f6bcd4621d373cade4e832627b4f6" {
	scan.Log("MD5 指纹命中")
}
  • Note: keep the case consistent when comparing (the SDK outputs lowercase).

SHA1(data string) string

  • What it does: computes SHA1, used by some fingerprint/signature checks.
  • Parameter: data string.
  • Return: string, 40 hexadecimal characters; an empty string on failure.
scan.Log(scan.SHA1("hello"))
  • Note: the output is lowercase hexadecimal; SHA1 is no longer suitable where security strength matters and is only used to match existing fingerprints.

SHA224(data string) string

  • What it does: computes SHA224.
  • Parameter: data string.
  • Return: string, 56 hexadecimal characters; an empty string on failure.
scan.Log(scan.SHA224("hello"))
  • Note: the output is lowercase and 56 characters long.

SHA256(data string) string

  • What it does: computes SHA256, the most commonly used content fingerprint.
  • Parameter: data string.
  • Return: string, 64 hexadecimal characters; an empty string on failure.
sum := scan.SHA256(resp.Body)
scan.Log("SHA256=" + sum)
  • Note: this is a different concept from WasmHash (the host's SHA-256 over output.wasm) — do not confuse them.

SHA384(data string) string

  • What it does: computes SHA384.
  • Parameter: data string.
  • Return: string, 96 hexadecimal characters; an empty string on failure.
scan.Log(scan.SHA384("hello"))
  • Note: the output is lowercase hexadecimal.

SHA512(data string) string

  • What it does: computes SHA512.
  • Parameter: data string.
  • Return: string, 128 hexadecimal characters; an empty string on failure.
scan.Log(scan.SHA512("hello"))
  • Note: the output is lowercase hexadecimal.

CRC32(data string) string

  • What it does: computes a CRC32 checksum (hexadecimal) for lightweight consistency checks.
  • Parameter: data string.
  • Return: string, the hexadecimal checksum; an empty string on failure.
scan.Log(scan.CRC32("hello"))
  • Note: CRC is not a cryptographic hash and must not be used for security verification.

CRC64(data string) string

  • What it does: computes a CRC64 checksum (hexadecimal).
  • Parameter: data string.
  • Return: string, the hexadecimal checksum; an empty string on failure.
scan.Log(scan.CRC64("hello"))
  • Note: like CRC32, it is a non-cryptographic checksum.

RandStr(mode, length int) string

  • What it does: generates a random string; used to build unique markers or payloads for blind (no-echo) probing.
  • Parameters: mode int, bit flags (1 lowercase / 2 digits / 4 uppercase / 8 special; combinable); length int, the length.
  • Return: string, a random string; an empty string on failure.
marker := scan.RandStr(2|4, 12) // 数字+大写,12 位
scan.Log("本次标记: " + marker)
resp := scan.HTTPGet(scan.Flow.URL + "?cb=" + marker)
if strings.Contains(resp.Body, marker) {
	scan.Report(scan.VulnFinding{Name: "参数回显", Level: "low", URL: scan.Flow.URL})
}
  • Note: mode is a bitwise OR combination; passing 0 may yield an empty string, and an excessive length may be limited downstream.

LeftOf(s, sep string) string

  • What it does: takes the content on the left of a separator (including how the original text is handled when there is no match); extracts fields from a response.
  • Parameters: s string, the source text; sep string, the separator (e.g. "\"token\":\"").
  • Return: string, the content to the left of the separator; what happens when the separator is absent is up to the underlying implementation (may return an empty string or the original text).
token := scan.LeftOf(resp.Body, "\"token\":\"")
scan.Log("token 前缀: " + token)
  • Note: the argument order is (s, sep) — do not swap it; when sep contains quotes/escapes, write them out in full.

RightOf(s, sep string) string

  • What it does: takes the content on the right of a separator.
  • Parameters: s string, the source text; sep string, the separator.
  • Return: string, the content to the right of the separator.
val := scan.RightOf(resp.Body, "\"token\":\"")
scan.Log("token 之后: " + val)
  • Note: it splits at the first match; with multiple matches the result may not be the segment you expect.

MiddleOf(s, left, right string) string

  • What it does: takes the content between two markers; the most commonly used extraction method.
  • Parameters: s string, the source text; left string, the left marker; right string, the right marker.
  • Return: string, the content between the two markers; if either marker is missing it usually returns an empty string.
title := scan.MiddleOf(resp.Body, "<title>", "</title>")
scan.Log("页面标题: " + title)
  • Note: it searches for the left marker and then the right marker in order; multi-line/nested cases must be handled by yourself.

TextBetween(source, start, end string, startPosition int, offset string, fallback bool) (int, string)

What it does: a more fine-grained "take content between two texts" than MiddleOf; you can specify the starting search position and an offset, and it returns the position as well.

Signature:

func TextBetween(source, start, end string, startPosition int, offset string, fallback bool) (int, string)

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
sourcestringSource textresp.Body and the like<title>首页</title>
startstringStart markerYou specify it"<title>"
endstringEnd markerYou specify it"</title>"
startPositionintByte offset in the source text where the search startsYou specify it (pass 0 the first time)0
offsetstringResult offset (in string form)You specify it""
fallbackboolWhether to fall back to the source text when not foundYou specify ittrue

Return: (int, string) — two return values: pos is the hit position (int) and text is the extracted content (string). You must accept both, or at least ignore one with _. On failure text is usually empty and pos is 0 or -1 (depending on the underlying implementation).

Code snippet:

// 两个返回值:pos 是位置(int),text 是内容(string)
pos, text := scan.TextBetween(resp.Body, "<title>", "</title>", 0, "", true)
scan.Log(text)
scan.Log("位置: " + scan.TimestampToStr(int64(pos), false)) // 将 int 转成字符串展示

// 只关心内容时用 _ 忽略位置
_, title := scan.TextBetween(resp.Body, "<title>", "</title>", 0, "", true)
if title != "" {
	scan.Report(scan.VulnFinding{Name: "标题回显", Level: "low", URL: scan.Flow.URL, Evidence: title})
}

Notes:

  1. Do not accept only one of the two return values: pos, text := ... is the correct form; writing text := scan.TextBetween(...) will not compile.
  2. pos is an int, so it must be converted before it can go into a string log (as with TimestampToStr above).
  3. With fallback=true, the original text may also be returned when nothing is found, so before making a decision it is best to confirm that text really lies between the markers.

JSONGet(data []byte, path string) string

What it does: extracts a string value from a JSON response by path; the most convenient way to make field-level decisions.

Signature:

func JSONGet(data []byte, path string) string

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
data[]byteRaw JSON bytes[]byte(resp.Body)[]byte(resp.Body)
pathstringPath expression; supports a.b[0].cYou write it according to the response structure"data.user.name"

Return: string, the value obtained (stringified); if the path does not exist or cannot be resolved, an empty string is returned.

Code snippet:

// 注意第一个参数是 []byte,要显式转换
name := scan.JSONGet([]byte(resp.Body), "data.user.name")
if name == "" {
	// 换个可能的路径再试
	name = scan.JSONGet([]byte(resp.Body), "user.name")
}
if name != "" {
	scan.Report(scan.VulnFinding{Name: "未授权读取用户数据", Level: "high", URL: scan.Flow.URL, Evidence: name})
}

// 数组下标语法 a.b[0].c
first := scan.JSONGet([]byte(resp.Body), "data.items[0].id")
scan.Log("首个 id: " + first)

Notes:

  1. The first parameter is []byte and cannot take resp.Body (a string) directly; use []byte(resp.Body).
  2. A non-existent path returns an empty string without raising an error; testing the empty string is enough, but keep several candidate paths as a fallback.
  3. The path syntax supports dots and [n] indices, e.g. a.b[0].c; field names containing special characters may not resolve.

JSONGetRaw(data []byte, path string) string

What it does: extracts a raw value from JSON by path (preserving JSON literals such as objects/arrays/quoted strings); suited to pulling out a sub-structure as-is.

Signature:

func JSONGetRaw(data []byte, path string) string

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
data[]byteRaw JSON bytes[]byte(resp.Body)[]byte(resp.Body)
pathstringPath expressionYou write it"data.items"

Return: string, the raw JSON fragment (objects/arrays remain JSON text); an empty string when nothing is found (or when the underlying conversion fails).

Code snippet:

raw := scan.JSONGetRaw([]byte(resp.Body), "data.items")
scan.Log("items 原始值: " + raw)
if strings.HasPrefix(raw, "[") {
	// 是数组,可以再用 JSONGet 取下标
	first := scan.JSONGet([]byte(resp.Body), "data.items[0].id")
	scan.Log("首个 id: " + first)
}

Notes:

  1. As with JSONGet, the first parameter is []byte.
  2. What comes out is raw JSON text, so strings keep their quotes — be careful when testing.
  3. When nothing is found it mostly returns an empty string; do not json.Unmarshal the result without checking for emptiness first.

TimeToTimestamp(timeStr string, isMilli bool) int64

What it does: converts a time string into a timestamp (integer); used for time-based blind injection / time comparisons.

Signature:

func TimeToTimestamp(timeStr string, isMilli bool) int64

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
timeStrstringTime stringYou build it"2026-10-05 12:00:00"
isMilliboolWhether to output millisecondsYou specify itfalse (seconds)

Return: int64 timestamp; a parse failure returns 0 (the zero value when json.Unmarshal fails).

Code snippet:

ts := scan.TimeToTimestamp("2026-10-05 12:00:00", false)
scan.Log("时间戳(秒): " + scan.TimestampToStr(ts, false))
if ts == 0 {
	scan.Log("时间解析失败")
}

Notes:

  1. isMilli decides the unit; mixing seconds and milliseconds yields absurd differences.
  2. A format mismatch returns 0, so use 0 to detect failure.

TimestampToStr(ts int64, isMilli bool) string

What it does: converts a timestamp into a readable time string; it is also often used temporarily to turn an integer into a string (as in the logging examples above).

Signature:

func TimestampToStr(ts int64, isMilli bool) string

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
tsint64TimestampA TimeToTimestamp result or a time difference1759646400
isMilliboolWhether ts is in millisecondsYou specify ittrue

Return: string, the time string; an empty string on failure.

Code snippet:

ts := scan.TimeToTimestamp("2026-10-05 12:00:00", false)
scan.Log(scan.TimestampToStr(ts, true)) // 按毫秒解释 ts

// 也常用来把 int 拼进日志(非时间语义)
scan.Log("响应长度: " + scan.TimestampToStr(int64(len(resp.Body)), false))

Notes:

  1. isMilli must match the actual unit of ts, otherwise the time is scrambled.
  2. Using it for "int to string" is only a convenience; semantically it is a timestamp, so do not use it for strict formatting.

FormatTime(now string, param int) string

What it does: performs offset arithmetic on a given time (N days/hours/minutes/seconds forwards or backwards, etc.); the semantics of param are described in the host tools documentation.

Signature:

func FormatTime(now string, param int) string

Parameter table:

ParameterTypeWhat to passWhere it comes fromExample value
nowstringBase time stringYou build it"2026-10-05 12:00:00"
paramintOffset parameter (encodes unit/direction)You specify it according to your use case1

Return: string, the offset time string; an empty string on failure.

Code snippet:

next := scan.FormatTime("2026-10-05 12:00:00", 1)
scan.Log("偏移结果: " + next)

Notes:

  1. The semantics of param are defined by the host tools (days/hours/minutes, etc.); different values differ greatly, so do not guess — determine them from the documentation or experiments.
  2. A mismatched time format returns an empty string.

7. Host Functions (env Namespace) Low-Level Table

Hand-written languages that do not use the Go SDK (Zig / C# / AssemblyScript, etc.) need to import the functions below directly. All string parameters are i32 (a linear-memory pointer + length), and the return value is the number of bytes written into the out buffer.

FunctionParameters (all i32)ReturnDescription
scan_httpmethodPtr, methodLen, urlPtr, urlLen, bodyPtr, bodyLen, outPtr, outCapBytes writtenSends HTTP (automatic Cookie session); the response JSON is written into out
scan_logmsgPtr, msgLen—Outputs one debug log line
scan_reportfindingPtr, findingLen—Reports a vulnerability finding (VulnFinding JSON)
scan_callfuncID, argsPtr, argsLen, outPtr, outCapBytes writtenCalls a built-in function (arguments are a JSON array; returns a JSON string)
scan_tkeyPtr, keyLen, outPtr, outCapBytes writtenReads this plugin's language-pack text in the current language (key space <language>.<plugin ID>.<key>)
scan_configkeyPtr, keyLen, outPtr, outCapBytes writtenReads this plugin's configuration value
scan_set_timeoutms—Sets this plugin's execution timeout (milliseconds); the default 30s applies when it is not called

Why the plugin must pass "pointer + length" itself (WASI ABI)

WASM is a linear-memory sandbox: the memory of the host process and the memory of the WASM module are two completely isolated address spaces. When a host function receives an int, it neither knows whether it is text or a number nor can access any byte in the plugin's memory — unless the plugin tells it explicitly:

  • Pointer: the start address of the string/bytes in WASM linear memory (i32);
  • Length: how many bytes starting at that address make up this content.

Based on these, the host reads the content out of WASM linear memory by pointer and length, then decodes it as UTF-8 (the Go SDK wrappers automatically split a string into pointer + length). Likewise, the output buffer must be allocated in advance by the plugin (the Go SDK prepares 4KB for string-returning wrappers and 64KB for HTTP responses and built-in function calls); the plugin passes outPtr + outCap to the host, the host returns the actual number of bytes written n after writing, and the plugin takes out[:n]. If the content exceeds outCap, the host truncates it — which is also why the response body limit and the buffer size must match.

Hand-written C calling example (consistent with scan_sdk.h):

/* 底层导入声明:import_module("env") + import_name("scan_http") */
__attribute__((import_module("env"), import_name("scan_http")))
extern int scan_http(int mp, int ml, int up, int ul, int bp, int bl, int op, int oc);

static inline int scan_ptr(const char* s) { return (int)(long)s; }
static inline int scan_len(const char* s) { return s ? (int)strlen(s) : 0; }

char out[65536];
int n = scan_http(scan_ptr("GET"), scan_len("GET"),
                  scan_ptr(target), scan_len(target),
                  scan_ptr(""), scan_len(""),
                  scan_ptr(out), (int)sizeof(out));
out[n] = 0; /* 宿主返回的是写入字节数,需自行补 '\0' 再当 C 字符串用 */

Direct-call example with //go:wasmimport in Go (without the SDK HTTP wrapper):

package main

import "unsafe"

//go:wasmimport env scan_http
func scanHTTP(method, url, body string, out unsafe.Pointer, outCap uint32) uint32

//go:wasmimport env scan_log
func scanLog(msg string)

func rawGet(target string) string {
	var out [65536]byte
	n := scanHTTP("GET", target, "", unsafe.Pointer(&out[0]), uint32(len(out)))
	scanLog("原始响应字节数: " + string(out[:n]))
	return string(out[:n]) // 这里拿到的是响应 JSON 文本
}

func main() { _ = rawGet("http://example.com/") }
说明

Go's string parameters are automatically split by the compiler into "pointer + length", so the signature can simply say method, url, body string without manual splitting.

8. Full Built-in Function ID Table (scan_call) and Direct Calls

Built-in functions are implemented by the scan node itself, and the plugin only passes arguments: the benefit is a smaller plugin and consistent behavior (the same ID has exactly the same semantics in the Go / C / C++ / Rust SDKs). Arguments are always a JSON array string, and the return value is a JSON string.

IDFunctionArgumentsReturn
1to_strvalueConverts any value to a string
2to_intvalueConverts any value to an integer
3to_int64valueConverts any value to int64
4to_uint32valueConverts any value to uint32
5to_uint64valueConverts any value to uint64
6to_floatvalueConverts any value to a float
7to_boolvalueConverts any value to a boolean
8to_bytesvalueConverts any value to a byte string (string form)
10base64_encodedataString
11base64_decodedataString
12bytes_to_hexdataHexadecimal string (no spaces)
13hex_to_byteshexByte string
14url_encodeinputString
15url_decodeinputString
16html_encodedataString
17html_decodedataString
18charset_convertdata, targetEncoding, autoDetectString
20md5dataHexadecimal string
21sha1dataHexadecimal string
22sha224dataHexadecimal string
23sha256dataHexadecimal string
24sha384dataHexadecimal string
25sha512dataHexadecimal string
26crc32dataHexadecimal string
27crc64dataHexadecimal string
30rand_strmode, lengthRandom string (mode bit flags: 1 lowercase / 2 digits / 4 uppercase / 8 special)
40left_ofs, keyWordContent to the left of the separator
41right_ofs, keyWordContent to the right of the separator
42middle_ofs, left, rightContent between the two markers
43text_betweensource, start, end, startPosition, offset, fallbackToSource{"pos":n,"text":""}
44csv_to_linefields, lineBreakOne line of a CSV string
45csv_cleanfieldsCleaned JSON array string
50json_getjsonData, pathString
51json_get_rawjsonData, pathRaw JSON value
60timestamptimeStr, isMilliTimestamp (string form)
61timestamp_strts, isMilliTime string
62format_timenow, paramTime offset result
说明

The Go SDK does not wrap the type-conversion functions with IDs 1~8 individually; in hand-written languages, or whenever needed, they can be called directly with scan_call. Not exposed: dangerous capabilities such as file I/O, process/system commands, DNS/arbitrary networking, direct HTTP connections (everything goes through scan_http) and concurrency pools.

How to call scan_call directly without the wrappers: the arguments are a JSON array, and the return value is a JSON-encoded string (so the result carries quotes and must be deserialized or stripped of quotes by string handling). Below is a complete example that calls it directly with //go:wasmimport instead of the SDK MD5 wrapper:

package main

import (
	"encoding/json"
	"unsafe"
)

//go:wasmimport env scan_call
func scanCall(id uint32, args string, out unsafe.Pointer, outCap uint32) uint32

// rawCall 直调内置函数:argsJSON 形如 `["hello"]`,返回反序列化后的字符串
func rawCall(id uint32, argsJSON string) (string, bool) {
	var out [65536]byte
	n := scanCall(id, argsJSON, unsafe.Pointer(&out[0]), uint32(len(out)))
	if n == 0 {
		return "", false // 宿主写入 0 字节 = 失败(或结果为空)
	}
	var s string
	if err := json.Unmarshal(out[:n], &s); err != nil {
		return "", false // 返回值不是 JSON 字符串
	}
	return s, true
}

func main() {
	// 等价于 scan.MD5("hello"),ID=20,参数为 JSON 数组
	sum, ok := rawCall(20, `["hello"]`)
	if ok {
		scanLogRaw("MD5=" + sum)
	}

	// ID=30 rand_str,参数是数字(JSON 里直接用整数)
	r, _ := rawCall(30, `[6,12]`) // mode=2|4, length=12
	_ = r

	// 未知 ID 时宿主返回 {"error":"未知的内置函数 ID"}
	if _, ok := rawCall(9999, `[]`); !ok {
		// 处理失败
	}
}

//go:wasmimport env scan_log
func scanLogRaw(msg string)

Three key points for direct calls:

  1. The return value of scan_call is the number of bytes written into out; n == 0 counts as a failure/empty result.
  2. The returned content is a JSON string literal (with quotes); it must go through json.Unmarshal or manual quote stripping before it can be used as the original text — that is exactly what the Go SDK wrappers do.
  3. Strings inside the JSON argument array must be escaped by yourself (text containing " or \); hand-written languages can refer to scan_json_escape in scan_sdk.h.

9. HTTP Capability Details

scan.HTTP is the only outlet through which a plugin accesses the network. It is implemented by the host's env.scan_http proxy and constrained by the following policies:

  • Automatic Cookie session: the host maintains a cookiejar for each plugin instance; a response Set-Cookie is saved automatically and sent with subsequent requests. Suited to login-state / multi-step request scenarios.
  • Through the task proxy: if the task has a proxy configured, requests go through it; otherwise they connect directly.
  • Time limit: constrained jointly by the plugin-level timeout (default 30s, adjustable with SetTimeout) and the HTTP client timeout.
  • Response body limit 1MB: the host reads at most 1MB with io.LimitReader, and anything beyond is discarded. Do not rely on the tail of a very large response when making decisions.
  • resp.Error check: when building the request fails or a network error occurs, StatusCode = 0 and Error is non-empty; always check resp.Error == "" before checking the status code.
  • Redirects: at most 10 are followed; beyond 10 the last response is returned (no further redirection).
  • Certificate policy: the default TLS verification is used and certificate validation is not skipped; a self-signed target shows up through resp.Error.
resp := scan.HTTP("POST", target, `{"user":"admin"}`)
if resp.Error != "" {
	scan.Log("请求失败: " + resp.Error)
	return
}
if resp.StatusCode == 200 && resp.Headers["content-type"] != "" {
	scan.Log("content-type: " + resp.Headers["content-type"])
}
提示

The Cookie session is maintained per plugin instance and persists across packets (the entire batch of one plugin shares a single session). This is both an advantage (multi-step logins carry Cookies automatically) and a risk of cross-contamination between packets — see the symptom table in Chapter 10.

10. Debugging Handbook

This chapter provides a debugging path you can follow step by step, plus a "symptom → cause → fix" quick-reference table.

10.1 Watching Logs: scan.Log and the stdout Fallback Protocol

  • Prefer scan.Log(msg): logs are collected by the host and sent upward with the result of this execution, displayed line by line in the scan node's task log and in the GUI debug panel. The most direct way to find out "whether it executed and how far it got".
  • stdout fallback protocol: besides scan_report, the older line protocol is also supported (C/C++ hand-written implementations commonly use printf/write):
    • [LOG] <消息> → recorded as one debug log line;
    • [FIND] <VulnFinding JSON> → recorded as one vulnerability finding.
    • After _start returns, the host inspects what the plugin wrote to stdout and matches the prefixes line by line. Using scan_report/scan.Log is still recommended; stdout is only for compatibility and as a fallback.
/* C 手写:不引入 SDK 时的兜底输出 */
write(1, "[LOG] 开始检测\n", 17);
char buf[1024];
int n = snprintf(buf, sizeof(buf),
    "[FIND] {\"name\":\"命令注入\",\"detail\":\"命中回显\",\"level\":\"high\",\"url\":\"%s\"}\n", target);
write(1, buf, n);
注意

A [FIND] line must be single-line valid JSON, and the prefix must be exactly [FIND] (optionally followed by a space). A JSON parse failure is silently ignored (the host discards that line outright), so you will only see "nothing was reported" and no error.

10.2 Five-Minute Minimal Verification Flow

  1. Edit the source: in your POC project, edit main.go (or the C/Rust source) and add a line such as scan.Log("我跑到这里了").
  2. Rebuild: GOOS=wasip1 GOARCH=wasm go build -o output.wasm . (confirm the artifact exports _start).
  3. Upload/build: upload source/ at the GUI's WASM vulnerability build entry, or replace output.wasm in the build service directly.
  4. Sign: sign through the GUI signing entry or the TestSecVulnService build service, and confirm sig.json.signStatus == "verified".
  5. Single-packet trial run: pick a packet (or fill in a URL manually) in the debug panel and run it once; this is equivalent to one single-packet execution by the host.
  6. Check logs and hits: your scan.Log output should appear in the debug panel, and a vulnerability card should appear when something is hit; if there is no output, troubleshoot according to 10.3.
提示

As long as GOOS=wasip1 GOARCH=wasm go build succeeds locally inside source/, the artifact is basically usable; the real differences usually lie only in "the signature" and "whether LoadFlow was called".

10.3 Symptom → Cause → Fix (10+ entries)

SymptomCauseFix
The plugin does nothing and there are no logsscan.LoadFlow() was forgotten, so scan.Flow is entirely emptyCall scan.LoadFlow() on the first line of main, and return early when scan.Flow.URL == ""
The debug panel shows "not loaded / unsigned" and it never executesNo signature, or signature verification failed (unsigned / verify_failed)Sign through the GUI/build service so sig.json.signStatus becomes verified; confirm output.wasm was not modified after signing
Instantiation failure: instantiate module ... unknown importapp_* (or ctl_*) host functions were used by mistakeA WASM POC uses only scan_*; only application plugins use app_*, and the two sets cannot be mixed
The result Error says "the wasm module is missing the _start entry"Built with -buildmode=c-shared by mistakeBuild vulnerability POCs with GOOS=wasip1 GOARCH=wasm go build -o output.wasm .
Trailing features cannot be detected and the response looks cut offThe response body was truncated by the 1MB limitKeep checks near the beginning; or switch to features that do not depend on a very large response; if necessary change approach (e.g. look only at the status code/headers)
The result Error says "plugin execution timed out (default 30s…)"The default 30s timeout was reached and the plugin did not finishCall scan.SetTimeout(60000) at the start of main to raise it reasonably; avoid pointless long polling/sleeping
There is a vulnerability card but the title/body is blankKey Report fields were left empty (Name/Detail)Fill in at least Name (you can use scan.T("vuln_name")) and Detail; a blank URL falls back to Flow.URL
The card level is displayed abnormally or is missingA wrong Level value (e.g. High/高危)Use only the four lowercase values critical / high / medium / low
Results of multiple packets contaminate each other and the login state is scrambledThe Cookie session persists across packets (maintained per plugin instance)When isolation is required, clear the session yourself (change strategy) or split into separate plugins; do not assume "a brand-new session per packet"
scan.T(key) / scan.ConfigGet(key) returns an empty string or the key nameThe language pack/configuration is missing or the key is wrongT returns the key name itself when it cannot find one; ConfigGet returns an empty string; verify the key name and whether the language pack/plugin.config.json ships with the package
It stops executing after sig.json was edited by handHand-editing sig.json made the signature/hash mismatchDo not hand-edit signature files; run the signing flow again to generate sig.json
scan.JSONGet(...) does not compileA string was passed as the first argumentIt needs []byte; use scan.JSONGet([]byte(resp.Body), "a.b[0].c")
Reading resp.Body right away yields empty and it is misjudged as no vulnerabilityresp.Error was not checkedDo if resp.Error != "" { return } first, then check the status code and content
Built-in function results/logs are truncatedThe out buffer is too small (T/ConfigGet 4KB, HTTP/scan_call 64KB)The Go SDK already provides enough; hand-written languages must supply a sufficient outCap; remember the host truncates beyond it
Reading files / connecting to databases / opening sockets directly all failThese capabilities are not exposed (an anti-RCE allowlist)Use only scan_http and the controlled APIs listed in this document; networking always goes through scan_http

10.4 Platform-Independent Self-Testing: Extract Logic into Pure Functions and Run go test

WASM can only run inside the host, but your decision logic does not have to depend on the host — extract it into pure functions that "take strings in and return bool/structs", iterate quickly locally with ordinary go test, and simply call them from main at the end.

package main

import (
	"strings"
)

// 纯函数:只依赖标准库,可在普通 GOARCH 下用 go test 跑
func IsPasswdLeak(body string) bool {
	return strings.Contains(strings.ToLower(body), "root:x:0:0")
}

// POC 入口:把「取数据」与「判逻辑」分开
func main() {
	scan.SetTimeout(30000)
	scan.LoadFlow()
	if scan.Flow.URL == "" {
		return
	}
	resp := scan.HTTPGet(scan.Flow.URL)
	if resp.Error != "" {
		return
	}
	if IsPasswdLeak(resp.Body) {
		scan.Report(scan.VulnFinding{Name: "敏感文件泄露", Level: "high", URL: resp.URL})
	}
}

The corresponding test (file name leak_test.go, without //go:build wasm, so it can be run directly with go test on your machine):

package main

import "testing"

func TestIsPasswdLeak(t *testing.T) {
	if !IsPasswdLeak("<pre>root:x:0:0:root:/root:/bin/bash</pre>") {
		t.Fatal("应命中 passwd 特征")
	}
	if IsPasswdLeak("<html>hello</html>") {
		t.Fatal("不应命中")
	}
}
重要

Because main.go imports the scan package (which carries //go:build wasm), that package does not participate in compilation for a non-wasm build on your machine and will be reported as missing. It is recommended to split the pure functions and main into different files (for example, logic.go contains only pure functions while main.go depends on wasm), and to run tests only against logic.go; alternatively, isolate main with a build tag when testing locally. That way you can quickly verify the decision logic on a machine without a scan node, and then compile the whole thing to wasm for the platform.

11. Security Policy

  1. Only verified WASM is executed: the scan node loads and executes only templates that pass signature verification; the rest are skipped and recorded.
  2. Host function allowlist: only scan_http / scan_log / scan_report / scan_call / scan_t / scan_config / scan_set_timeout; file I/O, process/system commands and DNS/arbitrary networking are not exposed.
  3. No files / processes / direct network connections: a plugin cannot access the host filesystem, cannot execute external programs and cannot initiate arbitrary network connections on its own; HTTP always goes through scan_http (via the proxy, with time and size limits).
  4. Crash protection: a WASM execution error is returned as an error, and the host adds a recover fallback, so a trap/panic cannot bring down the scanning process.
  5. Timeout termination: the default is 30s per plugin; on timeout that plugin is terminated automatically and the next one continues, without affecting the whole scan task.
重要

This is a "capability minimization" design (anti-RCE, similar to the remediation of the historical Lua plugin issue). Your plugin can only perform detection with the APIs listed in this document — this is not a restriction, it is a security boundary.

12. Pitfall Checklist

PitfallSymptomHow to avoid it
Forgetting LoadFlow()scan.Flow.URL is empty and the plugin does nothingCall LoadFlow() at the start of main and return early on the empty value
Forgetting to signsignStatus=unsigned and the plugin is never executed at allSign through the GUI / build service and confirm verified
Writing app_* into itInstantiation failure unknown importA WASM POC uses only scan_*; only application plugins use app_*
Building with c-shared by mistakeThe module is missing _startBuild vulnerability POCs with GOOS=wasip1 GOARCH=wasm go build -o output.wasm .
Response body truncatedTrailing features cannot be detectedRemember the 1MB response limit; keep checks near the beginning and rethink large-content checks
Output buffer too smallLog / built-in function results are truncatedThe Go SDK already gives 4KB / 64KB; hand-written languages must provide enough buffer
Timeout set too high/too lowA large plugin is cut off at 30s, or the task's time is used upRaise it reasonably with SetTimeout at the start; avoid pointless long polling
Multiple packets sharing a sessionLogin state / Cookies contaminate each other across packets and results driftThe session is maintained per plugin instance and persists across packets; clear it yourself or split plugins when isolation is needed
Not checking resp.ErrorReading Body directly yields an empty string and is misjudged as "no vulnerability"Check resp.Error == "" first, then the status code and content
Looking for file/network APIs among the built-insAn error or an empty string is returnedFile/network/process capabilities are not exposed; use scan_http and the controlled APIs instead
Passing a string to JSONGetIt does not compileThe first parameter is []byte; use scan.JSONGet([]byte(resp.Body), path)
Hand-editing sig.jsonThe signature/hash no longer matches and it stops executingDo not hand-edit; run the signing flow again

13. Complete Examples

13.1 Backup File Exposure Detection (Go)

Scenario: append common backup file names to the target directory and request them; report a hit.

package main

import (
	"strings"

	"scan"
)

func main() {
	scan.SetTimeout(60000)
	scan.LoadFlow()
	if scan.Flow.URL == "" {
		return
	}

	// 取 URL 中最后一个 '/' 之前的目录前缀
	base := scan.Flow.URL
	if i := strings.LastIndex(base, "?"); i >= 0 {
		base = base[:i]
	}
	if i := strings.LastIndex(base, "/"); i >= 0 {
		base = base[:i]
	}

	targets := []string{"/backup.zip", "/www.zip", "/db.sql", "/.git/config", "/config.php.bak"}
	for _, t := range targets {
		u := base + t
		resp := scan.HTTPGet(u)
		if resp.Error != "" || resp.StatusCode != 200 {
			continue
		}
		low := strings.ToLower(resp.Body)
		// 命中特征:备份文件/配置文件的典型内容
		if strings.Contains(low, "db_host") || strings.Contains(low, "[core]") ||
			strings.Contains(low, "create table") || strings.Contains(resp.Headers["content-type"], "application/zip") {
			// Evidence 取响应体前 500 字节(Go 1.21+ 内置 min)
			evidence := resp.Body[:min(len(resp.Body), 500)]
			scan.Report(scan.VulnFinding{
				Name:     "备份/配置文件泄露",
				Detail:   "发现可访问的备份或配置文件: " + t,
				Level:    "high",
				URL:      u,
				Evidence: evidence,
				Request:  scan.Flow.Method + " " + u,
				Response: resp.Body[:min(len(resp.Body), 200)],
			})
			break
		}
	}
}
说明

min is a Go 1.21+ built-in; if your toolchain is older, replace it with if len(s) > 500 { s = s[:500] }. Truncate Evidence / Response as a rule to keep the report payload from getting too large.

13.2 Unauthorized Access Detection for JSON APIs (Go)

Scenario: enumerate an id parameter against an API that returns JSON; if it returns another user's data in an unauthenticated state, it is judged as broken access control.

package main

import (
	"strings"

	"scan"
)

func main() {
	scan.SetTimeout(45000)
	scan.LoadFlow()
	url := scan.Flow.URL
	if !strings.Contains(strings.ToLower(url), "json") && !strings.Contains(strings.ToLower(scan.Flow.Headers["accept"]), "json") {
		// 只处理疑似 JSON 接口,避免无效请求
		// 这里按 URL 是否含 /api 判断
		if !strings.Contains(strings.ToLower(url), "/api") {
			return
		}
	}

	sep := "?"
	if strings.Contains(url, "?") {
		sep = "&"
	}

	for _, id := range []string{"1", "2", "100"} {
		u := url + sep + "id=" + id
		resp := scan.HTTPGet(u)
		if resp.Error != "" || resp.StatusCode != 200 {
			continue
		}
		if !strings.Contains(strings.ToLower(resp.Headers["content-type"]), "json") {
			continue
		}
		// 读取关键字段,判断是否泄露了用户/订单数据(注意 JSONGet 第一个参数是 []byte)
		user := scan.JSONGet([]byte(resp.Body), "data.username")
		if user == "" {
			user = scan.JSONGet([]byte(resp.Body), "username")
		}
		if user != "" {
			// 命中:未授权即可读到用户数据
			scan.Report(scan.VulnFinding{
				Name:     "JSON 接口未授权访问",
				Detail:   "未携带认证信息即可读取 id=" + id + " 的用户数据",
				Level:    "high",
				URL:      u,
				Evidence: scan.LeftOf(resp.Body, "\"username\""),
				SensitiveText:      resp.Body,
				SensitiveKeywords:  []string{user},
				SensitiveMatchRule: "json_get(data.username) != empty",
			})
			return
		}
	}
}
提示

Both examples follow the same skeleton: SetTimeout → LoadFlow → build the request → judge resp.Error / the status code → run feature/JSON checks → Report. Once you have this skeleton down, most passive-scanning POCs can be written quickly. If you cannot remember how to use a symbol, go back to Chapter 6 and look up its section.


Related documents: Plugin Development Overview, Application Plugin Development, Go Hot-Reload POC Development.

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