---
slug: scan-poc-wasm
title: 扫描节点 · WASM POC 模板开发
titleEn: Scan Node WASM POC Template Development
summary: 用 WASM 编写漏洞检测模板：scan_* 宿主函数、VulnFinding 上报、meta.yaml 与签名要求。
summaryEn: 'Write vulnerability detection templates in WASM: scan_* host functions, VulnFinding reporting, meta.yaml and signing.'
category: 扫描节点
order: 30
enabled: true
updatedAt: "2026-10-05"
---
# 扫描节点 · WASM POC 模板开发

[[toc]]

> 本文面向**第三方开发者**：只要你有官方发行包 + 本文档，就能从零写出一个能被扫描节点加载并执行的 WASM 漏洞 POC 模板。
>
> 本版把**每一个公开函数/类型**拆成独立小节，逐条讲清「作用 / 签名 / 参数从哪来 / 返回什么 / 怎么取返回值 / 代码片段 / 写错会怎样」。所有参数名、字段名、类型均**逐字来自**官方 SDK 包内的 `scan.go`，可放心复制。

## 一、它是什么

**WASM POC 模板**是「POC 模板列表」里 `PocType = wasm` 的一类漏洞模板。它把检测逻辑编译成一个 **WASI 模块**（`output.wasm`），由**扫描节点**作为宿主加载运行。运行时模型是**被动扫描**：

```text
扫描任务启动（扫描节点）
  ├─ 先把任务里的流量包去重（按包 Hash）
  └─ 加载 / 编译一个插件（先校验签名，只加载 verified 的模板）
        ──▶ 依次对该插件扫全部去重流量包（每包一次执行）
        ──▶ 卸载该插件 ──▶ 加载下一个插件
```

- 一个流量包 = **一次 WASM 执行**：宿主把该包序列化后写入 WASM 的 **stdin**，插件通过 `scan.LoadFlow()` 取出；同时设置 `SCAN_*` 环境变量作为兜底。
- 一次执行对应一个 `Flow` 结构，插件在 `func main()`（WASI 导出 `_start`）里完成检测、用 `scan.HTTP` 发请求、用 `scan.Report` 上报发现、用 `scan.Log` 输出日志。
- **每个插件只加载/编译一次**，处理完所有流量包后才卸载——这样几千个 2~3MB 的插件不会同时驻留内存。

> [!IMPORTANT]
> WASM POC 是**被动扫描器**：它不决定扫哪些目标，而是被扫描节点投喂一个个去重流量包。你的插件职责是「给定一个流量包，决定是否要额外发请求、如何判定、命中后如何上报」。

**与相邻形态的区别（一句话版）**：

| 形态 | 载体 | 入口 | 回答的问题 |
|---|---|---|---|
| YAML 模板 | 声明式 YAML | 引擎解析 | 用规则能表达的检测 |
| Go 热加载 POC | 纯 Go 源码（进程内解释执行） | `@meta` + `func Run` | 需要图灵完备脚本、但不想走 WASM 构建链 |
| **WASM POC 模板（本文）** | `output.wasm` | `_start` / `func main` | 需要跨语言、二进制分发、强签名管控的检测 |
| 应用插件 | `scan.wasm`（c-shared） | `应用_` 前缀导出函数 | 常驻钩住任务/流量/MITM |

## 二、适用边界（重点）：`scan_*` 与 `app_*` 互不通用

扫描节点为两类 WASM 模块注入了**两套完全不重叠**的宿主函数。**`scan_*` 这组宿主函数只在「POC 模板执行」的调用路径上注入**。

| 维度 | WASM POC 模板（`scan_*`） | 应用插件（`app_*`） |
|---|---|---|
| 宿主函数命名空间 | `scan_http` / `scan_log` / `scan_report` / `scan_call` / `scan_t` / `scan_config` / `scan_set_timeout` | `app_log` / `app_send_tcp` / `app_send_tcp_json` / `app_tcp_received_data` / `app_call` / `app_t` / `app_config` / `app_node_info` / `app_set_timeout` |
| 入口 | `_start`（`func main`），一次处理一个流量包 | `应用_` 前缀的**导出函数**，按钩子调用 |
| 构建方式 | 普通 wasip1 命令模块 | **必须 `-buildmode=c-shared`** |
| 生效场景 | **仅** POC 模板列表中 `PocType=wasm` 的条目 | 插件商店安装的扫描节点应用插件 |
| 目录 | `poc/<lang>/wasm/<VulnIde>/` | `poc/plugin/<uuid>/` |
| SDK 文件 | `scan.go` | `app.go` |
| 场景 | 漏洞检测 | 任务预处理 / 流量处理 / TCP 指令扩展 |

> 表中的两个 SDK 文件（`scan.go` / `app.go`）都在你下载的官方 SDK 包内，按目标语言各取一份即可。

**判断方法**：你的产物在 POC 模板列表里**作为一条漏洞**被扫描 → 用 `scan.*`；在插件商店 / 应用插件目录里**作为常驻插件**跑 → 用 `app.*`。

> [!WARNING]
> 把 `scan.HTTP(...)` 写进应用插件、或把 `app.SpawnTool(...)` 写进 WASM POC，都会因为宿主没有导出对应函数而**实例化失败**（`instantiate module: ... unknown import`）。这不是运行时才报错，而是插件根本跑不起来。同理，控制器插件用 `ctl_*`、AiAgent 外部工具用 stdin/stdout JSON——四套 SDK 互不通用。

**三选一：YAML / Go 热加载 / WASM 怎么选**

| 你的情况 | 推荐 |
|---|---|
| 请求 + 匹配规则 + 正则 / 耗时 / 反连能表达 | **YAML 模板**（首选，无需 SDK、无需构建） |
| 需要复杂算法、循环、自有解析，且团队写 Go | **Go 热加载 POC**（`import "scan"`，进程内解释执行） |
| 需要跨语言实现、二进制分发、强签名管控、体积/性能敏感 | **WASM POC**（本文） |

## 三、入口在哪：从 POC 模板列表到 `_start` 被调用

这一章回答第三方开发者问得最多的问题：**「我写的 `func main()` 到底是谁调用的？中间经过了什么？为什么我本地跑得好好的、上传后却不动？」** 下面按真实链路分步讲。

### 3.1 完整链路（分步）

1. **在 GUI 选择 WASM 类型**：进入「添加漏洞 / POC 模版列表」，新建/编辑一条漏洞，把模板类型选为 **WASM**。这一步决定该漏洞最终会被写成 `PocType: "wasm"`，走的是本文这条执行路径（而不是 YAML 或 Go 热加载）。
2. **源码进入构建服务编译**：你把源码（`source/`，多文件，含 SDK 文件）连同元数据提交给 TestSecVulnService 的构建服务（GUI 上传构建入口）。构建服务按 `meta.yaml.SourceLanguage`（`go` / `c` / `cpp` / `rust` / `tinygo`）选择工具链，产出 **WASI 命令模块** `output.wasm`。关键要求：产物必须导出 **`_start`**（Go 的 `package main` 自动提供；不要用 `-buildmode=c-shared`）。
3. **Ed25519 签名 `sig.json`**：构建成功后，服务端用控制器上传的**私钥**对 `output.wasm` 字节做 Ed25519 签名，写入 `sig.json`（`userSignature` / `userPubkey`），或由 POC 市场公钥给出 `serverSignature` / `serverPubkey`。最终 `signStatus` 会落成 `verified` 或 `official`。**没有有效签名的产物后面不会被加载**（见第二章与第十一章）。
4. **控制器加载**：控制器把该 POC 从 POC 市场/上传目录同步到自己的 `poc/<lang>/wasm/<VulnIde>/` 目录（`<lang>` 为语言目录，如 `cn`；`<VulnIde>` 是漏洞唯一标识符），并解析 `meta.yaml` 生成一条 `PocType="wasm"` 的漏洞条目。
5. **任务下发到扫描节点**：启动扫描任务时，控制器把命中的漏洞列表（含 WASM 类型条目及其模板目录）随任务一起下发给扫描节点。
6. **节点只加载 `verified` 的插件**：扫描节点拿到模板目录后，先读 `meta.yaml` + `output.wasm` + `sig.json` 并**重新验签**；只有 `signStatus` 为 `verified` 的插件才会进入执行，其余记一条日志后跳过（连编译都不会做）。
7. **批量流式执行**：对每个通过验签的插件，节点**加载一次、编译一次**，然后依次对该插件跑完**全部去重流量包**，最后卸载，再处理下一个插件。同一时刻内存里只有一个插件的编译产物。
8. **单次执行的动作**（每次一个流量包）：
   1. 把该流量包序列化成 JSON，写入一个临时文件，作为 WASM 的 **stdin**（同时把 `Flow` 各字段设成 `SCAN_*` 环境变量作兜底）；
   2. 新建一个**全新模块实例**（重置 wasm 全局状态）并完成初始化；
   3. 取导出函数 **`_start`** —— 这**就是你的 `func main()`**；
   4. 调用 `_start()`，你的代码开始跑：`scan.LoadFlow()` 读 stdin → `scan_*` 主机函数做检测 → `scan.Report` / `scan.Log` 回传；
   5. `_start` 返回后，宿主收集本次执行的漏洞发现、日志与请求计数，再兜底解析 stdout 的 `[LOG]` / `[FIND]` 行，最后销毁该模块实例。
9. **结果回传**：本次执行的漏洞发现上报控制器（会做 404 基线误报闸门与去重），日志进任务日志，请求计数计入进度。

> [!IMPORTANT]
> **`func main()` 就是入口**。宿主不会调用你其它任何函数，也**不需要你自己去读 stdin 以外的东西**——流量包 JSON 已经放在 stdin 里，`scan.LoadFlow()` 已经帮你读好并填进 `scan.Flow`。你只要在 `main` 里用 `scan.Flow.*` 取值、用 `scan.*` 发请求/上报即可。

> [!NOTE]
> **每次执行都是一次全新实例**。宿主对每个流量包都新建一个模块实例，wasm 全局变量会被重置，因此：同一个插件实例内的 **Cookie 会话**是贯穿它的**整个批次**的（源于宿主为插件实例维护的 Cookie 会话，而非 wasm 全局状态）；而你自己在 wasm 里定义的包级变量，下一个流量包执行时**不会保留**。

## 四、目录结构与 `meta.yaml` / `sig.json`

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

各文件作用：

| 文件 / 目录 | 作用 | 必需 |
|---|---|---|
| `source/` | 人类可读的源码（Go/C/C++/Rust/TinyGo 均可），随包分发便于审计 | 建议 |
| `output.wasm` | 真正被加载执行的文件，`_start` 为入口 | **必需** |
| `meta.yaml` | 漏洞元数据，加载后成为一条 `PocType="wasm"` 的漏洞条目 | **必需** |
| `sig.json` | 签名与验签状态，**无有效签名不执行** | **必需** |
| `plugin.config.json` | 键值配置，插件内 `scan.ConfigGet(key)` 读取 | 可选 |

### 4.1 `meta.yaml` 逐字段表

与 YAML POC 共用同一套多语言约定：`Languages` + `DefaultLanguage` 为**唯一事实源**，平面字段（`Name`/`Description` 等）是运行时解析字段。下表每个字段都给出「类型 / 必填 / 含义 / 示例值 / 写错后果」。

| 字段 | 类型 | 必填 | 含义 | 示例值 | 写错后果 |
|---|---|---|---|---|---|
| `VulnIde` | string | 是 | 漏洞唯一标识符，**全局唯一** | `048d825b807f4215a705d42a18dba527` | 与别的漏洞撞号 → 覆盖/去重异常；为空则回退用目录名，可能对不上控制器索引 |
| `Name` | string | 是 | 漏洞名称（平面字段，可被 `Languages` 覆盖） | `WASM-go-反射型XSS检测` | 留空则漏洞列表/卡片名称空白 |
| `Languages` | map | 否 | 多语言文本映射（键→文本），唯一事实源 | `{cn: {...}, en: {...}}` | 键与 `DefaultLanguage` 对不上会回退平面字段；只写中文会导致英文界面显示中文 |
| `DefaultLanguage` | string | 否 | 默认语言键（如 `cn` / `en`） | `cn` | 缺失回退 `cn`；写不存在的语言键会取不到文本 |
| `Level` | string | 是 | 危害等级 `critical` / `high` / `medium` / `low` | `high` | 写成 `High`/`严重` 等非法值 → 等级映射失败，漏洞卡片等级异常 |
| `PocType` | string | 是 | 固定 `wasm`，决定走这条执行路径 | `wasm` | 写错（如 `yaml`）→ 根本不会进 WASM 执行链路 |
| `CVEId` | string | 否 | CVE 编号 | `CVE-2021-44228` | 无影响（可空） |
| `CweId` | string | 否 | CWE 编号 | `CWE-79` | 无影响（可空） |
| `CvssScore` | string | 否 | CVSS 评分 | `9.8` | 无影响（可空） |
| `CvssVector` | string | 否 | CVSS 向量 | `AV:N/AC:L/...` | 无影响（可空） |
| `SourceLanguage` | string | 是 | 源码语言 `go` / `c` / `cpp` / `rust` / `tinygo`，构建服务据此选工具链 | `go` | 与 `source/` 实际语言不符 → 构建失败或产物不可用 |
| `Description` | string | 否 | 漏洞描述 | `反射型 XSS 载荷回显检测` | 留空则详情页无描述 |
| `Fingerprint` | string | 否 | 指纹 | — | 无影响 |
| `AffectedProducts` | string | 否 | 影响产品 | — | 无影响 |
| `References` | string | 否 | 参考链接 | — | 无影响 |
| `Solution` | string | 否 | 修复建议 | — | 无影响 |
| `Confidence` | int | 否 | 置信度（0~100） | `80` | 越界可能被展示为异常值 |
| `Author` | string | 否 | 作者 | `TestSecScan` | 无影响 |
| `Enabled` | bool | 否 | 是否启用 | `true` | `false` 则该模板不下发 |

最小可用 `meta.yaml`：

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

> [!TIP]
> `TestDepth` / `Verification` / `AddTime` 等是构建/控制器侧的附加字段（真实市场包 `meta.yaml` 里会带），第三方手写模板按上表最小集即可；不确定时以构建服务导出的 `meta.yaml` 为准。

### 4.2 `sig.json` 逐字段表

| 字段 | 类型 | 必填 | 含义 | 示例值 | 写错后果 |
|---|---|---|---|---|---|
| `userPubkey` | string | 视签名方式 | **私有签名**公钥（base64，控制器本地 `controller_ed25519.pub`） | `MFkwEwYH...` | 截断/非 base64 → 验签 `verify_failed`，不加载 |
| `userSignature` | string | 视签名方式 | 用上述私钥对 `output.wasm` 字节的 Ed25519 签名（base64） | `PZydTvki...` | 手改一个字 → 验签失败 |
| `serverPubkey` | string | 视签名方式 | **官方签名**公钥（base64，POC 市场公钥 `TestSecScanPoc.pub`） | `+napZUOk...` | 官方路径验不过，回退私有路径或判失败 |
| `serverSignature` | string | 视签名方式 | 官方对 `output.wasm` 字节的 Ed25519 签名 | `PZydTvki...` | 同上 |
| `wasmHash` | string | 建议 | `output.wasm` 的 SHA-256 十六进制摘要 | `2ce8970f6e30...` | 与 `output.wasm` 不匹配说明产物被换过；验签流程会失败 |
| `signStatus` | string | 是 | 验签状态（见下表） | `verified` | 不是 `verified`/`official` → 节点**直接跳过** |
| `updateTime` | string | 否 | 签名/更新时间 | `2026-08-15T21:05:34+08:00` | 无影响 |

`signStatus` 取值：

| 值 | 含义 | 是否可执行 |
|---|---|---|
| `verified` | 验签通过（官方或私有任一路径） | ✅ |
| `official` | 验签来源为 POC 市场公钥 | ✅ |
| `unsigned` | 无签名 | ❌ |
| `verify_failed` | 有签名但验签失败（视为被篡改） | ❌ |

> [!CAUTION]
> 验签**只认宿主持有的公钥**（官方优先于私有），绝不信任包内自带的公钥。自签密钥对会被判为 `verify_failed`，**根本不会进入执行**——不是「看到了但拒绝执行」。**只执行 `verified`** 是硬门槛。`signStatus` 的判定取值就是 `verified` / `verify_failed` / `unsigned`，扫描节点只放行 `verified`。

## 五、快速开始（Go，完整可运行）

### 5.1 工程结构

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

`go.mod`：

```text
module my-wasm-poc

go 1.22

require scan v0.0.0

replace scan => ./scan
```

> `scan/` 目录里放的就是官方 SDK 包里的 `scan.go`（带 `//go:build wasm`，只在 `GOARCH=wasm` 时参与编译）。它的 `package scan` 名与你的 `import "scan"` 通过 `replace` 对齐。

### 5.2 `main.go`

```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 编译

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

产物必须是 **WASI 命令模块**并导出 `_start`（Go 的 `package main` 会自动提供）。**不要**用 `-buildmode=c-shared`——那是应用插件的构建方式。

### 5.4 放置与签名

按第四章目录结构放置 `output.wasm` / `meta.yaml` / `sig.json`，然后通过 GUI 的「签名」入口或 TestSecVulnService 构建服务完成签名，使 `sig.json.signStatus` 变为 `verified`。未签名的产物不会被扫描节点加载。

### 5.5 调试

- 在 GUI 的 POC「测试验证 / 调试」面板里选择目标 URL 或流量包运行，可看到 `scan.Log` 输出、`scan.Report` 的发现、真实请求/响应；
- 调试面板等价于扫描节点用单个 `Flow` 跑一次单包执行，因此结果与线上一致。详细步骤见第十章「调试手册」。

## 六、公开 API 逐函数精讲

以下符号全部来自官方 SDK 包内的 `scan.go`。每个小节依次给出：**作用 → 签名 → 参数表 → 返回 → 代码片段 → 注意事项**。代码片段可直接复制进你的 POC 工程。

### 6.1 类型与全局

#### `Flow`

**作用**：`Flow` 类型描述「当前被投喂的这一个流量包」。它是被动扫描模型的输入，**在使用任何字段前必须先调用 `scan.LoadFlow()`**。SDK 同时声明了一个同名的包级全局变量 `var Flow Flow`，你读取得用 `scan.Flow.XXX`。

**签名**：

```go
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"`
}
```

**字段表**：

| 字段 | 类型 | 含义 | 从哪来 / 什么格式 | 怎么用 |
|---|---|---|---|---|
| `URL` | string | 完整请求 URL | 宿主编排侧把流量包序列化为 JSON 后写 stdin，字段名 `url` | 最常用：直接拿来做检测目标 `scan.Flow.URL`；为空说明没拿到流量包 |
| `Method` | string | 请求方法 | 同一 JSON，字段名 `method`，值为 `GET`/`POST`/`PUT`… 大写 | 复刻原请求方法，如 `scan.HTTP(scan.Flow.Method, u, "")` |
| `Host` | string | 目标主机名 | JSON 字段 `host`，形如 `example.com:8080`（可能带端口） | 拼接虚拟主机头、按域名做过滤 |
| `Scheme` | string | 协议 | JSON 字段 `scheme`，值为 `http` 或 `https` | 拼 URL、判断是否 https |
| `Path` | string | 请求路径 | JSON 字段 `path`，以 `/` 开头，不含查询串 | 替换路径、按后缀过滤（如只处理 `.php`） |
| `Query` | string | 查询参数 | JSON 字段 `query`，**不含前导 `?`** | 追加参数时判断用 `?` 还是 `&` |
| `Headers` | map[string]string | 请求头 | JSON 字段 `headers`，**key 已小写** | 读 `scan.Flow.Headers["cookie"]`、`["content-type"]`；注意 key 是小写 |
| `Body` | string | 请求体 | JSON 字段 `body`，原样文本（可能是表单/JSON） | 从 POST 体提取参数值再改写 |
| `RawHeaders` | string | 原始请求头文本 | JSON 字段 `rawHeaders`，多行文本（每行 `Name: Value`） | 需要保留原始大小写/顺序时用；普通场景用 `Headers` |

**代码片段**：

```go
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)
}
```

**注意事项**：
1. **不调用 `LoadFlow()` 就字段全空**——`scan.Flow.URL == ""`，后面的请求全部打不出或打到空 URL。这是最坑的一条。
2. `Headers` 的键是**小写**，写成 `scan.Flow.Headers["Cookie"]` 取不到值。
3. 全局变量与类型同名（都叫 `Flow`），是包级 `scan.Flow` 变量；`LoadFlow()` 每次从 stdin 读一次并覆盖它。

#### `HTTPResponse`

**作用**：`scan.HTTP` / `scan.HTTPGet` / `scan.HTTPPost` / `scan.HTTPJSONPost` 的返回结构，承载一次 HTTP 请求的结果。判断请求成败、取响应体做特征匹配都靠它。

**签名**：

```go
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"`
}
```

**字段表**：

| 字段 | 类型 | 含义 | 怎么用 |
|---|---|---|---|
| `StatusCode` | int | HTTP 状态码；**0 表示请求根本没成功** | 先判 `Error == ""`，再看是否等于期望值（如 `200`） |
| `Body` | string | 响应体文本，宿主**最多读 1MB**，超出被丢弃 | 做关键字/正则/JSON 判定，如 `strings.Contains(resp.Body, ...)` |
| `Headers` | map[string]string | 响应头，**key 小写**，多值用 `, ` 连接 | `resp.Headers["content-type"]`、`resp.Headers["server"]` |
| `Cookies` | []string | 响应 `Set-Cookie` 的原始字符串列表 | 需要显式读某个 cookie 值时遍历；会话由宿主自动维护 |
| `URL` | string | 最终请求 URL（**含重定向后的地址**） | 判断是否被重定向到登录页 |
| `TimeMs` | int64 | 本次请求耗时（毫秒） | 时间盲注类判定、日志性能信息 |
| `Error` | string | 失败原因；**成功为空串** | **务必先判 `resp.Error == ""` 再判状态码** |

**代码片段**：

```go
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})
}
```

**注意事项**：
1. **不判 `resp.Error` 直接读 `Body`**：连接失败时 `Body` 是空串，容易被误判成「无漏洞」而漏报或静默。
2. 响应体上限 **1MB**，尾部特征可能取不到；判定尽量靠前，或改用流式特征。
3. `Headers` 键是小写，且多值被合并成一个字符串，不要当数组用。

#### `VulnFinding`

**作用**：`scan.Report` 的上报结构。命中漏洞时构造它并上报，GUI 就会生成一张漏洞卡片；`SensitiveText` 等三字段专供「敏感信息发现」类漏洞高亮。

**签名**：

```go
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"` // 实际匹配的公式/规则
}
```

**字段表**：

| 字段 | 类型 | 含义 | 怎么用 |
|---|---|---|---|
| `Name` | string | 漏洞名称（卡片标题） | 填写中文默认名或 `scan.T("vuln_name")` 取语言包；**留空卡片会空白** |
| `Detail` | string | 漏洞详情（卡片正文） | 说明命中的证据与判定逻辑 |
| `Evidence` | string | 命中证据（响应片段 / 数据包文本） | 建议截断到几百字，如 `resp.Body[:min(len(resp.Body), 500)]` |
| `Level` | string | 等级 `critical` / `high` / `medium` / `low` | **取值必须精确**；留空则回退用模板 `meta.yaml.Level` |
| `URL` | string | 命中 URL | **留空则用当前流量包 `Flow.URL`**；跨目标检测时建议显式填 |
| `CVEId` | string | 可选 CVE 编号 | 留空用模板元数据 |
| `Request` | string | 可选：触发请求报文（文本） | 供 GUI 展示原始请求 |
| `Response` | string | 可选：命中响应报文（文本） | 供 GUI 展示原始响应，建议截断 |
| `SensitiveText` | string | 敏感信息高亮：原始文本片段 | 敏感信息类漏洞专用，取命中内容前后各 200 字符 |
| `SensitiveKeywords` | []string | 敏感信息高亮：命中的关键字列表 | GUI 纯文本查看器据此高亮命中位置 |
| `SensitiveMatchRule` | string | 敏感信息高亮：匹配的公式/规则 | 展示判定依据 |

**代码片段**：

```go
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\")",
	})
}
```

**注意事项**：
1. **`Level` 写错**（如 `High`、`高危`）会导致等级映射失败，卡片等级异常；务必用四个小写英文值。
2. `Name` / `Detail` 留空，卡片标题/正文空白，看起来像「上报了但没内容」。
3. `Evidence` / `Response` 不做长度截断会撑大上报体；建议统一截断。

### 6.2 主机 API

#### `LoadFlow()`

**作用**：从 stdin 读取当前流量包 JSON 并填充全局 `scan.Flow`；**这是插件与宿主交换输入的唯一入口**，必须在读 `scan.Flow.*` 之前调用。

**签名**：

```go
func LoadFlow()
```

**参数表**：无参数。

**返回**：无返回。它直接把解析结果写进包级变量 `scan.Flow`（解析失败时 `Flow` 保持零值）。

**代码片段**：

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

**注意事项**：
1. **必须第一件事就调用**；漏掉则 `scan.Flow` 全为零值，检测逻辑静默失效。
2. 同一实例内重复调用会覆盖 `Flow`（通常不需要）。
3. 它只读 stdin，不读环境变量——`SCAN_*` 是宿主给的兜底，SDK 不替你解析。

#### `SetTimeout(ms int64)`

**作用**：动态设置本插件（本批次）的执行超时，替代默认的 30s。适合需要长轮询、多步请求的插件。

**签名**：

```go
func SetTimeout(ms int64)
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `ms` | int64 | 本插件最大运行毫秒数，必须 > 0 | 你按检测复杂度自行估算 | `60000`（60 秒） |

**返回**：无返回。

**代码片段**：

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

**注意事项**：
1. 它重置的是**整个批次**的超时定时器，不是「每个流量包各一份」；批次内要留足总量。
2. 传 `0` 或负数会被宿主忽略，仍按默认 30s。
3. 设太大而插件卡死，会长时间占住该插件的批次时长，挤占任务时间。

#### `T(key string) string`

**作用**：取**当前语言**下**当前插件**的语言包文本，用于把界面/日志文案做成中英双语（四端语言包的一部分）。

**签名**：

```go
func T(key string) string
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `key` | string | 语言包里的键（**不含插件 ID 前缀**） | 你的语言包文件 | `vuln_name` |

**返回**：string。宿主内部把键拼成 `<当前语言>.<插件ID>.<key>` 去查；查不到时回退返回 `key` 本身（所以失败不会报错，而是原样显示键名）。

**代码片段**：

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

**注意事项**：
1. 只传 `key`，**不要再传插件 ID 或语言**——宿主已自动补 `<语言>.<插件ID>.`。
2. 语言包缺失时返回的是**键名本身**（如 `vuln_name`），不是空串；别把它当「有值」来判断。
3. 键空间含插件 ID，因此不同插件的同名 key 互不冲突。

#### `ConfigGet(key string) string`

**作用**：读取当前插件的配置值，用于把可调参数（开关、阈值）从代码里抽出来。

**签名**：

```go
func ConfigGet(key string) string
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `key` | string | 配置项的键 | `plugin.config.json`（Key/Value，值均为字符串） | `enable_deep` |

**返回**：string。键不存在或配置为空时返回**空串**。

**代码片段**：

```go
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"
}
```

**注意事项**：
1. 取不到返回空串，**不会报错**；要用空串判断并给默认值。
2. 配置值全是字符串，数字需自己转换（如 `== "1"` 而不是 `== 1`）。
3. 宿主在**加载插件时**读取配置；调试面板单跑时若没打包配置文件，可能读不到值。

#### `HTTP(method, url, body string) HTTPResponse`

**作用**：插件访问网络的**唯一出口**。需要非 GET、或需要自定义方法/请求体时用它；底层经任务代理、限时、限大小。

**签名**：

```go
func HTTP(method, url, body string) HTTPResponse
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `method` | string | HTTP 方法（大写） | 你指定或用 `scan.Flow.Method` | `"POST"` |
| `url` | string | 完整目标 URL（含 scheme） | 你构造，或基于 `scan.Flow.URL` | `"https://a.com/api"` |
| `body` | string | 请求体原始文本；GET 传 `""` | 你构造 | `{"user":"admin"}` |

**返回**：`HTTPResponse`（逐字段见 6.1）。请求失败时 `StatusCode=0`、`Error` 非空、`Body` 为空。

**代码片段**：

```go
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)],
	})
}
```

**注意事项**：
1. **不能自定义请求头**——SDK/宿主都不提供设置 Header 的入口。需要特定头时，把信息放进 URL/查询串或请求体。
2. 响应体上限 1MB，超出被截断。
3. 方法名不合法或 URL 不可解析时，返回 `Error` 而非 panic，务必判错误。

#### `HTTPGet(url string) HTTPResponse`

**作用**：GET 请求快捷封装，等价 `HTTP("GET", url, "")`。最常用的探测方式。

**签名**：

```go
func HTTPGet(url string) HTTPResponse
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `url` | string | 完整目标 URL | 你构造 | `"https://a.com/robots.txt"` |

**返回**：`HTTPResponse`，同 `HTTP`。

**代码片段**：

```go
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})
}
```

**注意事项**：
1. 自动跟随重定向（最多 10 次），`resp.URL` 是最终地址。
2. 等价于 `body=""`，不要指望它发 POST。
3. 无 TLS 跳过选项，自签名目标会以 `resp.Error` 体现。

#### `HTTPPost(url, body string) HTTPResponse`

**作用**：POST 请求快捷封装，等价 `HTTP("POST", url, body)`。

**签名**：

```go
func HTTPPost(url, body string) HTTPResponse
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `url` | string | 完整目标 URL | 你构造 | `"https://a.com/login"` |
| `body` | string | 请求体原文 | 你构造 | `user=admin&pwd=admin` |

**返回**：`HTTPResponse`，同 `HTTP`。

**代码片段**：

```go
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))
}
```

**注意事项**：
1. 宿主不会自动加 `Content-Type`；表单/JSON 的编码要你自己在 body 里体现。
2. 同样是自动 Cookie 会话的一部分，登录态会保留。
3. body 为空时退化为一个空体 POST，注意区分。

#### `HTTPJSONPost(url, body string) HTTPResponse`

**作用**：语义上的「POST JSON」，让代码可读性更好。

**签名**：

```go
func HTTPJSONPost(url, body string) HTTPResponse
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `url` | string | 完整目标 URL | 你构造 | `"https://a.com/api/user"` |
| `body` | string | JSON 文本 | 你构造 | `{"id":1}` |

**返回**：`HTTPResponse`，同 `HTTP`。

**代码片段**：

```go
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)
}
```

**注意事项**：
1. **实现上等价于 `HTTPPost`**：宿主并未额外注入 `Content-Type: application/json` 头（SDK 注释如是描述，但宿主并未自动设置该头）。若目标强制要求该头，需自行确认或换用能表达的方式。
2. 无法自定义头这条同样适用。
3. 返回判定与 `HTTP` 完全一致。

#### `Log(msg string)`

**作用**：输出一条调试日志，**在 GUI 调试面板可见**。定位「插件到底跑没跑、走到哪一步」最直接的手段。

**签名**：

```go
func Log(msg string)
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `msg` | string | 任意可读文本 | 你构造 | `"开始检测: " + scan.Flow.URL` |

**返回**：无返回。日志被宿主收集，随本次执行的结果上抛到任务日志与调试面板。

**代码片段**：

```go
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))
```

**注意事项**：
1. 日志按批次累积、每次单包执行前重置；同一次执行内多次调用都会保留。
2. 别在超高频循环里打海量日志，会拖慢并撑爆日志面板。
3. 日志内容是纯文本，含换行会原样呈现，建议单行短句。

#### `Report(f VulnFinding)`

**作用**：上报一条漏洞发现（内部序列化为 JSON 后调用 `scan_report`）。命中的最终动作。

**签名**：

```go
func Report(f VulnFinding)
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `f` | `VulnFinding` | 一个填好的漏洞结构体 | 你构造（字段见 6.1） | `scan.VulnFinding{Name:"XSS", Level:"high"}` |

**返回**：无返回。宿主解析成功后追加到本次执行的发现列表并上抛；JSON 解析失败会被**静默丢弃**。

**代码片段**：

```go
// 一次执行可以上报多条
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})
```

**注意事项**：
1. `Report` 的字段**必须能被 JSON 正常序列化**；字段值本身没问题，但 `Level` 语义错误不会被这里拦截，而是影响卡片展示。
2. 同一执行上报多条会生成多张卡片/多条明细，注意去重语义（宿主会按漏洞 Hash 去重）。
3. 命中但 `Name` 为空，卡片标题空白——务必填名称。

### 6.3 内置函数封装

以下封装全部由**扫描节点主程序**实现（宿主内置的安全函数子集），插件只传参数，因此**编译体积更小、行为一致**。除特别说明外，**底层调用失败一律返回空串**，不 panic、不报错。

#### `Base64Encode(data string) string`

- **作用**：Base64 编码，用于构造 Basic 认证头、把二进制变成可放进 URL/JSON 的文本。
- **参数**：`data` string，待编码原文；示例 `"admin:admin"`。
- **返回**：string，编码结果；输入为空/底层失败返回空串。

```go
enc := scan.Base64Encode("admin:admin") // "YWRtaW46YWRtaW4="
scan.Log("Authorization: Basic " + enc)
```

- **注意**：底层失败返回空串（不报错），别把它当「有内容」判断。

#### `Base64Decode(data string) string`

- **作用**：Base64 解码，还原被编码的载荷/凭据。
- **参数**：`data` string，合法 Base64 文本；示例 `"YWRtaW46YWRtaW4="`。
- **返回**：string，解码后的原文；非法输入返回空串。

```go
plain := scan.Base64Decode("YWRtaW46YWRtaW4=") // "admin:admin"
if plain == "" {
	scan.Log("解码失败或原文为空")
}
```

- **注意**：解码失败与「原文本来就是空」都返回空串，无法区分。

#### `HexEncode(data string) string`

- **作用**：把字节串转成十六进制文本（无空格），便于比较/展示二进制。
- **参数**：`data` string，任意字节串。
- **返回**：string，十六进制小写串；失败返回空串。

```go
scan.Log(scan.HexEncode("AB")) // "4142"
```

- **注意**：按字节编码，中文字符会变成 6 个十六进制字符而非 2 个。

#### `HexDecode(hex string) string`

- **作用**：十六进制文本还原为字节串。
- **参数**：`hex` string，偶数长度的十六进制文本。
- **返回**：string，还原后的字节串；奇数长度/非法字符返回空串。

```go
scan.Log(scan.HexDecode("4142")) // "AB"
```

- **注意**：长度必须为偶数，否则解码失败返回空串。

#### `URLEncode(input string) string`

- **作用**：URL 编码，把参数/载荷安全放进查询串或路径。
- **参数**：`input` string，原始文本。
- **返回**：string，编码结果；失败返回空串。

```go
scan.Log(scan.URLEncode("a b&c")) // "a+b%26c"
u := scan.Flow.URL + "?q=" + scan.URLEncode("'; SELECT 1--")
```

- **注意**：空格被编成 `+`，某些场景目标要求 `%20`，注意差异。

#### `URLDecode(input string) string`

- **作用**：URL 解码，还原响应/请求里被编码的参数。
- **参数**：`input` string，URL 编码文本。
- **返回**：string，解码结果；失败返回空串。

```go
scan.Log(scan.URLDecode("a%20b%26c")) // "a b&c"
```

- **注意**：非法百分号序列会失败返回空串。

#### `HTMLEncode(data string) string`

- **作用**：HTML 实体编码，构造需要转义的 XSS 载荷或展示文本。
- **参数**：`data` string，原始文本。
- **返回**：string，实体编码结果；失败返回空串。

```go
scan.Log(scan.HTMLEncode("<script>")) // "&lt;script&gt;"
```

- **注意**：它是实体编码，不要误当成 URL 编码。

#### `HTMLDecode(data string) string`

- **作用**：HTML 实体解码，还原响应里的实体文本再匹配。
- **参数**：`data` string，含 HTML 实体的文本。
- **返回**：string，解码结果；失败返回空串。

```go
scan.Log(scan.HTMLDecode("&lt;title&gt;")) // "<title>"
```

- **注意**：匹配前先解码，能避免实体绕过导致的漏判。

#### `CharsetConvert(data, targetEncoding string, autoDetect bool) string`

- **作用**：字符编码转换（如把 GBK 响应转成 UTF-8），中文目标站常见。
- **参数**：`data` string 待转换字节；`targetEncoding` string 目标编码（如 `"UTF-8"`）；`autoDetect` bool 是否自动识别源编码。
- **返回**：string，转换后的文本；失败返回空串。

```go
body := scan.CharsetConvert(resp.Body, "UTF-8", true)
if strings.Contains(body, "管理后台") {
	scan.Log("命中中文关键字（已转码）")
}
```

- **注意**：`autoDetect` 传 `true` 更省心；源编码识别错会导致乱码，判定前可先 `scan.Log` 看看。

#### `MD5(data string) string`

- **作用**：计算 MD5（十六进制小写），用于指纹/签名匹配。
- **参数**：`data` string，待哈希原文。
- **返回**：string，32 位十六进制小写串；失败返回空串。

```go
if scan.MD5(resp.Body) == "098f6bcd4621d373cade4e832627b4f6" {
	scan.Log("MD5 指纹命中")
}
```

- **注意**：比对时大小写要一致（SDK 输出小写）。

#### `SHA1(data string) string`

- **作用**：计算 SHA1，用于某些指纹/签名校验。
- **参数**：`data` string。
- **返回**：string，40 位十六进制；失败返回空串。

```go
scan.Log(scan.SHA1("hello"))
```

- **注意**：输出小写十六进制；`SHA1` 已不适用于安全强度场景，仅用于匹配既有指纹。

#### `SHA224(data string) string`

- **作用**：计算 SHA224。
- **参数**：`data` string。
- **返回**：string，56 位十六进制；失败返回空串。

```go
scan.Log(scan.SHA224("hello"))
```

- **注意**：输出小写，长度 56。

#### `SHA256(data string) string`

- **作用**：计算 SHA256，最常用的内容指纹。
- **参数**：`data` string。
- **返回**：string，64 位十六进制；失败返回空串。

```go
sum := scan.SHA256(resp.Body)
scan.Log("SHA256=" + sum)
```

- **注意**：与 `WasmHash`（宿主对 `output.wasm` 的 SHA-256）概念不同，别混淆。

#### `SHA384(data string) string`

- **作用**：计算 SHA384。
- **参数**：`data` string。
- **返回**：string，96 位十六进制；失败返回空串。

```go
scan.Log(scan.SHA384("hello"))
```

- **注意**：输出小写十六进制。

#### `SHA512(data string) string`

- **作用**：计算 SHA512。
- **参数**：`data` string。
- **返回**：string，128 位十六进制；失败返回空串。

```go
scan.Log(scan.SHA512("hello"))
```

- **注意**：输出小写十六进制。

#### `CRC32(data string) string`

- **作用**：计算 CRC32 校验值（十六进制），用于轻量一致性判断。
- **参数**：`data` string。
- **返回**：string，十六进制校验值；失败返回空串。

```go
scan.Log(scan.CRC32("hello"))
```

- **注意**：CRC 非加密哈希，不可用于安全校验。

#### `CRC64(data string) string`

- **作用**：计算 CRC64 校验值（十六进制）。
- **参数**：`data` string。
- **返回**：string，十六进制校验值；失败返回空串。

```go
scan.Log(scan.CRC64("hello"))
```

- **注意**：与 `CRC32` 同为非加密校验。

#### `RandStr(mode, length int) string`

- **作用**：生成随机串，用于构造唯一标记/无回显探测的 payload。
- **参数**：`mode` int 位标志（1 小写 / 2 数字 / 4 大写 / 8 特殊，可组合）；`length` int 长度。
- **返回**：string，随机串；失败返回空串。

```go
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})
}
```

- **注意**：`mode` 是位或组合，传 `0` 可能得到空串；长度过大可能被下游限制。

#### `LeftOf(s, sep string) string`

- **作用**：取分隔符**左侧**内容（含不匹配时的原文处理），从响应里截取字段。
- **参数**：`s` string 源文本；`sep` string 分隔符（如 `"\"token\":\""`）。
- **返回**：string，分隔符左边的内容；找不到分隔符时的行为由底层决定（可能返回空串/原文）。

```go
token := scan.LeftOf(resp.Body, "\"token\":\"")
scan.Log("token 前缀: " + token)
```

- **注意**：参数顺序是 `(s, sep)`，别写反；`sep` 含引号/转义要写完整。

#### `RightOf(s, sep string) string`

- **作用**：取分隔符**右侧**内容。
- **参数**：`s` string 源文本；`sep` string 分隔符。
- **返回**：string，分隔符右边的内容。

```go
val := scan.RightOf(resp.Body, "\"token\":\"")
scan.Log("token 之后: " + val)
```

- **注意**：从**第一个**匹配处切分；多匹配场景结果可能不是你以为的那段。

#### `MiddleOf(s, left, right string) string`

- **作用**：取左右两个标记**之间**的内容，最常用的截取手段。
- **参数**：`s` string 源文本；`left` string 左标记；`right` string 右标记。
- **返回**：string，两标记之间的内容；任一标记缺失通常返回空串。

```go
title := scan.MiddleOf(resp.Body, "<title>", "</title>")
scan.Log("页面标题: " + title)
```

- **注意**：按顺序找左标记再找右标记，跨行/嵌套需自行处理。

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

**作用**：比 `MiddleOf` 更精细的「取两文本之间内容」，可指定起始搜索位置与偏移，并返回**位置**。

**签名**：

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

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `source` | string | 源文本 | `resp.Body` 等 | `<title>首页</title>` |
| `start` | string | 起始标记 | 你指定 | `"<title>"` |
| `end` | string | 结束标记 | 你指定 | `"</title>"` |
| `startPosition` | int | 从源文本第几个字节开始找 | 你指定（首次传 0） | `0` |
| `offset` | string | 结果偏移量（字符串形式） | 你指定 | `""` |
| `fallback` | bool | 找不到时是否回退到源文本 | 你指定 | `true` |

**返回**：`(int, string)` 两个返回值——**`pos` 是命中位置（int），`text` 是截取内容（string）**。必须两个都接，或至少用 `_` 忽略一个。失败时 `text` 通常为空、`pos` 为 0 或 -1（视底层）。

**代码片段**：

```go
// 两个返回值：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})
}
```

**注意事项**：
1. **两个返回值别只接一个**：`pos, text := ...` 是正确写法；写成 `text := scan.TextBetween(...)` 会编译不过。
2. `pos` 是 int，要放进字符串日志得先转换（如上面的 `TimestampToStr`）。
3. `fallback=true` 时找不到也可能回退原文，判定前最好确认 `text` 真的在标记之间。

#### `JSONGet(data []byte, path string) string`

**作用**：按路径从 JSON 响应里**取字符串值**，做字段级判定最方便。

**签名**：

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

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `data` | []byte | JSON 原始字节 | `[]byte(resp.Body)` | `[]byte(resp.Body)` |
| `path` | string | 路径表达式，支持 `a.b[0].c` | 你按响应结构写 | `"data.user.name"` |

**返回**：string，取到的值（字符串化）；**路径不存在或取不到时返回空串**。

**代码片段**：

```go
// 注意第一个参数是 []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)
```

**注意事项**：
1. **第一个参数是 `[]byte`**，不能直接传 `resp.Body`（string），要 `[]byte(resp.Body)`。
2. 路径不存在**返回空串**，不报错；用空串判断即可，但要放多个候选路径兜底。
3. 路径语法支持点号与 `[n]` 下标，如 `a.b[0].c`；字段名含特殊字符时可能取不到。

#### `JSONGetRaw(data []byte, path string) string`

**作用**：按路径从 JSON 里取**原始值**（保留 JSON 字面量，如对象/数组/带引号字符串），适合把子结构原样取出。

**签名**：

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

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `data` | []byte | JSON 原始字节 | `[]byte(resp.Body)` | `[]byte(resp.Body)` |
| `path` | string | 路径表达式 | 你写 | `"data.items"` |

**返回**：string，原始 JSON 片段（对象/数组仍是 JSON 文本）；取不到时返回空串（底层转换失败则空）。

**代码片段**：

```go
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)
}
```

**注意事项**：
1. 与 `JSONGet` 一样，第一个参数是 `[]byte`。
2. 取出来的是**原始 JSON 文本**，字符串会带引号，判断时注意。
3. 取不到时多返回空串；别直接对结果做 `json.Unmarshal` 而不判空。

#### `TimeToTimestamp(timeStr string, isMilli bool) int64`

**作用**：把时间字符串转成时间戳（整数），用于时间盲注/时间比较。

**签名**：

```go
func TimeToTimestamp(timeStr string, isMilli bool) int64
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `timeStr` | string | 时间字符串 | 你构造 | `"2026-10-05 12:00:00"` |
| `isMilli` | bool | 是否按毫秒输出 | 你指定 | `false`（秒） |

**返回**：int64 时间戳；解析失败返回 0（`json.Unmarshal` 失败时零值）。

**代码片段**：

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

**注意事项**：
1. `isMilli` 决定单位；秒和毫秒混用会算出离谱的差值。
2. 格式不符返回 0，用 0 判断失败。

#### `TimestampToStr(ts int64, isMilli bool) string`

**作用**：把时间戳转成可读时间字符串；也常被临时用来把整数转成字符串（如上面的日志例子）。

**签名**：

```go
func TimestampToStr(ts int64, isMilli bool) string
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `ts` | int64 | 时间戳 | `TimeToTimestamp` 结果或时间差 | `1759646400` |
| `isMilli` | bool | `ts` 是否为毫秒 | 你指定 | `true` |

**返回**：string，时间字符串；失败返回空串。

**代码片段**：

```go
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))
```

**注意事项**：
1. `isMilli` 与 `ts` 的实际单位要匹配，否则时间错乱。
2. 用它做「int 转 string」只是便捷写法，语义上是时间戳，别用于严格格式化。

#### `FormatTime(now string, param int) string`

**作用**：基于给定时间做偏移计算（前后 N 天/时/分/秒等），`param` 语义见宿主 tools 文档。

**签名**：

```go
func FormatTime(now string, param int) string
```

**参数表**：

| 参数 | 类型 | 传什么 | 从哪来 | 示例值 |
|---|---|---|---|---|
| `now` | string | 基准时间字符串 | 你构造 | `"2026-10-05 12:00:00"` |
| `param` | int | 偏移参数（编码单位/方向） | 你按业务指定 | `1` |

**返回**：string，偏移后的时间字符串；失败返回空串。

**代码片段**：

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

**注意事项**：
1. `param` 的语义由宿主 tools 定义（天/时/分等），不同取值差异大，别猜，按文档/实验确定。
2. 时间格式不符会返回空串。

## 七、宿主函数（`env` 命名空间）底层表

不使用 Go SDK 的手写语言（Zig / C# / AssemblyScript 等）需要直接导入下面这些函数。所有字符串参数都是 **i32（线性内存指针 + 长度）**，返回值是**写入 out 缓冲的字节数**。

| 函数 | 参数（均为 i32） | 返回 | 说明 |
|---|---|---|---|
| `scan_http` | `methodPtr, methodLen, urlPtr, urlLen, bodyPtr, bodyLen, outPtr, outCap` | 写入字节数 | 发起 HTTP（自动 Cookie 会话），响应 JSON 写入 out |
| `scan_log` | `msgPtr, msgLen` | — | 输出一条调试日志 |
| `scan_report` | `findingPtr, findingLen` | — | 上报漏洞发现（`VulnFinding` JSON） |
| `scan_call` | `funcID, argsPtr, argsLen, outPtr, outCap` | 写入字节数 | 调用内置函数（参数为 JSON 数组，返回 JSON 字符串） |
| `scan_t` | `keyPtr, keyLen, outPtr, outCap` | 写入字节数 | 取当前语言下本插件语言包文本（键空间 `<语言>.<插件ID>.<key>`） |
| `scan_config` | `keyPtr, keyLen, outPtr, outCap` | 写入字节数 | 取本插件配置值 |
| `scan_set_timeout` | `ms` | — | 设置本插件执行超时（毫秒），不调用默认 30s |

**为什么插件要自己传「指针 + 长度」（WASI ABI）**

WASM 是一种**线性内存沙箱**：宿主进程的内存与 WASM 模块的内存是两块完全隔离的地址空间。宿主函数拿到一个 `int` 时，它既不知道这是一段文本还是数字，也**无法访问**插件内存里的任何字节——除非插件明确告诉它：

- **指针**：字符串/字节在 WASM 线性内存中的起始地址（i32）；
- **长度**：从该地址起多少字节构成这段内容。

宿主据此按指针与长度从 WASM 线性内存中读出这段内容，再按 UTF-8 解码（Go SDK 的封装会自动把 string 拆成指针 + 长度）。同理，**输出缓冲区必须由插件预先分配**（Go SDK 为取字符串的封装准备了 4KB，为 HTTP 响应与内置函数调用准备了 64KB），插件把 `outPtr` + `outCap` 传给宿主；宿主写完后返回实际写入字节数 `n`，插件取 `out[:n]`。若内容超过 `outCap`，宿主会**截断**——这也是响应体上限与缓冲区大小要匹配的原因。

**手写 C 调用示例**（与 `scan_sdk.h` 一致）：

```c
/* 底层导入声明：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 字符串用 */
```

**Go 里 `//go:wasmimport` 直调示例**（不依赖 SDK `HTTP` 封装）：

```go
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/") }
```

> [!NOTE]
> Go 的 `string` 参数会被编译器自动拆成「指针 + 长度」，所以签名里可以直接写 `method, url, body string`，无需手动拆。

## 八、内置函数 ID 全表（`scan_call`）与直接调用

内置函数**由扫描节点主程序实现**，插件只传参数：好处是**插件体积更小、行为一致**（同 ID 在 Go / C / C++ / Rust SDK 中语义完全相同）。参数一律是 JSON 数组字符串，返回 JSON 字符串。

| ID | 函数 | 参数 | 返回 |
|---|---|---|---|
| 1 | `to_str` | `value` | 任意值转字符串 |
| 2 | `to_int` | `value` | 任意值转整数 |
| 3 | `to_int64` | `value` | 任意值转 int64 |
| 4 | `to_uint32` | `value` | 任意值转 uint32 |
| 5 | `to_uint64` | `value` | 任意值转 uint64 |
| 6 | `to_float` | `value` | 任意值转浮点数 |
| 7 | `to_bool` | `value` | 任意值转布尔 |
| 8 | `to_bytes` | `value` | 任意值转字节串（字符串形式） |
| 10 | `base64_encode` | `data` | 字符串 |
| 11 | `base64_decode` | `data` | 字符串 |
| 12 | `bytes_to_hex` | `data` | 十六进制字符串（无空格） |
| 13 | `hex_to_bytes` | `hex` | 字节串 |
| 14 | `url_encode` | `input` | 字符串 |
| 15 | `url_decode` | `input` | 字符串 |
| 16 | `html_encode` | `data` | 字符串 |
| 17 | `html_decode` | `data` | 字符串 |
| 18 | `charset_convert` | `data, targetEncoding, autoDetect` | 字符串 |
| 20 | `md5` | `data` | 十六进制字符串 |
| 21 | `sha1` | `data` | 十六进制字符串 |
| 22 | `sha224` | `data` | 十六进制字符串 |
| 23 | `sha256` | `data` | 十六进制字符串 |
| 24 | `sha384` | `data` | 十六进制字符串 |
| 25 | `sha512` | `data` | 十六进制字符串 |
| 26 | `crc32` | `data` | 十六进制字符串 |
| 27 | `crc64` | `data` | 十六进制字符串 |
| 30 | `rand_str` | `mode, length` | 随机字符串（mode 位标志 1 小写 / 2 数字 / 4 大写 / 8 特殊） |
| 40 | `left_of` | `s, keyWord` | 分隔符左侧内容 |
| 41 | `right_of` | `s, keyWord` | 分隔符右侧内容 |
| 42 | `middle_of` | `s, left, right` | 两标记之间的内容 |
| 43 | `text_between` | `source, start, end, startPosition, offset, fallbackToSource` | `{"pos":n,"text":""}` |
| 44 | `csv_to_line` | `fields, lineBreak` | 一行 CSV 字符串 |
| 45 | `csv_clean` | `fields` | 清洗后的 JSON 数组字符串 |
| 50 | `json_get` | `jsonData, path` | 字符串 |
| 51 | `json_get_raw` | `jsonData, path` | JSON 原始值 |
| 60 | `timestamp` | `timeStr, isMilli` | 时间戳（字符串形式） |
| 61 | `timestamp_str` | `ts, isMilli` | 时间字符串 |
| 62 | `format_time` | `now, param` | 时间偏移结果 |

> [!NOTE]
> ID 1~8 的类型转换函数 Go SDK 未单独封装，手写语言或需要时可直接用 `scan_call` 调用。**不公开**：文件读写、进程/系统命令、DNS/任意网络、HTTP 直连（统一走 `scan_http`）、并发池等危险能力。

**不依赖封装时怎么直调 `scan_call`**：参数是 JSON 数组，返回是 **JSON 编码的字符串**（所以结果带引号，需反序列化或用字符串处理去掉引号）。下面是不依赖 SDK `MD5` 封装、直接用 `//go:wasmimport` 调用的完整示例：

```go
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)
```

**直调三条要点**：

1. `scan_call` 返回值是**写入 out 的字节数**，`n == 0` 视为失败/空。
2. 返回内容是 **JSON 字符串字面量**（带引号），必须 `json.Unmarshal` 或手动去引号后才能当作原文使用；Go SDK 的封装正是这么做的。
3. 参数 JSON 数组里的字符串**要自己转义**（含 `"`、`\` 的文本），手写语言可参考 `scan_sdk.h` 的 `scan_json_escape`。

## 九、HTTP 能力细节

`scan.HTTP` 是插件访问网络的**唯一出口**，由宿主的 `env.scan_http` 代理实现，受以下策略约束：

- **自动 Cookie 会话**：宿主为每个插件实例维护一个 `cookiejar`，响应 `Set-Cookie` 会被自动保存并在后续请求中带上。适合登录态 / 多步请求场景。
- **经任务代理**：若任务配置了代理，请求走该代理；未配置则直连。
- **限时**：受插件级超时（默认 30s，`SetTimeout` 可调）与 HTTP 客户端超时共同约束。
- **响应体上限 1MB**：宿主用 `io.LimitReader` 最多读 1MB，**超出部分被丢弃**。判定时不要依赖超大响应的尾部内容。
- **`resp.Error` 判定**：构造请求失败或网络错误时，`StatusCode = 0` 且 `Error` 非空；**务必先判 `resp.Error == ""` 再判状态码**。
- **重定向**：最多跟随 10 次，超过 10 次返回最后一次响应（不再继续跳转）。
- **证书策略**：使用默认 TLS 校验，不跳过证书验证；自签名目标会以 `resp.Error` 体现。

```go
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"])
}
```

> [!TIP]
> Cookie 会话是**按插件实例**维护、跨流量包持续的（同一个插件的整个批次共用一份会话）。这既是优势（多步登录自动带 Cookie），也意味着**不同流量包之间可能「串味」**——见第十章症状表。

## 十、调试手册

这一章给出一套可照做的调试路径，以及「症状 → 原因 → 解决」速查表。

### 10.1 看日志：`scan.Log` 与 stdout 兜底协议

- **首选 `scan.Log(msg)`**：日志被宿主收集，随本次执行的结果上抛，在扫描节点的任务日志与 **GUI 调试面板**逐条展示。定位「有没有执行、走到哪一步」最直接。
- **stdout 兜底协议**：`scan_report` 之外还兼容旧版行协议（C/C++ 手写实现常用 `printf`/`write`）：
  - `[LOG] <消息>` → 记为一条调试日志；
  - `[FIND] <VulnFinding JSON>` → 记为一条漏洞发现。
  - 宿主在 `_start` 返回后，会检查插件写到 stdout 的内容，逐行匹配前缀。**推荐仍用 `scan_report`/`scan.Log`**，stdout 仅作兼容与兜底。

```c
/* 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);
```

> [!WARNING]
> `[FIND]` 行必须是**单行合法 JSON**，且前缀严格是 `[FIND]`（其后可跟空格）。JSON 解析失败会被**静默忽略**（宿主直接丢弃该行），你只会看到「没上报」而没有报错。

### 10.2 五分钟最小验证流程

1. **改源码**：在 POC 工程里改 `main.go`（或 C/Rust 源码），加一句 `scan.Log("我跑到这里了")`。
2. **重新编译**：`GOOS=wasip1 GOARCH=wasm go build -o output.wasm .`（确认产物导出 `_start`）。
3. **上传/构建**：在 GUI 的 WASM 漏洞构建入口上传 `source/`，或直接替换构建服务里的 `output.wasm`。
4. **签名**：通过 GUI 签名入口或 TestSecVulnService 构建服务签名，确认 `sig.json.signStatus == "verified"`。
5. **单包试跑**：在调试面板选一个流量包（或手动填 URL）运行一次，等价于宿主的一次单包执行。
6. **看日志与命中**：调试面板里应出现你的 `scan.Log` 输出；命中时出现漏洞卡片；如无输出，按 10.3 排查。

> [!TIP]
> 本地只要能在 `source/` 里跑通 `GOOS=wasip1 GOARCH=wasm go build`，产物就基本可用；真正的差异通常只在「签名」和「有没有调 `LoadFlow`」上。

### 10.3 症状 → 原因 → 解决（≥10 条）

| 症状 | 原因 | 解决 |
|---|---|---|
| 插件啥也不做、日志也没有 | **忘记 `scan.LoadFlow()`**，`scan.Flow` 全空 | 在 `main` 第一行调用 `scan.LoadFlow()`，并判 `scan.Flow.URL == ""` 提前返回 |
| 调试面板显示「未加载 / 未签名」，根本不执行 | **没有签名或验签失败**（`unsigned` / `verify_failed`） | 走 GUI/构建服务签名，使 `sig.json.signStatus` 变 `verified`；确认 `output.wasm` 未在签名后被改动 |
| 实例化失败：`instantiate module ... unknown import` | **误用了 `app_*`**（或 `ctl_*`）宿主函数 | WASM POC 只用 `scan_*`；应用插件才用 `app_*`，两套不可混用 |
| 结果 `Error` 为「wasm 模块缺少 `_start` 入口」 | **误用 `-buildmode=c-shared` 构建** | 漏洞 POC 用 `GOOS=wasip1 GOARCH=wasm go build -o output.wasm .` |
| 尾部特征判定不到、响应像是被砍断 | **响应体被 1MB 上限截断** | 判定靠前；或改用不依赖超大响应的特征；必要时换思路（如只看状态码/头） |
| 结果 `Error` 为「插件执行超时（默认 30s…）」 | **默认 30s 超时到了**，插件没跑完 | 在 `main` 开头 `scan.SetTimeout(60000)` 合理放大；避免无谓长轮询/睡眠 |
| 有漏洞卡片但标题/正文空白 | **`Report` 关键字段留空**（`Name`/`Detail`） | 至少填 `Name`（可 `scan.T("vuln_name")`）与 `Detail`；`URL` 留空会回退 `Flow.URL` |
| 卡片等级显示异常/缺失 | **`Level` 取值写错**（如 `High`/`高危`） | 只用 `critical` / `high` / `medium` / `low` 四个小写值 |
| 多个流量包结果互相「串味」，登录态错乱 | **Cookie 会话跨流量包持续**（按插件实例维护） | 需要隔离时自行清理会话（换策略）或拆成独立插件；不要把「每包全新会话」当成前提 |
| `scan.T(key)` / `scan.ConfigGet(key)` 返回空串或键名 | **语言包/配置缺失或键写错** | `T` 找不到返回键名本身；`ConfigGet` 找不到返回空串；核对键名与语言包/`plugin.config.json` 是否随包 |
| 手改了 `sig.json` 后就不执行了 | **`sig.json` 手改导致签名/hash 不匹配** | 不要手改签名文件；重新走签名流程生成 `sig.json` |
| `scan.JSONGet(...)` 编译不过 | **第一个参数传了 `string`** | 它要 `[]byte`，用 `scan.JSONGet([]byte(resp.Body), "a.b[0].c")` |
| 上手直接读 `resp.Body` 得到空、误判无漏洞 | **未判 `resp.Error`** | 先 `if resp.Error != "" { return }`，再判状态码与内容 |
| 内置函数结果/日志被截断 | **out 缓冲区太小**（`T`/`ConfigGet` 4KB、`HTTP`/`scan_call` 64KB） | Go SDK 已给足；手写语言自备足够 outCap；注意宿主超出即截断 |
| 想读文件/连数据库/直接开 socket，全失败 | **这些能力未公开**（防 RCE 白名单） | 只用 `scan_http` 与本文列出的受控 API；网络统一走 `scan_http` |

### 10.4 不依赖平台自测：把逻辑抽成纯函数用 `go test` 跑

WASM 只能在宿主里跑，但你的**判定逻辑**没有必要依赖宿主——把它抽成「输入字符串、输出 bool/结构体」的纯函数，用普通 `go test` 在本地快速迭代，最后在 `main` 里调用它即可。

```go
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})
	}
}
```

对应的测试（文件名 `leak_test.go`，**不带 `//go:build wasm`**，可在本机直接 `go test`）：

```go
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("不应命中")
	}
}
```

> [!IMPORTANT]
> 因为 `main.go` 引用了 `scan` 包（带 `//go:build wasm`），本机非 wasm 构建时该包不参与编译、会报找不到。**推荐把纯函数与 `main` 拆到不同文件**（例如 `logic.go` 只含纯函数、`main.go` 依赖 wasm），测试只对 `logic.go` 跑；或在本地测试时用构建标签把 `main` 隔离出去。这样你就能在没有扫描节点的机器上快速验证判定逻辑，再整体编译成 wasm 上平台。

## 十一、安全策略

1. **只执行 `verified` 的 WASM**：扫描节点只加载并执行验签通过的模板，其余的跳过并记录。
2. **主机函数白名单**：仅 `scan_http` / `scan_log` / `scan_report` / `scan_call` / `scan_t` / `scan_config` / `scan_set_timeout`，**不暴露**文件读写、进程/系统命令、DNS/任意网络。
3. **无文件 / 进程 / 网络直连**：插件无法访问宿主文件系统、无法执行外部程序、无法自行发起任意网络；HTTP 统一走 `scan_http`（经代理、限时、限大小）。
4. **崩溃保护**：WASM 执行出错以 error 返回，宿主再用 `recover` 兜底，trap/panic **不会拖垮扫描进程**。
5. **超时终止**：每插件默认 30s，超时自动终止该插件并继续下一个，不影响整个扫描任务。

> [!IMPORTANT]
> 这是一套「能力最小化」的设计（防 RCE，类似历史 Lua 插件问题的整改）。你的插件**只能**用本文列出的 API 完成检测——这不是限制，而是安全边界。

## 十二、注意事项清单

| 坑 | 表现 | 规避 |
|---|---|---|
| 忘记 `LoadFlow()` | `scan.Flow.URL` 为空，插件什么都不做 | 在 `main` 开头就调用 `LoadFlow()`，并做空值返回 |
| 忘记签名 | `signStatus=unsigned`，插件**根本不会被执行** | 通过 GUI / 构建服务签名，确认 `verified` |
| 把 `app_*` 写进来 | 实例化失败 `unknown import` | WASM POC 只用 `scan_*`；应用插件才用 `app_*` |
| 误用 c-shared 构建 | 模块缺少 `_start` | 漏洞 POC 用 `GOOS=wasip1 GOARCH=wasm go build -o output.wasm .` |
| 响应体被截断 | 尾部特征判定不到 | 记住响应上限 1MB；判定靠前、体量大的内容换思路 |
| 输出缓冲区太小 | `Log` / 内置函数结果被截断 | Go SDK 已给 4KB / 64KB；手写语言自备足够缓冲 |
| 超时设置过大/过小 | 大插件被 30s 掐断，或占满任务时长 | 开头 `SetTimeout` 合理放大；避免无谓长轮询 |
| 多个流量包共用会话 | 登录态 / Cookie 在包间“串味”，结果漂移 | 会话是**按插件实例**维护、跨流量包持续；需要隔离时自行清理或分插件 |
| 未判 `resp.Error` | 直接读 `Body` 得到空串，误判为“无漏洞” | 先判 `resp.Error == ""`，再判状态码与内容 |
| 在内置函数里找文件/网络 | 返回错误或空串 | 文件/网络/进程能力**未公开**，改用 `scan_http` 与受控 API |
| `JSONGet` 传了 string | 编译不过 | 第一个参数是 `[]byte`，用 `scan.JSONGet([]byte(resp.Body), path)` |
| 手改 `sig.json` | 签名/hash 不匹配，不再执行 | 不要手改；重新走签名流程 |

## 十三、完整示例

### 13.1 备份文件泄露检测（Go）

场景：把常见备份文件名拼到目标目录下请求，命中则上报。

```go
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
		}
	}
}
```

> [!NOTE]
> `min` 是 Go 1.21+ 的内置函数；若你的工具链更早，用 `if len(s) > 500 { s = s[:500] }` 代替。`Evidence` / `Response` 建议一律做长度截断，避免上报体过大。

### 13.2 JSON 接口未授权访问检测（Go）

场景：对返回 JSON 的接口附带 `id` 参数枚举，若在**未认证**状态下返回他人数据，则判为越权。

```go
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
		}
	}
}
```

> [!TIP]
> 两个示例都遵循同一套骨架：`SetTimeout` → `LoadFlow` → 构造请求 → `resp.Error` / 状态码判定 → 特征/JSON 判定 → `Report`。掌握这套骨架后，多数被动扫描类 POC 都能快速写出。找不到符号怎么用时，回到第六章查对应小节即可。

---

> 相关文档：[插件开发总览](/docs/overview)、[应用插件开发](/docs/scan-app-plugin)、[Go 热加载 POC 开发](/docs/go-poc-hotload)。

<!-- en -->
# Scan Node · WASM POC Template Development

[[toc]]

> 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**:

```text
扫描任务启动（扫描节点）
  ├─ 先把任务里的流量包去重（按包 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.

> [!IMPORTANT]
> 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)**:

| Form | Carrier | Entry point | Question it answers |
|---|---|---|---|
| YAML template | Declarative YAML | Engine parsing | Detection expressible with rules |
| Go hot-reload POC | Pure Go source (interpreted in-process) | `@meta` + `func Run` | You need a Turing-complete script but do not want a WASM build chain |
| **WASM POC template (this document)** | `output.wasm` | `_start` / `func main` | Detection that needs cross-language support, binary distribution and strong signature control |
| Application plugin | `scan.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.**

| Dimension | WASM POC template (`scan_*`) | Application plugin (`app_*`) |
|---|---|---|
| Host function namespace | `scan_http` / `scan_log` / `scan_report` / `scan_call` / `scan_t` / `scan_config` / `scan_set_timeout` | `app_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 call | **Exported functions** prefixed with `应用_`, invoked by hook |
| Build mode | Ordinary wasip1 command module | **Must use `-buildmode=c-shared`** |
| When it takes effect | **Only** entries with `PocType=wasm` in the POC template list | Scan node application plugins installed from the plugin store |
| Directory | `poc/<lang>/wasm/<VulnIde>/` | `poc/plugin/<uuid>/` |
| SDK file | `scan.go` | `app.go` |
| Use case | Vulnerability detection | Task 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.*`.

> [!WARNING]
> 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 situation | Recommendation |
|---|---|
| Expressible with a request + match rules + regex / timing / callback | **YAML template** (first choice: no SDK, no build) |
| Complex algorithms, loops, custom parsing, and your team writes Go | **Go hot-reload POC** (`import "scan"`, interpreted in-process) |
| Cross-language implementation, binary distribution, strong signature control, size/performance sensitive | **WASM 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.

> [!IMPORTANT]
> **`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.

> [!NOTE]
> **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`

```text
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 / directory | Purpose | Required |
|---|---|---|
| `source/` | Human-readable source (Go/C/C++/Rust/TinyGo all acceptable), distributed with the package for auditing | Recommended |
| `output.wasm` | The file that is actually loaded and executed; `_start` is the entry point | **Required** |
| `meta.yaml` | Vulnerability metadata; after loading it becomes a vulnerability entry with `PocType="wasm"` | **Required** |
| `sig.json` | Signature and verification status; **without a valid signature it is not executed** | **Required** |
| `plugin.config.json` | Key/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".

| Field | Type | Required | Meaning | Example value | Consequence of a mistake |
|---|---|---|---|---|---|
| `VulnIde` | string | Yes | Unique vulnerability identifier, **globally unique** | `048d825b807f4215a705d42a18dba527` | Colliding with another vulnerability → overwrite/dedup anomalies; if empty it falls back to the directory name and may not match the controller index |
| `Name` | string | Yes | Vulnerability name (flat field, can be overridden by `Languages`) | `WASM-go-反射型XSS检测` | If left blank, the vulnerability list/card name is empty |
| `Languages` | map | No | Multilingual 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 |
| `DefaultLanguage` | string | No | Default language key (e.g. `cn` / `en`) | `cn` | Falls back to `cn` when missing; a non-existent language key cannot resolve text |
| `Level` | string | Yes | Severity level: `critical` / `high` / `medium` / `low` | `high` | Illegal values such as `High`/`严重` → level mapping fails and the vulnerability card shows an abnormal level |
| `PocType` | string | Yes | Fixed to `wasm`; decides which execution path is taken | `wasm` | A wrong value (e.g. `yaml`) → it never enters the WASM execution chain at all |
| `CVEId` | string | No | CVE identifier | `CVE-2021-44228` | No impact (may be empty) |
| `CweId` | string | No | CWE identifier | `CWE-79` | No impact (may be empty) |
| `CvssScore` | string | No | CVSS score | `9.8` | No impact (may be empty) |
| `CvssVector` | string | No | CVSS vector | `AV:N/AC:L/...` | No impact (may be empty) |
| `SourceLanguage` | string | Yes | Source language `go` / `c` / `cpp` / `rust` / `tinygo`; the build service selects the toolchain from it | `go` | Mismatch with the actual language in `source/` → build failure or unusable artifact |
| `Description` | string | No | Vulnerability description | `反射型 XSS 载荷回显检测` | If blank, the detail page has no description |
| `Fingerprint` | string | No | Fingerprint | — | No impact |
| `AffectedProducts` | string | No | Affected products | — | No impact |
| `References` | string | No | Reference links | — | No impact |
| `Solution` | string | No | Remediation advice | — | No impact |
| `Confidence` | int | No | Confidence (0~100) | `80` | Out-of-range values may be displayed as anomalies |
| `Author` | string | No | Author | `TestSecScan` | No impact |
| `Enabled` | bool | No | Whether the template is enabled | `true` | If `false`, the template is not dispatched |

Minimal usable `meta.yaml`:

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

> [!TIP]
> `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

| Field | Type | Required | Meaning | Example value | Consequence of a mistake |
|---|---|---|---|---|---|
| `userPubkey` | string | Depends on the signing method | Public key for the **private signature** (base64, the controller-local `controller_ed25519.pub`) | `MFkwEwYH...` | Truncated / not base64 → verification `verify_failed`, not loaded |
| `userSignature` | string | Depends on the signing method | Ed25519 signature (base64) over the `output.wasm` bytes made with the private key above | `PZydTvki...` | Change one character by hand → verification fails |
| `serverPubkey` | string | Depends on the signing method | Public 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 |
| `serverSignature` | string | Depends on the signing method | Official Ed25519 signature over the `output.wasm` bytes | `PZydTvki...` | Same as above |
| `wasmHash` | string | Recommended | SHA-256 hex digest of `output.wasm` | `2ce8970f6e30...` | A mismatch with `output.wasm` means the artifact was swapped; the verification flow will fail |
| `signStatus` | string | Yes | Verification status (see the table below) | `verified` | Anything other than `verified`/`official` → the node **skips it directly** |
| `updateTime` | string | No | Signing/update time | `2026-08-15T21:05:34+08:00` | No impact |

Values of `signStatus`:

| Value | Meaning | Executable |
|---|---|---|
| `verified` | Verification passed (either the official or the private path) | ✅ |
| `official` | Verification came from the POC marketplace public key | ✅ |
| `unsigned` | No signature | ❌ |
| `verify_failed` | A signature exists but verification failed (treated as tampered) | ❌ |

> [!CAUTION]
> 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

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

`go.mod`:

```text
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`

```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

```bash
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**:

```go
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**:

| Field | Type | Meaning | Where it comes from / format | How to use it |
|---|---|---|---|---|
| `URL` | string | Full request URL | The host orchestration side serializes the packet to JSON and writes it to stdin; field name `url` | Most common: use it directly as the detection target `scan.Flow.URL`; if empty, no packet was received |
| `Method` | string | Request method | Same JSON, field name `method`, value `GET`/`POST`/`PUT`… uppercase | Replicate the original request method, e.g. `scan.HTTP(scan.Flow.Method, u, "")` |
| `Host` | string | Target host name | JSON field `host`, in the form `example.com:8080` (may include a port) | Build virtual host headers, filter by domain |
| `Scheme` | string | Scheme | JSON field `scheme`, value `http` or `https` | Build URLs, test whether it is https |
| `Path` | string | Request path | JSON field `path`, starts with `/`, excludes the query string | Replace paths, filter by suffix (e.g. only handle `.php`) |
| `Query` | string | Query parameters | JSON field `query`, **without the leading `?`** | When appending parameters, decide between `?` and `&` |
| `Headers` | map[string]string | Request headers | JSON field `headers`, **keys are already lowercased** | Read `scan.Flow.Headers["cookie"]`, `["content-type"]`; remember the keys are lowercase |
| `Body` | string | Request body | JSON field `body`, raw text (may be a form or JSON) | Extract parameter values from a POST body and rewrite them |
| `RawHeaders` | string | Raw request header text | JSON 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**:

```go
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**:

```go
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**:

| Field | Type | Meaning | How to use it |
|---|---|---|---|
| `StatusCode` | int | HTTP status code; **0 means the request never succeeded** | Check `Error == ""` first, then whether the code equals the expected value (e.g. `200`) |
| `Body` | string | Response body text; the host reads **at most 1MB** and discards the rest | Use for keyword/regex/JSON checks, e.g. `strings.Contains(resp.Body, ...)` |
| `Headers` | map[string]string | Response headers, **lowercase keys**, multiple values joined with `, ` | `resp.Headers["content-type"]`, `resp.Headers["server"]` |
| `Cookies` | []string | List of raw `Set-Cookie` strings from the response | Iterate when you need an explicit cookie value; the session is maintained automatically by the host |
| `URL` | string | Final request URL (**including the address after redirects**) | Detect whether it was redirected to a login page |
| `TimeMs` | int64 | Duration of this request (milliseconds) | Time-based blind injection decisions, performance logging |
| `Error` | string | Failure reason; **empty string on success** | **Always check `resp.Error == ""` before checking the status code** |

**Code snippet**:

```go
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**:

```go
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**:

| Field | Type | Meaning | How to use it |
|---|---|---|---|
| `Name` | string | Vulnerability 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** |
| `Detail` | string | Vulnerability detail (card body) | Explain the evidence hit and the decision logic |
| `Evidence` | string | Evidence of the hit (response snippet / packet text) | Truncating to a few hundred characters is recommended, e.g. `resp.Body[:min(len(resp.Body), 500)]` |
| `Level` | string | Level: `critical` / `high` / `medium` / `low` | **The value must be exact**; if left blank it falls back to the template's `meta.yaml.Level` |
| `URL` | string | Hit URL | **If left blank, the current packet's `Flow.URL` is used**; when detecting across targets it is better to fill it explicitly |
| `CVEId` | string | Optional CVE identifier | If blank, the template metadata is used |
| `Request` | string | Optional: the triggering request message (text) | Lets the GUI display the raw request |
| `Response` | string | Optional: the hit response message (text) | Lets the GUI display the raw response; truncation is recommended |
| `SensitiveText` | string | Sensitive information highlight: raw text snippet | For sensitive-information findings only; take 200 characters before and after the hit |
| `SensitiveKeywords` | []string | Sensitive information highlight: list of keywords hit | The GUI plain-text viewer highlights the hit positions from it |
| `SensitiveMatchRule` | string | Sensitive information highlight: the formula/rule matched | Shows the basis of the decision |

**Code snippet**:

```go
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**:

```go
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**:

```go
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**:

```go
func SetTimeout(ms int64)
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `ms` | int64 | Maximum runtime of this plugin in milliseconds; must be > 0 | Estimate it yourself from the detection complexity | `60000` (60 seconds) |

**Return**: none.

**Code snippet**:

```go
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**:

```go
func T(key string) string
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `key` | string | Key in the language pack (**without the plugin ID prefix**) | Your language pack file | `vuln_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**:

```go
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**:

```go
func ConfigGet(key string) string
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `key` | string | Key of the configuration entry | `plugin.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**:

```go
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**:

```go
func HTTP(method, url, body string) HTTPResponse
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `method` | string | HTTP method (uppercase) | You specify it, or use `scan.Flow.Method` | `"POST"` |
| `url` | string | Full target URL (with scheme) | You build it, or base it on `scan.Flow.URL` | `"https://a.com/api"` |
| `body` | string | Raw request body text; pass `""` for GET | You 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**:

```go
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**:

```go
func HTTPGet(url string) HTTPResponse
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `url` | string | Full target URL | You build it | `"https://a.com/robots.txt"` |

**Return**: `HTTPResponse`, the same as `HTTP`.

**Code snippet**:

```go
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**:

```go
func HTTPPost(url, body string) HTTPResponse
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `url` | string | Full target URL | You build it | `"https://a.com/login"` |
| `body` | string | Raw request body | You build it | `user=admin&pwd=admin` |

**Return**: `HTTPResponse`, the same as `HTTP`.

**Code snippet**:

```go
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**:

```go
func HTTPJSONPost(url, body string) HTTPResponse
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `url` | string | Full target URL | You build it | `"https://a.com/api/user"` |
| `body` | string | JSON text | You build it | `{"id":1}` |

**Return**: `HTTPResponse`, the same as `HTTP`.

**Code snippet**:

```go
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**:

```go
func Log(msg string)
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `msg` | string | Any readable text | You 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**:

```go
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**:

```go
func Report(f VulnFinding)
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `f` | `VulnFinding` | A filled-in vulnerability structure | You 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**:

```go
// 一次执行可以上报多条
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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.

```go
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).

```go
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.

```go
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.

```go
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**:

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

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `source` | string | Source text | `resp.Body` and the like | `<title>首页</title>` |
| `start` | string | Start marker | You specify it | `"<title>"` |
| `end` | string | End marker | You specify it | `"</title>"` |
| `startPosition` | int | Byte offset in the source text where the search starts | You specify it (pass 0 the first time) | `0` |
| `offset` | string | Result offset (in string form) | You specify it | `""` |
| `fallback` | bool | Whether to fall back to the source text when not found | You specify it | `true` |

**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**:

```go
// 两个返回值：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**:

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

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `data` | []byte | Raw JSON bytes | `[]byte(resp.Body)` | `[]byte(resp.Body)` |
| `path` | string | Path expression; supports `a.b[0].c` | You 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**:

```go
// 注意第一个参数是 []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**:

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

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `data` | []byte | Raw JSON bytes | `[]byte(resp.Body)` | `[]byte(resp.Body)` |
| `path` | string | Path expression | You 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**:

```go
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**:

```go
func TimeToTimestamp(timeStr string, isMilli bool) int64
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `timeStr` | string | Time string | You build it | `"2026-10-05 12:00:00"` |
| `isMilli` | bool | Whether to output milliseconds | You specify it | `false` (seconds) |

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

**Code snippet**:

```go
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**:

```go
func TimestampToStr(ts int64, isMilli bool) string
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `ts` | int64 | Timestamp | A `TimeToTimestamp` result or a time difference | `1759646400` |
| `isMilli` | bool | Whether `ts` is in milliseconds | You specify it | `true` |

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

**Code snippet**:

```go
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**:

```go
func FormatTime(now string, param int) string
```

**Parameter table**:

| Parameter | Type | What to pass | Where it comes from | Example value |
|---|---|---|---|---|
| `now` | string | Base time string | You build it | `"2026-10-05 12:00:00"` |
| `param` | int | Offset parameter (encodes unit/direction) | You specify it according to your use case | `1` |

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

**Code snippet**:

```go
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**.

| Function | Parameters (all i32) | Return | Description |
|---|---|---|---|
| `scan_http` | `methodPtr, methodLen, urlPtr, urlLen, bodyPtr, bodyLen, outPtr, outCap` | Bytes written | Sends HTTP (automatic Cookie session); the response JSON is written into out |
| `scan_log` | `msgPtr, msgLen` | — | Outputs one debug log line |
| `scan_report` | `findingPtr, findingLen` | — | Reports a vulnerability finding (`VulnFinding` JSON) |
| `scan_call` | `funcID, argsPtr, argsLen, outPtr, outCap` | Bytes written | Calls a built-in function (arguments are a JSON array; returns a JSON string) |
| `scan_t` | `keyPtr, keyLen, outPtr, outCap` | Bytes written | Reads this plugin's language-pack text in the current language (key space `<language>.<plugin ID>.<key>`) |
| `scan_config` | `keyPtr, keyLen, outPtr, outCap` | Bytes written | Reads this plugin's configuration value |
| `scan_set_timeout` | `ms` | — | 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`):

```c
/* 底层导入声明：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):

```go
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/") }
```

> [!NOTE]
> 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.

| ID | Function | Arguments | Return |
|---|---|---|---|
| 1 | `to_str` | `value` | Converts any value to a string |
| 2 | `to_int` | `value` | Converts any value to an integer |
| 3 | `to_int64` | `value` | Converts any value to int64 |
| 4 | `to_uint32` | `value` | Converts any value to uint32 |
| 5 | `to_uint64` | `value` | Converts any value to uint64 |
| 6 | `to_float` | `value` | Converts any value to a float |
| 7 | `to_bool` | `value` | Converts any value to a boolean |
| 8 | `to_bytes` | `value` | Converts any value to a byte string (string form) |
| 10 | `base64_encode` | `data` | String |
| 11 | `base64_decode` | `data` | String |
| 12 | `bytes_to_hex` | `data` | Hexadecimal string (no spaces) |
| 13 | `hex_to_bytes` | `hex` | Byte string |
| 14 | `url_encode` | `input` | String |
| 15 | `url_decode` | `input` | String |
| 16 | `html_encode` | `data` | String |
| 17 | `html_decode` | `data` | String |
| 18 | `charset_convert` | `data, targetEncoding, autoDetect` | String |
| 20 | `md5` | `data` | Hexadecimal string |
| 21 | `sha1` | `data` | Hexadecimal string |
| 22 | `sha224` | `data` | Hexadecimal string |
| 23 | `sha256` | `data` | Hexadecimal string |
| 24 | `sha384` | `data` | Hexadecimal string |
| 25 | `sha512` | `data` | Hexadecimal string |
| 26 | `crc32` | `data` | Hexadecimal string |
| 27 | `crc64` | `data` | Hexadecimal string |
| 30 | `rand_str` | `mode, length` | Random string (mode bit flags: 1 lowercase / 2 digits / 4 uppercase / 8 special) |
| 40 | `left_of` | `s, keyWord` | Content to the left of the separator |
| 41 | `right_of` | `s, keyWord` | Content to the right of the separator |
| 42 | `middle_of` | `s, left, right` | Content between the two markers |
| 43 | `text_between` | `source, start, end, startPosition, offset, fallbackToSource` | `{"pos":n,"text":""}` |
| 44 | `csv_to_line` | `fields, lineBreak` | One line of a CSV string |
| 45 | `csv_clean` | `fields` | Cleaned JSON array string |
| 50 | `json_get` | `jsonData, path` | String |
| 51 | `json_get_raw` | `jsonData, path` | Raw JSON value |
| 60 | `timestamp` | `timeStr, isMilli` | Timestamp (string form) |
| 61 | `timestamp_str` | `ts, isMilli` | Time string |
| 62 | `format_time` | `now, param` | Time offset result |

> [!NOTE]
> 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:

```go
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`.

```go
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"])
}
```

> [!TIP]
> 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
/* 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);
```

> [!WARNING]
> 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.

> [!TIP]
> 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)

| Symptom | Cause | Fix |
|---|---|---|
| The plugin does nothing and there are no logs | **`scan.LoadFlow()` was forgotten**, so `scan.Flow` is entirely empty | Call `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 executes | **No 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 import` | **`app_*`** (or `ctl_*`) host functions were used by mistake | A 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 mistake** | Build vulnerability POCs with `GOOS=wasip1 GOARCH=wasm go build -o output.wasm .` |
| Trailing features cannot be detected and the response looks cut off | **The response body was truncated by the 1MB limit** | Keep 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 finish | Call `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 blank | **Key `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 missing | **A 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 scrambled | **The 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 name | **The language pack/configuration is missing or the key is wrong** | `T` 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 hand | **Hand-editing `sig.json` made the signature/hash mismatch** | Do not hand-edit signature files; run the signing flow again to generate `sig.json` |
| `scan.JSONGet(...)` does not compile | **A `string` was passed as the first argument** | It 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 vulnerability | **`resp.Error` was not checked** | Do `if resp.Error != "" { return }` first, then check the status code and content |
| Built-in function results/logs are truncated | **The 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 fail | **These 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.

```go
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):

```go
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("不应命中")
	}
}
```

> [!IMPORTANT]
> 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.

> [!IMPORTANT]
> 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

| Pitfall | Symptom | How to avoid it |
|---|---|---|
| Forgetting `LoadFlow()` | `scan.Flow.URL` is empty and the plugin does nothing | Call `LoadFlow()` at the start of `main` and return early on the empty value |
| Forgetting to sign | `signStatus=unsigned` and the plugin **is never executed at all** | Sign through the GUI / build service and confirm `verified` |
| Writing `app_*` into it | Instantiation failure `unknown import` | A WASM POC uses only `scan_*`; only application plugins use `app_*` |
| Building with c-shared by mistake | The module is missing `_start` | Build vulnerability POCs with `GOOS=wasip1 GOARCH=wasm go build -o output.wasm .` |
| Response body truncated | Trailing features cannot be detected | Remember the 1MB response limit; keep checks near the beginning and rethink large-content checks |
| Output buffer too small | `Log` / built-in function results are truncated | The Go SDK already gives 4KB / 64KB; hand-written languages must provide enough buffer |
| Timeout set too high/too low | A large plugin is cut off at 30s, or the task's time is used up | Raise it reasonably with `SetTimeout` at the start; avoid pointless long polling |
| Multiple packets sharing a session | Login state / Cookies contaminate each other across packets and results drift | The session is maintained **per plugin instance** and persists across packets; clear it yourself or split plugins when isolation is needed |
| Not checking `resp.Error` | Reading `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-ins | An error or an empty string is returned | File/network/process capabilities are **not exposed**; use `scan_http` and the controlled APIs instead |
| Passing a string to `JSONGet` | It does not compile | The first parameter is `[]byte`; use `scan.JSONGet([]byte(resp.Body), path)` |
| Hand-editing `sig.json` | The signature/hash no longer matches and it stops executing | Do 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.

```go
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
		}
	}
}
```

> [!NOTE]
> `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.

```go
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
		}
	}
}
```

> [!TIP]
> 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](/docs/overview), [Application Plugin Development](/docs/scan-app-plugin), [Go Hot-Reload POC Development](/docs/go-poc-hotload).
