Merge pull request #594 from shadow1ng/dev
发布 / auto-tag (push) Has been cancelled
测试构建 / 代码检查 (push) Has been cancelled
发布 / release (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled

Release v2.2.0
This commit is contained in:
ZacharyZcR
2026-07-10 13:57:26 +08:00
committed by GitHub
86 changed files with 9120 additions and 192 deletions
+2 -1
View File
@@ -72,7 +72,8 @@ body:
attributes: attributes:
label: fscan 版本 label: fscan 版本
options: options:
- 2.2.0-rc (dev) - 2.2.0
- 2.2.0-rc
- 2.1.3 - 2.1.3
- 2.1.2 - 2.1.2
- 2.1.0 - 2.1.0
+2 -1
View File
@@ -94,7 +94,8 @@ body:
attributes: attributes:
label: fscan 版本 label: fscan 版本
options: options:
- 2.2.0-rc (dev) - 2.2.0
- 2.2.0-rc
- 2.1.3 - 2.1.3
- 2.1.2 - 2.1.2
- 2.1.0 - 2.1.0
+9 -12
View File
@@ -12,6 +12,7 @@ gh workflow run release.yml -f snapshot=true
# 3. 确认版本号一致 # 3. 确认版本号一致
grep "version" common/globals.go grep "version" common/globals.go
grep "版本" README.md grep "版本" README.md
grep "Version" README_EN.md
``` ```
## 发版 ## 发版
@@ -20,7 +21,7 @@ grep "版本" README.md
# 1. 确认 release notes 已就绪 # 1. 确认 release notes 已就绪
cat .github/release-notes/v<VERSION>.md cat .github/release-notes/v<VERSION>.md
# 2. 打 tag在 dev 分支打 RC,在 main 分支打正式版 # 2. 打 tagRC 手动打;正式版合并到 main 后由 CI 自动打 tag
git tag v<VERSION> git tag v<VERSION>
git push origin v<VERSION> git push origin v<VERSION>
@@ -47,18 +48,14 @@ git push origin v<VERSION>
## 正式版发布(RC → 正式) ## 正式版发布(RC → 正式)
```bash ```bash
# 1. 合并 dev 到 main # 1. dev 分支准备正式版内容
git checkout main
git merge dev
git push
# 2. 更新版本号去掉 -rc
# common/globals.go, README.md, README_EN.md # common/globals.go, README.md, README_EN.md
# 3. 准备正式版 release notes
# .github/release-notes/v2.2.0.md # .github/release-notes/v2.2.0.md
# 4. 打 tag # 2. 创建 dev -> main PR
git tag v2.2.0 gh pr create --base main --head dev
git push origin v2.2.0
# 3. 合并 PR
# main push 会自动读取 common/globals.go 中的版本号,创建 v<VERSION> tag
# tag push 会触发 GoReleaser 构建并创建 GitHub Release
``` ```
+12 -10
View File
@@ -1,3 +1,5 @@
version: 2
project_name: "fscan" project_name: "fscan"
before: before:
@@ -135,17 +137,17 @@ builds:
upx: upx:
- ids: [fscan, fscan-nolocal, fscan-web] - ids: [fscan, fscan-nolocal, fscan-web]
enabled: true enabled: true
goos: [windows, linux, freebsd] goos: [windows, linux]
goarch: [amd64, "386", arm, arm64, mips, mipsle] goarch: [amd64, "386", arm64]
compress: best compress: "6"
brute: false brute: false
lzma: false lzma: false
archives: archives:
# 标准版归档 # 标准版归档
- id: fscan - id: fscan
builds: [fscan] ids: [fscan]
format: binary formats: [binary]
allow_different_binary_count: true allow_different_binary_count: true
name_template: >- name_template: >-
fscan_{{ .Version }}_ fscan_{{ .Version }}_
@@ -158,8 +160,8 @@ archives:
# 无本地插件版归档 # 无本地插件版归档
- id: fscan-nolocal - id: fscan-nolocal
builds: [fscan-nolocal] ids: [fscan-nolocal]
format: binary formats: [binary]
allow_different_binary_count: true allow_different_binary_count: true
name_template: >- name_template: >-
fscan-nolocal_{{ .Version }}_ fscan-nolocal_{{ .Version }}_
@@ -172,8 +174,8 @@ archives:
# WebUI版归档 # WebUI版归档
- id: fscan-web - id: fscan-web
builds: [fscan-web] ids: [fscan-web]
format: binary formats: [binary]
allow_different_binary_count: true allow_different_binary_count: true
name_template: >- name_template: >-
fscan-web_{{ .Version }}_ fscan-web_{{ .Version }}_
@@ -238,7 +240,7 @@ release:
**完整更新日志**: https://github.com/{{ .Env.GITHUB_OWNER }}/{{ .Env.GITHUB_REPO }}/compare/{{ .PreviousTag }}...{{ .Tag }} **完整更新日志**: https://github.com/{{ .Env.GITHUB_OWNER }}/{{ .Env.GITHUB_REPO }}/compare/{{ .PreviousTag }}...{{ .Tag }}
snapshot: snapshot:
name_template: "{{ incpatch .Version }}-dev-{{ .ShortCommit }}" version_template: "{{ incpatch .Version }}-dev-{{ .ShortCommit }}"
metadata: metadata:
mod_timestamp: "{{ .CommitTimestamp }}" mod_timestamp: "{{ .CommitTimestamp }}"
+129
View File
@@ -0,0 +1,129 @@
# fscan v2.2.0
v2.2.0 是 v2.2 系列首个正式版,基于 v2.1.3 之后的 RC 测试和 Issue 反馈整理发布。
本版本重点提升大规模扫描稳定性、POC 扫描可靠性、非标准端口服务识别、插件隔离和嵌入式 SDK 能力。
---
## 重点变化
### 嵌入式 Scanner SDK
新增 `pkg/fscan`,fscan 从纯 CLI 工具扩展为可嵌入的 Go 扫描引擎:
- 支持在 Go 程序内直接调用扫描能力
- Scanner 实例拥有独立 `config` / `state` / `session`
- 全局状态迁移到 session,改善多实例并发隔离
- 补充 SDK 结果转换、配置校验和并发扫描测试
### 大规模扫描稳定性
- 新增流式 Host Iterator,大 CIDR 不再一次性展开到内存
- 移除 MaxHosts 硬限制,大网段不再被静默截断
- 新增自适应并发调度,基于 RTT、丢包率、fd limit 自动推导扫描参数
- 线程池升级为 AIMD + 慢启动,遇到资源耗尽时自动降速
- `-gt` 全局超时正式生效,超时后会取消扫描任务
- 新增 `-nsp`,可禁用网段预筛
### 服务识别与插件调度
- 修复非标准端口服务无法匹配插件的问题
- 新增服务缓存和指纹驱动插件匹配
- `-full` 模式下 Web 插件可覆盖所有开放端口
- 不确定服务增加 HTTP 回退探测
- 移除误导性的“无可用插件”日志
- 用户指定 `-p` 时 UDP 插件按端口交集正确调度
### Web / POC 扫描
- 修复默认扫描 POC 结果缺失
- 修复 `-hf` 批量扫描时 POC 缺失
- 修复 HTTPS 端口误用 HTTP 扫描 POC
- 修复 POC 结果文件只显示 `vulnerable` 不显示漏洞名称
- POC 加载按 `pocpath` 隔离缓存,多 session 不再互相覆盖
- 修复 CEL、reverseCheck、正则缓存等稳定性问题
- `-nopoc` 禁用 POC 时不再输出误导性错误日志
### 新增协议插件
新增多种原生协议插件,覆盖邮件、Java 调试、文件共享、带外管理、UDP 和工控场景:
| 插件 | 用途 |
|------|------|
| IMAP / POP3 | 邮件服务器检测 |
| JDWP | Java Debug 端口检测 |
| NFS / RMI | 文件共享 / Java 远程调用 |
| IPMI | 服务器带外管理 |
| SNMP / DNS / BACnet / Modbus | 网络设备、DNS、工控协议检测 |
### Web 版
- 拆分 CLI / Web 入口
- Web 版结果存储改为 SQLite 持久化
- Web API 版本号改为动态读取
---
## Bug 修复摘要
- 修复 #586 默认扫描 POC 结果缺失
- 修复 #587 `-hf` 批量扫描 POC 缺失
- 修复 #588 非标准端口服务插件匹配问题
- 修复 #590 Telnet Cisco MOTD 横幅误判 shell prompt
- 修复 #591 HTTPS POC 协议错误与结果名称缺失
- 修复 #592 service probe 空指针 panic
- 修复 #593 `-ehf` 排除主机未生效,支持 IP / CIDR / range
- 修复 UDP 插件阻塞导致扫描无法结束
- 修复 SSH goroutine 泄漏和握手 deadline 问题
- 修复 Redis exploit 超时和非超时错误处理
- 修复 MongoDB SCRAM、Cassandra、Oracle 等协议问题
- 修复 SOCKS5 代理认证、LM:NT hash、逗号分隔密码等参数问题
- 修复非终端输出 ANSI 控制码覆盖结果
- 修复 CSV / NDJSON / TXT 输出若干字段问题
- 修复 ARM 32 位原子计数器对齐问题
---
## 升级注意
- WebUI 仍建议视为实验性能力
- 本地后渗透插件仅用于授权环境
- v2.2.0 改动较大,建议从 v2.1.3 升级的用户先在测试环境验证扫描参数
- 如依赖旧版本输出格式,请重点检查 POC、SERVICE、VULN 结果字段
---
## 版本说明
| 版本 | 说明 |
|------|------|
| **fscan** | 标准版,包含全部插件(推荐) |
| **fscan-nolocal** | 精简版,不含本地模块(体积更小) |
| **fscan-web** | WebUI 版,带 Web 管理界面(主流平台) |
## 平台支持
| 平台 | 架构 |
|------|------|
| Linux | x64, x32, arm64, armv5/6/7, mips, mips64, mipsle |
| Windows | x64, x32 |
| macOS | x64, arm64 |
| FreeBSD | x64, x32, arm64, armv5/6/7 |
| Solaris | x64 |
---
## 校验
本版本已通过:
- `go test ./...`
- 近期 Issue 回归验证
- 本地 HTTP / HTTPS POC 扫描验证
- `-hf` 批量 POC 扫描验证
- `-ehf` IP / CIDR 排除验证
完整变更记录见:
https://github.com/shadow1ng/fscan/compare/v2.1.3...v2.2.0
+79 -2
View File
@@ -2,6 +2,8 @@ name: 发布
on: on:
push: push:
branches:
- main
tags: tags:
- 'v*' - 'v*'
workflow_dispatch: workflow_dispatch:
@@ -22,7 +24,60 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
auto-tag:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
tag: ${{ steps.version.outputs.tag }}
steps:
- name: 检出代码
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 读取版本号
id: version
shell: bash
run: |
VERSION=$(sed -n 's/^[[:space:]]*version = "\(.*\)"/\1/p' common/globals.go)
if [ -z "$VERSION" ]; then
echo "❌ 无法从 common/globals.go 读取版本号"
exit 1
fi
TAG="v${VERSION}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "准备发布 ${TAG}"
- name: 创建发布标签
shell: bash
run: |
TAG="${{ steps.version.outputs.tag }}"
if git ls-remote --exit-code --tags origin "refs/tags/${TAG}" >/tmp/tag-ref 2>/dev/null; then
git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"
TAG_COMMIT=$(git rev-list -n 1 "${TAG}")
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" = "$HEAD_COMMIT" ]; then
echo "✅ ${TAG} 已指向当前提交,跳过创建"
exit 0
fi
echo "❌ ${TAG} 已存在,但不指向当前提交"
echo "tag: ${TAG_COMMIT}"
echo "head: ${HEAD_COMMIT}"
exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "${TAG}" -m "Release ${TAG}"
git push origin "${TAG}"
release: release:
needs: [auto-tag]
if: ${{ always() && (startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.auto-tag.result == 'success')) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 90 timeout-minutes: 90
@@ -32,10 +87,32 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: 解析发布标签
id: release_tag
shell: bash
env:
AUTO_TAG: ${{ needs.auto-tag.outputs.tag }}
SNAPSHOT: ${{ inputs.snapshot }}
run: |
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
TAG="${GITHUB_REF_NAME}"
elif [ "${GITHUB_EVENT_NAME}" = "push" ] && [ "${GITHUB_REF}" = "refs/heads/main" ]; then
TAG="${AUTO_TAG}"
git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"
elif [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ] && [ "${SNAPSHOT}" = "true" ]; then
TAG="${GITHUB_REF_NAME}"
else
echo "❌ 非 snapshot 手动发布必须从 tag 触发"
exit 1
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
- name: 准备 Release Notes - name: 准备 Release Notes
if: ${{ !inputs.snapshot }} if: ${{ !inputs.snapshot }}
run: | run: |
TAG="${GITHUB_REF_NAME}" TAG="${RELEASE_TAG}"
NOTES_FILE=".github/release-notes/${TAG}.md" NOTES_FILE=".github/release-notes/${TAG}.md"
if [ -f "$NOTES_FILE" ]; then if [ -f "$NOTES_FILE" ]; then
@@ -61,7 +138,7 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
TAG="${GITHUB_REF_NAME}" TAG="${RELEASE_TAG}"
NOTES_FILE="${RELEASE_NOTES_FILE}" NOTES_FILE="${RELEASE_NOTES_FILE}"
if [ -s "$NOTES_FILE" ]; then if [ -s "$NOTES_FILE" ]; then
+1 -1
View File
@@ -4,7 +4,7 @@
内网综合扫描工具,一键自动化漏扫。 内网综合扫描工具,一键自动化漏扫。
**版本**: 2.2.0-rc.1 **版本**: 2.2.0
## 功能特性 ## 功能特性
+1 -1
View File
@@ -4,7 +4,7 @@
Comprehensive intranet scanning tool for automated vulnerability assessment. Comprehensive intranet scanning tool for automated vulnerability assessment.
**Version**: 2.2.0-rc.1.1 **Version**: 2.2.0
## Features ## Features
+256
View File
@@ -3,6 +3,7 @@ package common
import ( import (
"reflect" "reflect"
"testing" "testing"
"time"
fscanconfig "github.com/shadow1ng/fscan/common/config" fscanconfig "github.com/shadow1ng/fscan/common/config"
) )
@@ -192,3 +193,258 @@ func TestNormalizeURLBracketsIPv6Literals(t *testing.T) {
}) })
} }
} }
// TestModuleTimeout 测试模块超时计算
func TestModuleTimeout(t *testing.T) {
tests := []struct {
name string
timeout time.Duration
want time.Duration
}{
{"超时大于下限", 10 * time.Second, 10 * time.Second},
{"超时等于下限", 3 * time.Second, 3 * time.Second},
{"超时小于下限", 1 * time.Second, 3 * time.Second},
{"零超时", 0, 3 * time.Second},
{"负超时", -1 * time.Second, 3 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := NewConfig()
cfg.Timeout = tt.timeout
got := cfg.ModuleTimeout()
if got != tt.want {
t.Errorf("ModuleTimeout() = %v, want %v", got, tt.want)
}
})
}
}
// TestParseUserPassPairsExactMatch 测试精确单用户单密码路径
func TestParseUserPassPairsExactMatch(t *testing.T) {
fv := &FlagVars{
Username: "admin",
Password: "secret",
}
pairs, err := parseUserPassPairs(fv)
if err != nil {
t.Fatalf("parseUserPassPairs error = %v", err)
}
if len(pairs) != 1 {
t.Fatalf("期望 1 个 pair, 实际 %d", len(pairs))
}
if pairs[0].Username != "admin" || pairs[0].Password != "secret" {
t.Errorf("pair = %+v, want {admin secret}", pairs[0])
}
}
// TestParseUserPassPairsMultiUserSkips 测试多用户时不生成精确 pair
func TestParseUserPassPairsMultiUserSkips(t *testing.T) {
fv := &FlagVars{
Username: "admin,root",
Password: "pass",
}
pairs, err := parseUserPassPairs(fv)
if err != nil {
t.Fatalf("parseUserPassPairs error = %v", err)
}
if len(pairs) != 0 {
t.Fatalf("多用户场景不应生成精确 pair, 实际 %d 个", len(pairs))
}
}
// TestParseURLsEmpty 测试空输入返回空列表
func TestParseURLsEmpty(t *testing.T) {
fv := &FlagVars{}
urls, err := parseURLs(fv)
if err != nil {
t.Fatalf("parseURLs error = %v", err)
}
if len(urls) != 0 {
t.Fatalf("空输入应返回空 url 列表, 实际 %v", urls)
}
}
// TestParseURLsCommaSeparated 测试逗号分隔多 URL
func TestParseURLsCommaSeparated(t *testing.T) {
fv := &FlagVars{
TargetURL: "http://a.com,http://b.com,http://a.com", // 含重复
}
urls, err := parseURLs(fv)
if err != nil {
t.Fatalf("parseURLs error = %v", err)
}
if len(urls) != 2 {
t.Fatalf("去重后应有 2 个 url, 实际 %d: %v", len(urls), urls)
}
}
// TestParseURLsMissingFile 测试缺失文件返回错误
func TestParseURLsMissingFile(t *testing.T) {
fv := &FlagVars{URLsFile: "nonexistent-urls.txt"}
_, err := parseURLs(fv)
if err == nil {
t.Fatal("缺失文件应返回错误")
}
}
// ---------------------------------------------------------------------------
// parseHashes
// ---------------------------------------------------------------------------
// TestParseHashesEmpty 空输入返回空结果
func TestParseHashesEmpty(t *testing.T) {
fv := &FlagVars{}
vals, bytes, err := parseHashes(fv)
if err != nil {
t.Fatalf("parseHashes error = %v", err)
}
if len(vals) != 0 || len(bytes) != 0 {
t.Fatalf("空输入应返回空结果, vals=%v bytes=%v", vals, bytes)
}
}
// TestParseHashesValidNTLM 纯 32 字符 hex hash
func TestParseHashesValidNTLM(t *testing.T) {
hash := "aabbccddeeff00112233445566778899"
fv := &FlagVars{HashValue: hash}
vals, hashBytes, err := parseHashes(fv)
if err != nil {
t.Fatalf("parseHashes error = %v", err)
}
if len(vals) != 1 || vals[0] != hash {
t.Fatalf("vals = %v, want [%s]", vals, hash)
}
if len(hashBytes) != 1 || len(hashBytes[0]) != 16 {
t.Fatalf("hashBytes length wrong: %v", hashBytes)
}
}
// TestParseHashesLMNTFormat LM:NT 格式,提取 NT 部分
func TestParseHashesLMNTFormat(t *testing.T) {
lm := "aad3b435b51404eeaad3b435b51404ee"
nt := "31d6cfe0d16ae931b73c59d7e0c089c0"
fv := &FlagVars{HashValue: lm + ":" + nt}
vals, _, err := parseHashes(fv)
if err != nil {
t.Fatalf("parseHashes error = %v", err)
}
if len(vals) != 1 || vals[0] != nt {
t.Fatalf("vals = %v, want [%s]", vals, nt)
}
}
// TestParseHashesInvalidLength hash 长度不是 32 → error
func TestParseHashesInvalidLength(t *testing.T) {
fv := &FlagVars{HashValue: "tooshort"}
_, _, err := parseHashes(fv)
if err == nil {
t.Fatal("hash 长度不足应返回错误")
}
}
// TestParseHashesInvalidHex 32 字符但含非 hex 字符 → error
func TestParseHashesInvalidHex(t *testing.T) {
fv := &FlagVars{HashValue: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"}
_, _, err := parseHashes(fv)
if err == nil {
t.Fatal("非 hex 字符应返回错误")
}
}
// TestParseHashesMissingFile hash 文件不存在 → error
func TestParseHashesMissingFile(t *testing.T) {
fv := &FlagVars{HashFile: "nonexistent-hashes.txt"}
_, _, err := parseHashes(fv)
if err == nil {
t.Fatal("缺失 hash 文件应返回错误")
}
}
// ---------------------------------------------------------------------------
// parseUsernames
// ---------------------------------------------------------------------------
// TestParseUsernamesEmpty 空输入返回空结果
func TestParseUsernamesEmpty(t *testing.T) {
fv := &FlagVars{}
got, err := parseUsernames(fv)
if err != nil {
t.Fatalf("parseUsernames error = %v", err)
}
if len(got) != 0 {
t.Fatalf("空输入应返回空, got %v", got)
}
}
// TestParseUsernamesCommaSeparated 逗号分隔多用户
func TestParseUsernamesCommaSeparated(t *testing.T) {
fv := &FlagVars{Username: "admin, root, admin"} // 含重复和空格
got, err := parseUsernames(fv)
if err != nil {
t.Fatalf("parseUsernames error = %v", err)
}
want := []string{"admin", "root"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
// TestParseUsernamesAddUsers AddUsers 追加去重
func TestParseUsernamesAddUsers(t *testing.T) {
fv := &FlagVars{
Username: "admin",
AddUsers: "root,admin", // admin 重复
}
got, err := parseUsernames(fv)
if err != nil {
t.Fatalf("parseUsernames error = %v", err)
}
want := []string{"admin", "root"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
// TestParseUsernamesMissingFile 缺失用户文件 → error
func TestParseUsernamesMissingFile(t *testing.T) {
fv := &FlagVars{UsersFile: "nonexistent-users.txt"}
_, err := parseUsernames(fv)
if err == nil {
t.Fatal("缺失用户文件应返回错误")
}
}
// ---------------------------------------------------------------------------
// cloneStringSlice
// ---------------------------------------------------------------------------
// TestCloneStringSliceNil nil 输入返回 nil
func TestCloneStringSliceNil(t *testing.T) {
got := cloneStringSlice(nil)
if got != nil {
t.Fatalf("nil 输入应返回 nil, got %v", got)
}
}
// TestCloneStringSliceEmpty 空切片:append 无元素结果为 nillen 为 0
func TestCloneStringSliceEmpty(t *testing.T) {
got := cloneStringSlice([]string{})
if len(got) != 0 {
t.Fatalf("got len %d, want 0", len(got))
}
}
// TestCloneStringSliceCopiesValues 正常切片:值正确且独立
func TestCloneStringSliceCopiesValues(t *testing.T) {
src := []string{"a", "b", "c"}
got := cloneStringSlice(src)
if !reflect.DeepEqual(got, src) {
t.Fatalf("got %v, want %v", got, src)
}
// 修改 clone 不影响原始
got[0] = "mutated"
if src[0] != "a" {
t.Fatal("cloneStringSlice 返回的切片与源共享底层数组")
}
}
+5 -2
View File
@@ -32,6 +32,7 @@ type Config struct {
DisableBrute bool // 禁用暴力破解 DisableBrute bool // 禁用暴力破解
DisablePing bool // 禁用Ping检测 DisablePing bool // 禁用Ping检测
DisableTcpProbe bool // 禁用TCP补充探测 DisableTcpProbe bool // 禁用TCP补充探测
DisableSubnetProbe bool // 禁用网段预筛
// 扫描模式 // 扫描模式
Mode string // 扫描模式 Mode string // 扫描模式
@@ -62,7 +63,8 @@ type Config struct {
Target TargetConfig // 扫描目标配置 Target TargetConfig // 扫描目标配置
// 全局超时 // 全局超时
GlobalTimeout time.Duration GlobalTimeout time.Duration
GlobalTimeoutExplicit bool
// SOCKS5代理端口配置 // SOCKS5代理端口配置
Socks5ProxyPort int // SOCKS5代理端口 Socks5ProxyPort int // SOCKS5代理端口
@@ -200,7 +202,8 @@ func NewConfig() *Config {
ModuleThreadNum: 10, ModuleThreadNum: 10,
DisableBrute: false, DisableBrute: false,
DisablePing: false, DisablePing: false,
DisableTcpProbe: false, DisableTcpProbe: false,
DisableSubnetProbe: false,
// 扫描模式 // 扫描模式
Mode: DefaultScanMode, Mode: DefaultScanMode,
+3
View File
@@ -113,6 +113,7 @@ func Flag(Info *HostInfo) error {
flag.Int64Var(&fv.GlobalTimeout, "gt", 180, i18n.GetText("flag_global_timeout")) flag.Int64Var(&fv.GlobalTimeout, "gt", 180, i18n.GetText("flag_global_timeout"))
flag.BoolVar(&fv.DisablePing, "np", false, i18n.GetText("flag_disable_ping")) flag.BoolVar(&fv.DisablePing, "np", false, i18n.GetText("flag_disable_ping"))
flag.BoolVar(&fv.DisableTcpProbe, "ntp", false, i18n.GetText("flag_disable_tcp_probe")) flag.BoolVar(&fv.DisableTcpProbe, "ntp", false, i18n.GetText("flag_disable_tcp_probe"))
flag.BoolVar(&fv.DisableSubnetProbe, "nsp", false, i18n.GetText("flag_disable_subnet_probe"))
flag.StringVar(&fv.LocalPlugin, "local", "", i18n.GetText("flag_local_plugin")) flag.StringVar(&fv.LocalPlugin, "local", "", i18n.GetText("flag_local_plugin"))
flag.BoolVar(&fv.AliveOnly, "ao", false, i18n.GetText("flag_alive_only")) flag.BoolVar(&fv.AliveOnly, "ao", false, i18n.GetText("flag_alive_only"))
@@ -225,6 +226,8 @@ func Flag(Info *HostInfo) error {
fv.ModuleThreadNumExplicit = true fv.ModuleThreadNumExplicit = true
case "retry": case "retry":
fv.MaxRetriesExplicit = true fv.MaxRetriesExplicit = true
case "gt":
fv.GlobalTimeoutExplicit = true
case "icmp-rate": case "icmp-rate":
fv.ICMPRateExplicit = true fv.ICMPRateExplicit = true
case "num": case "num":
+5 -1
View File
@@ -39,8 +39,10 @@ type FlagVars struct {
TimeoutSec int64 // 秒,需转换为 time.Duration TimeoutSec int64 // 秒,需转换为 time.Duration
TimeoutExplicit bool TimeoutExplicit bool
GlobalTimeout int64 GlobalTimeout int64
GlobalTimeoutExplicit bool
DisablePing bool DisablePing bool
DisableTcpProbe bool DisableTcpProbe bool
DisableSubnetProbe bool
LocalPlugin string LocalPlugin string
AliveOnly bool AliveOnly bool
DisableBrute bool DisableBrute bool
@@ -150,6 +152,7 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
DisableBrute: fv.DisableBrute, DisableBrute: fv.DisableBrute,
DisablePing: fv.DisablePing, DisablePing: fv.DisablePing,
DisableTcpProbe: fv.DisableTcpProbe, DisableTcpProbe: fv.DisableTcpProbe,
DisableSubnetProbe: fv.DisableSubnetProbe,
// 扫描模式 // 扫描模式
Mode: fv.ScanMode, Mode: fv.ScanMode,
@@ -169,7 +172,8 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
DefaultMap: cloneStringSlice(config.DefaultProbeMap), DefaultMap: cloneStringSlice(config.DefaultProbeMap),
// 全局超时 // 全局超时
GlobalTimeout: time.Duration(fv.GlobalTimeout) * time.Second, GlobalTimeout: time.Duration(fv.GlobalTimeout) * time.Second,
GlobalTimeoutExplicit: fv.GlobalTimeoutExplicit,
// SOCKS5代理端口 // SOCKS5代理端口
Socks5ProxyPort: fv.Socks5ProxyPort, Socks5ProxyPort: fv.Socks5ProxyPort,
+1 -1
View File
@@ -69,7 +69,7 @@ const (
// 版本信息,通过 ldflags 注入 // 版本信息,通过 ldflags 注入
var ( var (
version = "2.2.0-rc.1" version = "2.2.0"
commit = "unknown" commit = "unknown"
date = "unknown" date = "unknown"
) )
+6
View File
@@ -26,10 +26,16 @@ flag_module_thread_num:
other: "Module thread count" other: "Module thread count"
flag_global_timeout: flag_global_timeout:
other: "Global timeout" other: "Global timeout"
global_timeout_adjusted:
other: "Large scan detected, global timeout adjusted from {{.V0}}s to {{.V1}}s (use -gt to override)"
global_timeout_exceeded:
other: "Global timeout reached (-gt {{.V0}}s), scan aborted. Use -gt to increase or set to 0 to disable"
flag_disable_ping: flag_disable_ping:
other: "Disable ping detection" other: "Disable ping detection"
flag_disable_tcp_probe: flag_disable_tcp_probe:
other: "Disable TCP supplementary probe" other: "Disable TCP supplementary probe"
flag_disable_subnet_probe:
other: "Disable subnet pre-filter (optimization that skips empty /24 subnets in large scans)"
flag_local_plugin: flag_local_plugin:
other: "Specify local plugin name (e.g.: cleaner, systeminfo, keylogger)" other: "Specify local plugin name (e.g.: cleaner, systeminfo, keylogger)"
flag_debug: flag_debug:
+6
View File
@@ -26,10 +26,16 @@ flag_module_thread_num:
other: "模块线程数" other: "模块线程数"
flag_global_timeout: flag_global_timeout:
other: "全局超时时间" other: "全局超时时间"
global_timeout_adjusted:
other: "扫描规模较大,全局超时从 {{.V0}}s 自动调整为 {{.V1}}s(可用 -gt 手动指定)"
global_timeout_exceeded:
other: "全局超时已到(-gt {{.V0}}s),扫描被终止。大规模扫描请用 -gt 调大超时或设为 0 禁用"
flag_disable_ping: flag_disable_ping:
other: "禁用ping探测" other: "禁用ping探测"
flag_disable_tcp_probe: flag_disable_tcp_probe:
other: "禁用TCP补充探测" other: "禁用TCP补充探测"
flag_disable_subnet_probe:
other: "禁用网段预筛(大规模扫描时跳过空 /24 网段的优化)"
flag_local_plugin: flag_local_plugin:
other: "指定本地插件名称 (如: cleaner, systeminfo, keylogger 等)" other: "指定本地插件名称 (如: cleaner, systeminfo, keylogger 等)"
flag_debug: flag_debug:
+3
View File
@@ -98,6 +98,9 @@ func (w *StdoutNDJSONWriter) flatten(r *ScanResult) *ndjsonRecord {
rec.Title = strVal(d, "title") rec.Title = strVal(d, "title")
rec.URL = strVal(d, "url") rec.URL = strVal(d, "url")
rec.Vulnerability = strVal(d, "vulnerability") rec.Vulnerability = strVal(d, "vulnerability")
if rec.Vulnerability == "" {
rec.Vulnerability = strVal(d, "vulnerability_name")
}
rec.Username = strVal(d, "username") rec.Username = strVal(d, "username")
rec.Password = strVal(d, "password") rec.Password = strVal(d, "password")
rec.Plugin = strVal(d, "plugin") rec.Plugin = strVal(d, "plugin")
+10 -1
View File
@@ -283,6 +283,9 @@ func (w *TXTWriter) formatVulnLine(result *ScanResult) string {
} }
vuln := w.getDetailStr(result, "vulnerability") vuln := w.getDetailStr(result, "vulnerability")
if vuln == "" {
vuln = w.getDetailStr(result, "vulnerability_name")
}
if vuln != "" { if vuln != "" {
return fmt.Sprintf("%s %s", result.Target, vuln) return fmt.Sprintf("%s %s", result.Target, vuln)
} }
@@ -789,12 +792,18 @@ func formatFingerprints(value interface{}) string {
func (w *CSVWriter) formatVulnRecord(result *ScanResult) []string { func (w *CSVWriter) formatVulnRecord(result *ScanResult) []string {
vulnType := "" vulnType := ""
vulnName := result.Status
if result.Details != nil { if result.Details != nil {
if t, ok := result.Details["type"].(string); ok { if t, ok := result.Details["type"].(string); ok {
vulnType = t vulnType = t
} }
if v, ok := result.Details["vulnerability"].(string); ok && v != "" {
vulnName = v
} else if v, ok := result.Details["vulnerability_name"].(string); ok && v != "" {
vulnName = v
}
} }
return []string{result.Target, vulnType, result.Status} return []string{result.Target, vulnType, vulnName}
} }
// GetFormat 获取格式类型 // GetFormat 获取格式类型
+417
View File
@@ -1728,3 +1728,420 @@ func TestManager_ConcurrentSave(t *testing.T) {
t.Logf("✓ 并发保存测试通过(%d个goroutine,每个%d次,输出%d行)", t.Logf("✓ 并发保存测试通过(%d个goroutine,每个%d次,输出%d行)",
numGoroutines, savesPerGoroutine, len(lines)) numGoroutines, savesPerGoroutine, len(lines))
} }
// =============================================================================
// TXTWriter - 内部格式化函数覆盖率测试
// =============================================================================
// newTestTXTWriter 创建用于单元测试的 TXTWriter(写到临时文件,调用方负责 Close)
func newTestTXTWriter(t *testing.T) *TXTWriter {
t.Helper()
w, err := NewTXTWriter(filepath.Join(t.TempDir(), "unit.txt"))
if err != nil {
t.Fatalf("创建 TXTWriter 失败: %v", err)
}
return w
}
// TestFormatServiceLine 覆盖 formatServiceLine 的各分支
func TestFormatServiceLine(t *testing.T) {
w := newTestTXTWriter(t)
defer w.Close()
tests := []struct {
name string
details map[string]interface{}
want []string // 输出中必须包含的子串
notwant []string // 输出中不应包含的子串
}{
{
name: "非web服务带service和banner",
details: map[string]interface{}{
"port": 22,
"service": "ssh",
"banner": "OpenSSH_8.0",
},
want: []string{"ssh", "OpenSSH_8.0"},
notwant: []string{"http://", "https://"},
},
{
name: "非web服务只有service",
details: map[string]interface{}{
"port": 3306,
"service": "mysql",
},
want: []string{"mysql"},
notwant: []string{"http://"},
},
{
name: "非web服务无banner",
details: map[string]interface{}{
"port": 21,
"service": "ftp",
},
want: []string{"ftp"},
},
{
name: "service=http 走 web 分支",
details: map[string]interface{}{
"port": 80,
"service": "http",
"title": "Home",
"status": 200,
},
want: []string{"http://", "Home"},
notwant: []string{"ssh"},
},
{
name: "service=https 走 web 分支",
details: map[string]interface{}{
"port": 443,
"service": "https",
"title": "Secure",
"status": 200,
},
want: []string{"https://", "Secure"},
},
{
name: "is_web=true 走 web 分支",
details: map[string]interface{}{
"port": 8080,
"is_web": true,
"title": "Dashboard",
"status": 302,
},
want: []string{"http://", "Dashboard"},
},
{
name: "有 status 字段触发 web 分支",
details: map[string]interface{}{
"port": 8080,
"status": 200,
},
want: []string{"http://"},
},
{
name: "有 server 字段触发 web 分支",
details: map[string]interface{}{
"port": 8080,
"server": "nginx",
},
want: []string{"http://", "nginx"},
},
{
name: "banner 含控制字符被转义",
details: map[string]interface{}{
"port": 9999,
"service": "custom",
"banner": "hello\nworld\r\n",
},
want: []string{"\\n", "\\r"},
notwant: []string{"http://"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &ScanResult{
Target: "192.168.1.1",
Type: TypeService,
Details: tt.details,
}
got := w.formatServiceLine(result)
for _, s := range tt.want {
if !strings.Contains(got, s) {
t.Errorf("formatServiceLine() = %q,缺少 %q", got, s)
}
}
for _, s := range tt.notwant {
if strings.Contains(got, s) {
t.Errorf("formatServiceLine() = %q,不应含 %q", got, s)
}
}
})
}
}
// TestGetFingerprints 覆盖 getFingerprints 的各类型分支
func TestGetFingerprints(t *testing.T) {
w := newTestTXTWriter(t)
defer w.Close()
tests := []struct {
name string
details map[string]interface{}
want string
}{
{
name: "nil fingerprints",
details: map[string]interface{}{},
want: "",
},
{
name: "[]string 非空",
details: map[string]interface{}{"fingerprints": []string{"nginx", "php"}},
want: "[nginx,php]",
},
{
name: "[]string 空slice",
details: map[string]interface{}{"fingerprints": []string{}},
want: "",
},
{
name: "[]interface{} 非空",
details: map[string]interface{}{"fingerprints": []interface{}{"wordpress", "jquery"}},
want: "[wordpress,jquery]",
},
{
name: "[]interface{} 含数字",
details: map[string]interface{}{"fingerprints": []interface{}{"apache", 2}},
want: "[apache,2]",
},
{
name: "[]interface{} 空slice",
details: map[string]interface{}{"fingerprints": []interface{}{}},
want: "",
},
{
name: "不支持的类型返回空",
details: map[string]interface{}{"fingerprints": "just-a-string"},
want: "",
},
{
name: "单个元素",
details: map[string]interface{}{"fingerprints": []string{"tomcat"}},
want: "[tomcat]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &ScanResult{Target: "1.2.3.4", Details: tt.details}
got := w.getFingerprints(result)
if got != tt.want {
t.Errorf("getFingerprints() = %qwant %q", got, tt.want)
}
})
}
}
// TestFormatVulnLine 覆盖 formatVulnLine 的各分支
func TestFormatVulnLine(t *testing.T) {
w := newTestTXTWriter(t)
defer w.Close()
tests := []struct {
name string
target string
status string
details map[string]interface{}
want string
}{
{
name: "weak_credential 带 service",
target: "192.168.1.1:22",
details: map[string]interface{}{
"type": "weak_credential",
"service": "ssh",
"username": "root",
"password": "123456",
},
want: "192.168.1.1:22 ssh root/123456",
},
{
name: "weak_credential 不带 service",
target: "192.168.1.1:3306",
details: map[string]interface{}{
"type": "weak_credential",
"username": "admin",
"password": "pass",
},
want: "192.168.1.1:3306 admin/pass",
},
{
name: "有 vulnerability 字段",
target: "10.0.0.1",
details: map[string]interface{}{
"type": "poc",
"vulnerability": "CVE-2024-1234",
},
want: "10.0.0.1 CVE-2024-1234",
},
{
name: "无 vulnerability 字段回退到 status",
target: "10.0.0.2",
status: "VULNERABLE",
details: map[string]interface{}{
"type": "unknown",
},
want: "10.0.0.2 VULNERABLE",
},
{
name: "空 details 回退到 status",
target: "10.0.0.3",
status: "poc_hit",
details: map[string]interface{}{},
want: "10.0.0.3 poc_hit",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &ScanResult{
Target: tt.target,
Status: tt.status,
Type: TypeVuln,
Details: tt.details,
}
got := w.formatVulnLine(result)
if got != tt.want {
t.Errorf("formatVulnLine() = %qwant %q", got, tt.want)
}
})
}
}
// TestIsWebService 覆盖 isWebService 的各判断分支
func TestIsWebService(t *testing.T) {
w := newTestTXTWriter(t)
defer w.Close()
tests := []struct {
name string
details map[string]interface{}
want bool
}{
{
name: "is_web=true",
details: map[string]interface{}{"is_web": true},
want: true,
},
{
name: "is_web=false 无其他标志",
details: map[string]interface{}{"is_web": false},
want: false,
},
{
name: "有 status 字段",
details: map[string]interface{}{"status": 200},
want: true,
},
{
name: "status=nil 不触发",
details: map[string]interface{}{},
want: false,
},
{
name: "有非空 server 字段",
details: map[string]interface{}{"server": "nginx"},
want: true,
},
{
name: "空 server 字段不触发",
details: map[string]interface{}{"server": ""},
want: false,
},
{
name: "service=http",
details: map[string]interface{}{"service": "http"},
want: true,
},
{
name: "service=https",
details: map[string]interface{}{"service": "https"},
want: true,
},
{
name: "service=ssh 不是 web",
details: map[string]interface{}{"service": "ssh"},
want: false,
},
{
name: "nil Details",
details: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &ScanResult{Target: "1.2.3.4", Details: tt.details}
got := w.isWebService(result)
if got != tt.want {
t.Errorf("isWebService() = %vwant %v", got, tt.want)
}
})
}
}
// TestWebProtocol 覆盖 webProtocol 的各判断分支
func TestWebProtocol(t *testing.T) {
w := newTestTXTWriter(t)
defer w.Close()
tests := []struct {
name string
target string
details map[string]interface{}
want string
}{
{
name: "protocol=https 直接返回",
target: "1.2.3.4:8443",
details: map[string]interface{}{"protocol": "https"},
want: "https",
},
{
name: "protocol=http 直接返回",
target: "1.2.3.4:8080",
details: map[string]interface{}{"protocol": "http"},
want: "http",
},
{
name: "protocol=HTTPS 大小写不敏感",
target: "1.2.3.4:443",
details: map[string]interface{}{"protocol": "HTTPS"},
want: "https",
},
{
name: "service=https 回退",
target: "1.2.3.4:8080",
details: map[string]interface{}{"service": "https"},
want: "https",
},
{
name: "target 含 :443 回退 https",
target: "example.com:443",
details: map[string]interface{}{},
want: "https",
},
{
name: "无任何标志默认 http",
target: "1.2.3.4:8080",
details: map[string]interface{}{},
want: "http",
},
{
name: "service=http 默认 http",
target: "1.2.3.4:80",
details: map[string]interface{}{"service": "http"},
want: "http",
},
{
name: "protocol 为其他值走 service 分支",
target: "1.2.3.4:9000",
details: map[string]interface{}{"protocol": "tcp", "service": "https"},
want: "https",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &ScanResult{Target: tt.target, Details: tt.details}
got := w.webProtocol(result, tt.target)
if got != tt.want {
t.Errorf("webProtocol() = %qwant %q", got, tt.want)
}
})
}
}
+18
View File
@@ -105,6 +105,24 @@ func TestInitOutputValidationAndDefaultExtension(t *testing.T) {
} }
} }
func TestCloseOutputWithStdoutWriter(t *testing.T) {
preserveOutputAPIGlobals(t)
// 初始化 silent 模式以创建 StdoutWriter
flagVars = &FlagVars{Silent: true, DisableSave: true}
if err := InitOutput(); err != nil {
t.Fatalf("InitOutput silent error = %v", err)
}
if StdoutWriter == nil {
t.Fatal("StdoutWriter 应在 Silent 模式下被初始化")
}
// CloseOutput 应正常关闭 StdoutWriter
if err := CloseOutput(); err != nil {
t.Fatalf("CloseOutput with StdoutWriter error = %v", err)
}
}
func TestSaveResultFacadeCallbackAndDisabledSave(t *testing.T) { func TestSaveResultFacadeCallbackAndDisabledSave(t *testing.T) {
preserveOutputAPIGlobals(t) preserveOutputAPIGlobals(t)
+87
View File
@@ -488,3 +488,90 @@ func ipToUint32(ip net.IP) (uint32, bool) {
func uint32ToIP(v uint32) string { func uint32ToIP(v uint32) string {
return fmt.Sprintf("%d.%d.%d.%d", byte(v>>24), byte(v>>16), byte(v>>8), byte(v)) return fmt.Sprintf("%d.%d.%d.%d", byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
} }
// EstimateHostCount 快速估算主机总数(不消费 iterator)
func EstimateHostCount(host string, filename string) int64 {
var total int64
if filename != "" {
if f, err := os.Open(filename); err == nil {
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
total += estimateHostEntry(line)
}
_ = f.Close()
}
}
for _, h := range strings.Split(host, ",") {
h = strings.TrimSpace(h)
if h != "" {
total += estimateHostEntry(h)
}
}
return total
}
func estimateHostEntry(entry string) int64 {
switch {
case entry == "192":
return 65536 // /16
case entry == "172":
return 1 << 20 // /12
case entry == "10":
return 1 << 24 // /8
case strings.Contains(entry, "/"):
_, ipNet, err := net.ParseCIDR(entry)
if err != nil {
return 1
}
ones, bits := ipNet.Mask.Size()
if bits != 32 {
return 1
}
size := int64(1) << uint(32-ones)
if size > 2 {
size -= 2
}
return size
case strings.Contains(entry, "-") && !strings.Contains(entry, ":") && looksLikeIPRange(entry):
parts := strings.SplitN(entry, "-", 2)
startIP := net.ParseIP(strings.TrimSpace(parts[0]))
if startIP == nil {
return 1
}
startU, ok := ipToUint32(startIP)
if !ok {
return 1
}
endStr := strings.TrimSpace(parts[1])
var endU uint32
if len(endStr) < 4 || !strings.Contains(endStr, ".") {
n, err := strconv.Atoi(endStr)
if err != nil || n > 255 {
return 1
}
endU = (startU & 0xFFFFFF00) | uint32(n)
} else {
endIP := net.ParseIP(endStr)
if endIP == nil {
return 1
}
endU, ok = ipToUint32(endIP)
if !ok {
return 1
}
}
if endU < startU {
return 1
}
return int64(endU-startU) + 1
default:
return 1
}
}
+776
View File
@@ -1,8 +1,10 @@
package parsers package parsers
import ( import (
"bufio"
"context" "context"
"errors" "errors"
"net"
"os" "os"
"reflect" "reflect"
"strings" "strings"
@@ -166,3 +168,777 @@ func (s *closeTrackingSource) Close() error {
s.closed = true s.closed = true
return s.err return s.err
} }
// =============================================================================
// newHostSource 分支覆盖
// =============================================================================
// TestNewHostSource_Shortcuts 验证 192/172/10 快捷方式展开为正确 CIDR
func TestNewHostSource_Shortcuts(t *testing.T) {
cases := []struct {
input string
wantFirst string
}{
{"192", "192.168.0.1"},
{"172", "172.16.0.1"},
{"10", "10.0.0.1"},
}
for _, c := range cases {
t.Run(c.input, func(t *testing.T) {
src, err := newHostSource(c.input)
if err != nil {
t.Fatalf("newHostSource(%q) error = %v", c.input, err)
}
defer src.Close()
host, ok, err := src.Next()
if err != nil || !ok {
t.Fatalf("Next() = %q/%v/%v", host, ok, err)
}
if host != c.wantFirst {
t.Errorf("first host = %q, 期望 %q", host, c.wantFirst)
}
})
}
}
// TestNewHostSource_CIDRBranch 验证含 "/" 走 CIDR 分支
func TestNewHostSource_CIDRBranch(t *testing.T) {
src, err := newHostSource("10.0.0.0/30")
if err != nil {
t.Fatalf("newHostSource CIDR error = %v", err)
}
defer src.Close()
host, ok, _ := src.Next()
if !ok || host != "10.0.0.1" {
t.Errorf("CIDR first host = %q, 期望 10.0.0.1", host)
}
}
// TestNewHostSource_InvalidCIDR 无效 CIDR 返回错误
func TestNewHostSource_InvalidCIDR(t *testing.T) {
_, err := newHostSource("999.0.0.0/24")
if err == nil {
t.Error("无效 CIDR 应返回 error")
}
}
// TestNewHostSource_RangeBranch 验证 a-b 格式走 range 分支
func TestNewHostSource_RangeBranch(t *testing.T) {
src, err := newHostSource("192.168.1.5-192.168.1.7")
if err != nil {
t.Fatalf("newHostSource range error = %v", err)
}
defer src.Close()
var got []string
for {
h, ok, err := src.Next()
if err != nil {
t.Fatalf("Next() error = %v", err)
}
if !ok {
break
}
got = append(got, h)
}
want := []string{"192.168.1.5", "192.168.1.6", "192.168.1.7"}
if !reflect.DeepEqual(got, want) {
t.Errorf("range hosts = %v, 期望 %v", got, want)
}
}
// TestNewHostSource_RangeShortTail 验证短尾写法 x.x.x.a-b
func TestNewHostSource_RangeShortTail(t *testing.T) {
src, err := newHostSource("10.0.0.3-5")
if err != nil {
t.Fatalf("newHostSource short-tail range error = %v", err)
}
defer src.Close()
var got []string
for {
h, ok, err := src.Next()
if err != nil {
t.Fatalf("Next() error = %v", err)
}
if !ok {
break
}
got = append(got, h)
}
want := []string{"10.0.0.3", "10.0.0.4", "10.0.0.5"}
if !reflect.DeepEqual(got, want) {
t.Errorf("short-tail range = %v, 期望 %v", got, want)
}
}
// TestNewHostSource_SingleHost 验证普通主机名走 singleHostSource 分支
func TestNewHostSource_SingleHost(t *testing.T) {
src, err := newHostSource("example.com")
if err != nil {
t.Fatalf("newHostSource single error = %v", err)
}
defer src.Close()
host, ok, err := src.Next()
if err != nil || !ok || host != "example.com" {
t.Errorf("single host = %q/%v/%v, 期望 example.com/true/nil", host, ok, err)
}
// 第二次应该耗尽
_, ok, _ = src.Next()
if ok {
t.Error("singleHostSource 第二次 Next 应返回 ok=false")
}
}
// =============================================================================
// hostMatcher.add 分支覆盖
// =============================================================================
// TestHostMatcherAdd_192Shortcut 验证 add("192") 展开为 192.168.0.0/16
func TestHostMatcherAdd_192Shortcut(t *testing.T) {
m := newHostMatcher()
if err := m.add("192"); err != nil {
t.Fatalf("add(192) error = %v", err)
}
if !m.match("192.168.1.100") {
t.Error("192.168.1.100 应命中 192.168.0.0/16")
}
if m.match("10.0.0.1") {
t.Error("10.0.0.1 不应命中")
}
}
// TestHostMatcherAdd_172Shortcut 验证 add("172")
func TestHostMatcherAdd_172Shortcut(t *testing.T) {
m := newHostMatcher()
if err := m.add("172"); err != nil {
t.Fatalf("add(172) error = %v", err)
}
if !m.match("172.16.0.1") {
t.Error("172.16.0.1 应命中 172.16.0.0/12")
}
}
// TestHostMatcherAdd_10Shortcut 验证 add("10")
func TestHostMatcherAdd_10Shortcut(t *testing.T) {
m := newHostMatcher()
if err := m.add("10"); err != nil {
t.Fatalf("add(10) error = %v", err)
}
if !m.match("10.1.2.3") {
t.Error("10.1.2.3 应命中 10.0.0.0/8")
}
}
// TestHostMatcherAdd_CIDR 验证 add 处理 CIDR 字符串
func TestHostMatcherAdd_CIDR(t *testing.T) {
m := newHostMatcher()
if err := m.add("192.168.5.0/24"); err != nil {
t.Fatalf("add CIDR error = %v", err)
}
if !m.match("192.168.5.10") {
t.Error("192.168.5.10 应命中 /24")
}
if m.match("192.168.6.10") {
t.Error("192.168.6.10 不应命中")
}
}
// TestHostMatcherAdd_Range 验证 add 处理 a-b 范围
func TestHostMatcherAdd_Range(t *testing.T) {
m := newHostMatcher()
if err := m.add("10.0.0.10-10.0.0.20"); err != nil {
t.Fatalf("add range error = %v", err)
}
if !m.match("10.0.0.15") {
t.Error("10.0.0.15 应命中范围")
}
if m.match("10.0.0.9") || m.match("10.0.0.21") {
t.Error("边界外不应命中")
}
}
// TestHostMatcherAdd_ExactHost 验证 add 处理普通主机名(exact 分支)
func TestHostMatcherAdd_ExactHost(t *testing.T) {
m := newHostMatcher()
if err := m.add("myhost.local"); err != nil {
t.Fatalf("add exact error = %v", err)
}
if !m.match("myhost.local") {
t.Error("exact 主机名应命中")
}
if m.match("other.local") {
t.Error("其他主机名不应命中")
}
}
// TestHostMatcherAdd_MultipleComma 验证逗号分隔多个值
func TestHostMatcherAdd_MultipleComma(t *testing.T) {
m := newHostMatcher()
if err := m.add("host1.com, host2.com, 192.168.1.0/30"); err != nil {
t.Fatalf("add comma-separated error = %v", err)
}
if !m.match("host1.com") || !m.match("host2.com") || !m.match("192.168.1.1") {
t.Error("逗号分隔的值应全部命中")
}
}
// TestHostMatcherAdd_EmptyEntry 逗号中间空串不报错
func TestHostMatcherAdd_EmptyEntry(t *testing.T) {
m := newHostMatcher()
if err := m.add(",,,"); err != nil {
t.Fatalf("全空逗号不应报错: %v", err)
}
}
// TestHostMatcherAdd_InvalidCIDR 无效 CIDR 返回 error
func TestHostMatcherAdd_InvalidCIDR(t *testing.T) {
m := newHostMatcher()
if err := m.add("999.0.0.0/8"); err == nil {
t.Error("无效 CIDR 应返回 error")
}
}
// =============================================================================
// fileHostSource.Next 分支覆盖
// =============================================================================
// TestFileHostSourceNext_SkipsEmptyAndComments 验证空行和注释行被跳过
func TestFileHostSourceNext_SkipsEmptyAndComments(t *testing.T) {
dir := t.TempDir()
path := dir + "/hosts.txt"
content := "\n# this is a comment\n\n \n10.0.0.1\n# another comment\n10.0.0.2\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile error = %v", err)
}
iter, err := NewHostIterator("", path)
if err != nil {
t.Fatalf("NewHostIterator error = %v", err)
}
defer iter.Close()
batch, err := iter.NextBatch(context.Background(), 10)
if err != nil {
t.Fatalf("NextBatch error = %v", err)
}
want := []string{"10.0.0.1", "10.0.0.2"}
if !reflect.DeepEqual(batch, want) {
t.Errorf("batch = %v, 期望 %v", batch, want)
}
}
// TestFileHostSourceNext_MultipleSources 验证文件中每行多个 host(逗号分隔)走 multiHostSource 分支
func TestFileHostSourceNext_MultipleSources(t *testing.T) {
dir := t.TempDir()
path := dir + "/hosts.txt"
// 一行两个 host,触发 multiHostSource 分支
content := "10.0.0.1,10.0.0.2\n10.0.0.3\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile error = %v", err)
}
iter, err := NewHostIterator("", path)
if err != nil {
t.Fatalf("NewHostIterator error = %v", err)
}
defer iter.Close()
batch, err := iter.NextBatch(context.Background(), 10)
if err != nil {
t.Fatalf("NextBatch error = %v", err)
}
want := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}
if !reflect.DeepEqual(batch, want) {
t.Errorf("batch = %v, 期望 %v", batch, want)
}
}
// TestFileHostSourceNext_InvalidLineSkipped 无效行(解析失败)被跳过不报错
func TestFileHostSourceNext_InvalidLineSkipped(t *testing.T) {
dir := t.TempDir()
path := dir + "/hosts.txt"
// 包含无效 CIDR,应被跳过
content := "999.0.0.0/8\n10.0.0.1\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile error = %v", err)
}
iter, err := NewHostIterator("", path)
if err != nil {
t.Fatalf("NewHostIterator error = %v", err)
}
defer iter.Close()
batch, err := iter.NextBatch(context.Background(), 10)
if err != nil {
t.Fatalf("NextBatch error = %v", err)
}
// 无效行被跳过,只返回有效行
if len(batch) != 1 || batch[0] != "10.0.0.1" {
t.Errorf("batch = %v, 期望 [10.0.0.1]", batch)
}
}
// =============================================================================
// NewHostIterator 错误路径
// =============================================================================
// TestNewHostIterator_InvalidFilename 不存在的文件应返回 error
func TestNewHostIterator_InvalidFilename(t *testing.T) {
_, err := NewHostIterator("", "/nonexistent/path/hosts.txt")
if err == nil {
t.Error("不存在的文件应返回 error")
}
}
// TestNewHostIterator_InvalidHost host 解析失败时应返回 error(并关闭已打开的文件 source)
func TestNewHostIterator_InvalidHost_WithFile(t *testing.T) {
dir := t.TempDir()
path := dir + "/hosts.txt"
if err := os.WriteFile(path, []byte("10.0.0.1\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
// 无效 CIDR 会让 newHostSources 失败
_, err := NewHostIterator("999.0.0.0/8", path)
if err == nil {
t.Error("无效 host 应返回 error")
}
}
// TestNewHostIterator_InvalidExclude exclude 参数无效时应返回 error
func TestNewHostIterator_InvalidExclude(t *testing.T) {
_, err := NewHostIterator("10.0.0.1", "", "999.0.0.0/8")
if err == nil {
t.Error("无效 exclude 应返回 error")
}
}
// TestNewHostIterator_EmptyExcludeSkipped 空白 exclude 条目应被跳过,不报错
func TestNewHostIterator_EmptyExcludeSkipped(t *testing.T) {
iter, err := NewHostIterator("10.0.0.1", "", " ", "")
if err != nil {
t.Fatalf("空白 exclude 不应报错: %v", err)
}
defer iter.Close()
host, ok, err := iter.Next()
if err != nil || !ok || host != "10.0.0.1" {
t.Errorf("Next() = %q/%v/%v", host, ok, err)
}
}
// =============================================================================
// Close 路径
// =============================================================================
// TestClose_Nil nil HostIterator Close 不 panic
func TestClose_Nil(t *testing.T) {
var it *HostIterator
if err := it.Close(); err != nil {
t.Errorf("nil Close 应返回 nil, 得到 %v", err)
}
}
// TestClose_WithCurrent 有 current source 时 Close 应关闭它
func TestClose_WithCurrent(t *testing.T) {
src := &closeTrackingSource{}
it := &HostIterator{current: src}
if err := it.Close(); err != nil {
t.Errorf("Close error = %v", err)
}
if !src.closed {
t.Error("current source 应被关闭")
}
if it.current != nil {
t.Error("Close 后 current 应为 nil")
}
}
// TestClose_SourcesError Close 中 source 返回 error 应被记录
func TestClose_SourcesError(t *testing.T) {
errSrc := &closeTrackingSource{err: errors.New("close error")}
it := &HostIterator{sources: []hostSource{errSrc}}
err := it.Close()
if err == nil {
t.Error("source Close 失败时应返回 error")
}
if !errSrc.closed {
t.Error("出错的 source 也应被调用 Close")
}
}
// TestClose_CurrentErrorThenSources current Close 报错,后续 source Close 成功,返回 current 的 error
func TestClose_CurrentErrorThenSources(t *testing.T) {
currentSrc := &closeTrackingSource{err: errors.New("current close error")}
otherSrc := &closeTrackingSource{}
it := &HostIterator{
current: currentSrc,
sources: []hostSource{otherSrc},
}
err := it.Close()
if err == nil {
t.Error("应返回 current 的 error")
}
if !currentSrc.closed || !otherSrc.closed {
t.Error("两个 source 都应被关闭")
}
}
// =============================================================================
// Next 错误路径
// =============================================================================
// errorSource 让 Next() 返回 error
type errorSource struct {
err error
}
func (s *errorSource) Next() (string, bool, error) { return "", false, s.err }
func (s *errorSource) Close() error { return nil }
// errorOnCloseSource Next 返回 ok=falseClose 返回 error
type errorOnCloseSource struct {
err error
}
func (s *errorOnCloseSource) Next() (string, bool, error) { return "", false, nil }
func (s *errorOnCloseSource) Close() error { return s.err }
// TestNext_SourceNextError source.Next() 返回 error 时 iter.Next 应透传
func TestNext_SourceNextError(t *testing.T) {
it := &HostIterator{
sources: []hostSource{&errorSource{err: errors.New("next error")}},
}
_, _, err := it.Next()
if err == nil {
t.Error("source Next error 应透传")
}
}
// TestNext_SourceCloseError 源耗尽时 Close 报错应透传
func TestNext_SourceCloseError(t *testing.T) {
it := &HostIterator{
sources: []hostSource{&errorOnCloseSource{err: errors.New("close error")}},
}
_, _, err := it.Next()
if err == nil {
t.Error("source 耗尽时 Close error 应透传")
}
}
// =============================================================================
// NextBatch 边界条件
// =============================================================================
// TestNextBatch_ZeroSize size=0 应使用 DefaultHostBatchSize(实际受源数量限制)
func TestNextBatch_ZeroSize(t *testing.T) {
iter, err := NewHostIterator("10.0.0.1", "")
if err != nil {
t.Fatalf("NewHostIterator: %v", err)
}
defer iter.Close()
// size=0 触发默认 DefaultHostBatchSize 分支,源只有一个 host
batch, err := iter.NextBatch(context.Background(), 0)
if err != nil {
t.Fatalf("NextBatch(0) error = %v", err)
}
if len(batch) != 1 || batch[0] != "10.0.0.1" {
t.Errorf("batch = %v, 期望 [10.0.0.1]", batch)
}
}
// TestNextBatch_NegativeSize size<0 也应使用默认值
func TestNextBatch_NegativeSize(t *testing.T) {
iter, err := NewHostIterator("10.0.0.2", "")
if err != nil {
t.Fatalf("NewHostIterator: %v", err)
}
defer iter.Close()
batch, err := iter.NextBatch(context.Background(), -1)
if err != nil {
t.Fatalf("NextBatch(-1) error = %v", err)
}
if len(batch) != 1 || batch[0] != "10.0.0.2" {
t.Errorf("batch = %v, 期望 [10.0.0.2]", batch)
}
}
// TestNextBatch_ContextCancelled context 取消应立即返回
func TestNextBatch_ContextCancelled(t *testing.T) {
iter, err := NewHostIterator("10.0.0.0/8", "")
if err != nil {
t.Fatalf("NewHostIterator: %v", err)
}
defer iter.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // 立即取消
_, err = iter.NextBatch(ctx, 100)
if err == nil {
t.Error("已取消的 context 应返回 error")
}
}
// TestNextBatch_DeduplicatesHosts 重复 host 只保留一个
func TestNextBatch_DeduplicatesHosts(t *testing.T) {
// 两个相同的单 host source
it := &HostIterator{
sources: []hostSource{
&singleHostSource{host: "10.0.0.1"},
&singleHostSource{host: "10.0.0.1"},
},
}
batch, err := it.NextBatch(context.Background(), 10)
if err != nil {
t.Fatalf("NextBatch error = %v", err)
}
if len(batch) != 1 || batch[0] != "10.0.0.1" {
t.Errorf("batch = %v, 期望去重为 [10.0.0.1]", batch)
}
}
// TestNextBatch_NextError Next 报错时应透传
func TestNextBatch_NextError(t *testing.T) {
it := &HostIterator{
sources: []hostSource{&errorSource{err: errors.New("iter error")}},
}
_, err := it.NextBatch(context.Background(), 10)
if err == nil {
t.Error("Next error 应透传到 NextBatch")
}
}
// =============================================================================
// newRangeHostSource 错误路径
// =============================================================================
// TestNewRangeHostSource_TooManyDashes 超过一个 "-" 应报错(实际按首个切分:a-b-c 被 Split 成 3 段)
func TestNewRangeHostSource_TooManyDashes(t *testing.T) {
// "a-b-c" Split by "-" 得到 3 段,len != 2,应报错
_, err := newRangeHostSource("10.0.0.1-10.0.0.5-extra")
if err == nil {
t.Error("三段格式应报错")
}
}
// TestNewRangeHostSource_InvalidStartIP 起始 IP 无效
func TestNewRangeHostSource_InvalidStartIP(t *testing.T) {
_, err := newRangeHostSource("notanip-10.0.0.5")
if err == nil {
t.Error("无效起始 IP 应报错")
}
}
// TestNewRangeHostSource_InvalidShortTailNonNumeric 短尾不是数字应报错
func TestNewRangeHostSource_InvalidShortTailNonNumeric(t *testing.T) {
// 尾部 "xyz" 不是数字
_, err := newRangeHostSource("10.0.0.1-xyz")
if err == nil {
t.Error("非数字短尾应报错")
}
}
// TestNewRangeHostSource_InvalidShortTailOver255 短尾超过 255 应报错
func TestNewRangeHostSource_InvalidShortTailOver255(t *testing.T) {
_, err := newRangeHostSource("10.0.0.1-300")
if err == nil {
t.Error("短尾 >255 应报错")
}
}
// TestNewRangeHostSource_StartGTEnd 起始 > 结束应报错
func TestNewRangeHostSource_StartGTEnd(t *testing.T) {
_, err := newRangeHostSource("10.0.0.200-10.0.0.100")
if err == nil {
t.Error("start > end 应报错")
}
}
// TestNewRangeHostSource_InvalidFullEndIP 完整结束 IP 无效(如 "10.0.0.999"
func TestNewRangeHostSource_InvalidFullEndIP(t *testing.T) {
// end IP 包含 "." 但无效
_, err := newRangeHostSource("10.0.0.1-10.0.0.999")
if err == nil {
t.Error("无效结束 IP 应报错")
}
}
// TestNewRangeHostSource_ShortTailStartGTEnd 短尾导致 start > end 应报错
func TestNewRangeHostSource_ShortTailStartGTEnd(t *testing.T) {
_, err := newRangeHostSource("10.0.0.200-100")
if err == nil {
t.Error("短尾结果 start > end 应报错")
}
}
// =============================================================================
// hostMatcher.addRange 错误路径
// =============================================================================
// TestAddRange_InvalidRange addRange 传入无效范围应报错
func TestAddRange_InvalidRange(t *testing.T) {
m := newHostMatcher()
if err := m.addRange("notvalid-range"); err == nil {
t.Error("无效 range 应返回 error")
}
}
// TestAddRange_ValidRange addRange 正常路径
func TestAddRange_ValidRange(t *testing.T) {
m := newHostMatcher()
if err := m.addRange("10.0.0.10-10.0.0.20"); err != nil {
t.Fatalf("addRange error = %v", err)
}
if !m.match("10.0.0.10") || !m.match("10.0.0.20") {
t.Error("addRange 边界值应命中")
}
}
// =============================================================================
// hostMatcher.add 错误路径(shortcut 分支中 addCIDR 失败)
// =============================================================================
// TestHostMatcherAdd_InvalidRange add 的 range 格式无效
func TestHostMatcherAdd_InvalidRange(t *testing.T) {
m := newHostMatcher()
// 构造一个 looksLikeIPRange 通过但 newRangeHostSource 失败的字符串
// "10.0.0.200-10.0.0.100" start>end 会报错
if err := m.add("10.0.0.200-10.0.0.100"); err == nil {
t.Error("无效 range (start>end) 应返回 error")
}
}
// =============================================================================
// newCIDRHostSource IPv6 路径
// =============================================================================
// TestNewCIDRHostSource_IPv6Rejected IPv6 CIDR 应报错
func TestNewCIDRHostSource_IPv6Rejected(t *testing.T) {
_, err := newCIDRHostSource("2001:db8::/32")
if err == nil {
t.Error("IPv6 CIDR 应被拒绝")
}
}
// =============================================================================
// fileHostSource.Close 路径
// =============================================================================
// TestFileHostSource_CloseWithCurrent fileHostSource.Close 时 current != nil 分支
func TestFileHostSource_CloseWithCurrent(t *testing.T) {
dir := t.TempDir()
path := dir + "/hosts.txt"
// 写入一个 CIDR,这样 fileHostSource 会持有 current source
if err := os.WriteFile(path, []byte("10.0.0.0/30\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
src, err := newFileHostSource(path)
if err != nil {
t.Fatalf("newFileHostSource: %v", err)
}
// 触发 current 被设置
_, _, _ = src.Next()
// 此时 current 应非 nilClose 应正常关闭它
if err := src.Close(); err != nil {
t.Errorf("Close with current error = %v", err)
}
}
// TestFileHostSource_CloseNilFile file 已经为 nil 时 Close 直接返回 nil
func TestFileHostSource_CloseNilFile(t *testing.T) {
src := &fileHostSource{file: nil}
if err := src.Close(); err != nil {
t.Errorf("nil file Close error = %v", err)
}
}
// =============================================================================
// multiHostSource.Close 路径
// =============================================================================
// TestMultiHostSource_CloseWithCurrent Close 时 current != nil 分支
func TestMultiHostSource_CloseWithCurrent(t *testing.T) {
inner := &closeTrackingSource{}
ms := &multiHostSource{current: inner}
if err := ms.Close(); err != nil {
t.Errorf("Close error = %v", err)
}
if !inner.closed {
t.Error("current 应被关闭")
}
if ms.current != nil {
t.Error("Close 后 current 应为 nil")
}
}
// =============================================================================
// ipToUint32 IPv6 路径
// =============================================================================
// TestIpToUint32_IPv6ReturnsFalse IPv6 地址应返回 false
func TestIpToUint32_IPv6ReturnsFalse(t *testing.T) {
ip := net.ParseIP("2001:db8::1")
_, ok := ipToUint32(ip)
if ok {
t.Error("IPv6 地址应返回 ok=false")
}
}
// TestIpToUint32_NilReturnsFalse nil IP 应返回 false
func TestIpToUint32_NilReturnsFalse(t *testing.T) {
_, ok := ipToUint32(nil)
if ok {
t.Error("nil IP 应返回 ok=false")
}
}
// =============================================================================
// 剩余未覆盖路径
// =============================================================================
// TestFileHostSource_CurrentNextError fileHostSource.Next 中 current.Next() 报错应透传
func TestFileHostSource_CurrentNextError(t *testing.T) {
src := &fileHostSource{
current: &errorSource{err: errors.New("inner error")},
// scanner 为 nil——不会走到 scanner 分支
scanner: bufio.NewScanner(strings.NewReader("")),
}
_, _, err := src.Next()
if err == nil {
t.Error("current.Next() 报错应透传")
}
}
// TestMultiHostSource_InnerNextError multiHostSource.Next 中内部 source.Next() 报错应透传
func TestMultiHostSource_InnerNextError(t *testing.T) {
ms := &multiHostSource{
sources: []hostSource{&errorSource{err: errors.New("inner error")}},
}
_, _, err := ms.Next()
if err == nil {
t.Error("内部 source.Next() 报错应透传到 multiHostSource.Next")
}
}
// TestNewHostSource_RangeError newHostSource range 分支中 newRangeHostSource 失败
func TestNewHostSource_RangeError(t *testing.T) {
// start > endlooksLikeIPRange 通过(前半部分是有效 IP),但 newRangeHostSource 返回错误
_, err := newHostSource("10.0.0.200-10.0.0.100")
if err == nil {
t.Error("start>end range 应返回 error")
}
}
// TestNewCIDRHostSource_IPv6DirectCall 直接调用 newCIDRHostSource 传入 IPv6 CIDR
func TestNewCIDRHostSource_IPv6DirectCall(t *testing.T) {
// IPv6 CIDR —— bits=128 != 32,触发 line 332-334
_, err := newCIDRHostSource("::1/128")
if err == nil {
t.Error("IPv6 CIDR 应被 newCIDRHostSource 拒绝 (bits!=32)")
}
}
+81
View File
@@ -6,6 +6,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/shadow1ng/fscan/common/output"
) )
func TestScanSessionLogMethodsHonorSilentConfig(t *testing.T) { func TestScanSessionLogMethodsHonorSilentConfig(t *testing.T) {
@@ -161,6 +163,85 @@ func TestParseProxyURLExtractsAuthWithoutScheme(t *testing.T) {
} }
} }
// TestScanSessionSaveResultUsesSink 测试 SaveResult 通过 ResultSink 分发
func TestScanSessionSaveResultUsesSink(t *testing.T) {
preserveOutputAPIGlobals(t)
cfg := NewConfig()
cfg.Output.DisableSave = true
SetGlobalConfig(cfg)
flagVars = &FlagVars{DisableSave: true}
_ = InitOutput()
var sinkGot *output.ScanResult
session := NewScanSession(cfg, NewState(), &FlagVars{})
session.ResultSink = func(r *output.ScanResult) error {
sinkGot = r
return nil
}
result := &output.ScanResult{
Type: output.TypeHost,
Target: "10.0.0.1",
Status: "ALIVE",
}
if err := session.SaveResult(result); err != nil {
t.Fatalf("session.SaveResult error = %v", err)
}
if sinkGot != result {
t.Fatalf("ResultSink 未被调用或参数不符: got %v", sinkGot)
}
}
// TestScanSessionSaveResultFallsBackToGlobal 测试无 sink 时回退到全局 SaveResult
func TestScanSessionSaveResultFallsBackToGlobal(t *testing.T) {
preserveOutputAPIGlobals(t)
cfg := NewConfig()
cfg.Output.DisableSave = true
SetGlobalConfig(cfg)
flagVars = &FlagVars{DisableSave: true}
_ = InitOutput()
called := false
SetResultCallback(func(payload interface{}) {
called = true
})
session := NewScanSession(cfg, NewState(), &FlagVars{})
// 不设置 ResultSink,应回退到全局
result := &output.ScanResult{
Type: output.TypeHost,
Target: "10.0.0.2",
Status: "ALIVE",
}
if err := session.SaveResult(result); err != nil {
t.Fatalf("session.SaveResult (fallback) error = %v", err)
}
if !called {
t.Fatal("回退到全局 SaveResult 时应触发 ResultCallback")
}
}
// TestScanSessionLogMethodsEnabledByDefault 测试非 Silent 配置下 Log 方法不被屏蔽
func TestScanSessionLogMethodsEnabledByDefault(t *testing.T) {
cfg := NewConfig()
cfg.Output.Silent = false
session := NewScanSession(cfg, NewState(), &FlagVars{})
if !session.loggingEnabled() {
t.Fatal("非 Silent 配置下 loggingEnabled 应返回 true")
}
}
// TestNilScanSessionLoggingEnabled 测试 nil session 的 loggingEnabled
func TestNilScanSessionLoggingEnabled(t *testing.T) {
var session *ScanSession
if !session.loggingEnabled() {
t.Fatal("nil session 的 loggingEnabled 应返回 true(安全降级)")
}
}
type roundTripFunc func(*http.Request) (*http.Response, error) type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+295
View File
@@ -215,6 +215,301 @@ func TestState_ConcurrentTaskCounters(t *testing.T) {
} }
} }
// TestState_GetOutputMutex 测试获取输出互斥锁指针
func TestState_GetOutputMutex(t *testing.T) {
s := NewState()
mu := s.GetOutputMutex()
if mu == nil {
t.Fatal("GetOutputMutex returned nil")
}
// 验证返回的指针可以正常加锁解锁
mu.Lock()
_ = 1 //nolint:staticcheck // SA2001: 故意测试空临界区
mu.Unlock()
}
// TestState_GetICMPLimiter 测试 ICMP 限速器延迟初始化
func TestState_GetICMPLimiter(t *testing.T) {
s := NewState()
limiter := s.GetICMPLimiter(0.1)
if limiter == nil {
t.Fatal("GetICMPLimiter returned nil")
}
// 再次调用应返回同一个实例(sync.Once 保证)
limiter2 := s.GetICMPLimiter(0.5)
if limiter != limiter2 {
t.Fatal("GetICMPLimiter should return the same instance on repeated calls")
}
}
// TestState_GetICMPLimiterMinRate 测试极低速率下的 ICMP 限速器
func TestState_GetICMPLimiterMinRate(t *testing.T) {
s := NewState()
// 极低速率(packetsPerSecond < 1)应被钳位到 1
limiter := s.GetICMPLimiter(0.000001)
if limiter == nil {
t.Fatal("GetICMPLimiter with tiny rate returned nil")
}
}
// TestState_GetPerfStats 测试性能统计数据
func TestState_GetPerfStats(t *testing.T) {
s := NewState()
// 初始状态:全零
stats := s.GetPerfStats()
if stats.TotalPackets != 0 {
t.Errorf("初始 TotalPackets 应为 0, 实际 %d", stats.TotalPackets)
}
if stats.SuccessRate != 0 {
t.Errorf("初始 SuccessRate 应为 0, 实际 %f", stats.SuccessRate)
}
// 增加一些计数后验证统计
s.IncrementTCPSuccessPacketCount()
s.IncrementTCPSuccessPacketCount()
s.IncrementTCPFailedPacketCount()
s.SetNum(3)
stats = s.GetPerfStats()
if stats.TotalPackets != 3 {
t.Errorf("TotalPackets 期望 3, 实际 %d", stats.TotalPackets)
}
if stats.TCPSuccess != 2 {
t.Errorf("TCPSuccess 期望 2, 实际 %d", stats.TCPSuccess)
}
if stats.TCPFailed != 1 {
t.Errorf("TCPFailed 期望 1, 实际 %d", stats.TCPFailed)
}
if stats.TargetsScanned != 3 {
t.Errorf("TargetsScanned 期望 3, 实际 %d", stats.TargetsScanned)
}
// success rate = 2/3 * 100 ≈ 66.67%
if stats.SuccessRate < 66 || stats.SuccessRate > 67 {
t.Errorf("SuccessRate 期望约 66.67, 实际 %f", stats.SuccessRate)
}
}
// TestState_GetPerfStatsJSON 测试性能统计 JSON 序列化
func TestState_GetPerfStatsJSON(t *testing.T) {
s := NewState()
s.IncrementTCPSuccessPacketCount()
json := s.GetPerfStatsJSON()
if json == "" || json == "{}" {
t.Fatalf("GetPerfStatsJSON 返回空: %q", json)
}
if len(json) < 10 {
t.Fatalf("GetPerfStatsJSON 内容过短: %q", json)
}
// 验证包含关键字段
for _, key := range []string{"total_packets", "tcp_success", "success_rate"} {
if !containsStr(json, key) {
t.Errorf("GetPerfStatsJSON 缺少字段 %q", key)
}
}
}
func containsStr(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && stringContains(s, sub))
}
func stringContains(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// TestState_GetPacketLimiter 测试通用发包限速器
func TestState_GetPacketLimiter(t *testing.T) {
t.Run("零速率返回nil", func(t *testing.T) {
s := NewState()
limiter := s.GetPacketLimiter(0)
if limiter != nil {
t.Fatal("零速率应返回 nil limiter")
}
})
t.Run("负速率返回nil", func(t *testing.T) {
s := NewState()
limiter := s.GetPacketLimiter(-1)
if limiter != nil {
t.Fatal("负速率应返回 nil limiter")
}
})
t.Run("正速率初始化限速器", func(t *testing.T) {
s := NewState()
limiter := s.GetPacketLimiter(600) // 600/min = 10/s
if limiter == nil {
t.Fatal("正速率应返回非 nil limiter")
}
// 再次调用返回同一实例
limiter2 := s.GetPacketLimiter(1200)
if limiter != limiter2 {
t.Fatal("GetPacketLimiter 应通过 sync.Once 复用实例")
}
})
t.Run("低速率被钳位到1pps", func(t *testing.T) {
s := NewState()
// 1/min < 1/s,应被钳位
limiter := s.GetPacketLimiter(1)
if limiter == nil {
t.Fatal("低速率钳位后应返回非 nil limiter")
}
})
}
// TestState_CacheService 测试服务识别缓存
func TestState_CacheService(t *testing.T) {
s := NewState()
// 未缓存时查询返回 false
_, ok := s.GetCachedService("192.168.1.1:80")
if ok {
t.Fatal("未缓存的 key 不应返回 ok=true")
}
// 缓存并查询
type fakeInfo struct{ Name string }
info := &fakeInfo{Name: "http"}
s.CacheService("192.168.1.1:80", info)
got, ok := s.GetCachedService("192.168.1.1:80")
if !ok {
t.Fatal("已缓存的 key 应返回 ok=true")
}
if got != info {
t.Fatalf("GetCachedService 返回 %v, 期望 %v", got, info)
}
// 不同 key 互不干扰
_, ok = s.GetCachedService("192.168.1.1:443")
if ok {
t.Fatal("不同 key 不应命中缓存")
}
}
// =============================================================================
// CheckAndIncrementPacketRate 测试
// =============================================================================
// TestCheckAndIncrementPacketRate_ZeroLimit 速率为 0 时无限制
func TestCheckAndIncrementPacketRate_ZeroLimit(t *testing.T) {
s := NewState()
for i := 0; i < 1000; i++ {
ok, err := s.CheckAndIncrementPacketRate(0)
if !ok || err != nil {
t.Fatalf("零速率限制应始终允许: ok=%v err=%v", ok, err)
}
}
}
// TestCheckAndIncrementPacketRate_NegativeLimit 负速率等同于无限制
func TestCheckAndIncrementPacketRate_NegativeLimit(t *testing.T) {
s := NewState()
ok, err := s.CheckAndIncrementPacketRate(-1)
if !ok || err != nil {
t.Fatalf("负速率应允许: ok=%v err=%v", ok, err)
}
}
// TestCheckAndIncrementPacketRate_AllowsWhenTokensAvailable 有令牌时返回 true
func TestCheckAndIncrementPacketRate_AllowsWhenTokensAvailable(t *testing.T) {
s := NewState()
// 600/min = 10/s,桶容量 20,初始满桶
ok, err := s.CheckAndIncrementPacketRate(600)
if !ok || err != nil {
t.Fatalf("初始应有令牌: ok=%v err=%v", ok, err)
}
}
// TestCheckAndIncrementPacketRate_RateLimitedAfterExhaustion 耗尽令牌后返回 false 和 PacketLimitError
func TestCheckAndIncrementPacketRate_RateLimitedAfterExhaustion(t *testing.T) {
s := NewState()
// 极低速率:1/min,桶容量为 1(钳位后 packetsPerSecond=1capacity=2
// 消耗掉所有令牌后应被限速
const limit int64 = 1
// 初始化限速器(第一次调用触发 sync.Once)
s.GetPacketLimiter(limit)
// 消耗完所有令牌(容量 <= 2)
for i := 0; i < 10; i++ {
s.CheckAndIncrementPacketRate(limit) //nolint: errcheck
}
// 此时令牌应已耗尽,下一次调用应被限速
ok, err := s.CheckAndIncrementPacketRate(limit)
if ok {
// 桶可能还剩令牌(容量 2),多耗几次再判断
for i := 0; i < 20; i++ {
ok, err = s.CheckAndIncrementPacketRate(limit)
if !ok {
break
}
}
}
if ok {
t.Fatal("令牌耗尽后应返回 ok=false")
}
if err == nil {
t.Fatal("令牌耗尽后应返回 error")
}
if !isPacketLimitError(err) {
t.Errorf("error 类型应为 PacketLimitError, 实际 %T: %v", err, err)
}
}
// isPacketLimitError 检查是否为 PacketLimitError
func isPacketLimitError(err error) bool {
_, ok := err.(*PacketLimitError)
return ok
}
// TestCheckAndIncrementPacketRate_ErrorUnwrapsToSentinel 验证 error 可 unwrap 到 sentinel
func TestCheckAndIncrementPacketRate_ErrorUnwrapsToSentinel(t *testing.T) {
s := NewState()
const limit int64 = 1
// 耗尽令牌
for i := 0; i < 50; i++ {
s.CheckAndIncrementPacketRate(limit) //nolint: errcheck
}
var lastErr error
for i := 0; i < 10; i++ {
ok, err := s.CheckAndIncrementPacketRate(limit)
if !ok {
lastErr = err
break
}
}
if lastErr == nil {
t.Skip("未能触发限速(可能令牌桶容量较大),跳过 unwrap 测试")
}
// 验证可 unwrap 到 ErrPacketRateLimited
pErr, ok := lastErr.(*PacketLimitError)
if !ok {
t.Fatalf("期望 *PacketLimitError, 实际 %T", lastErr)
}
if pErr.Sentinel != ErrPacketRateLimited {
t.Errorf("Sentinel = %v, 期望 ErrPacketRateLimited", pErr.Sentinel)
}
if pErr.Limit != limit {
t.Errorf("Limit = %d, 期望 %d", pErr.Limit, limit)
}
}
// TestState_OutputMutex 测试输出互斥锁 // TestState_OutputMutex 测试输出互斥锁
func TestState_OutputMutex(t *testing.T) { func TestState_OutputMutex(t *testing.T) {
s := NewState() s := NewState()
+119
View File
@@ -1,6 +1,7 @@
package core package core
import ( import (
"sync/atomic"
"testing" "testing"
"time" "time"
) )
@@ -152,3 +153,121 @@ func TestAdaptivePool_Wait(t *testing.T) {
t.Logf("Wait 测试通过: %v", duration) t.Logf("Wait 测试通过: %v", duration)
} }
// =============================================================================
// maybeReduceTarget 补充覆盖
// =============================================================================
// TestMaybeReduceTarget_NoOpWhenRTTLow rttRatio <= 3.0 时不修改 target
func TestMaybeReduceTarget_NoOpWhenRTTLow(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(100, 100, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
defer pool.Release()
initialTarget := atomic.LoadInt32(&pool.target)
// RTTRatio 样本不足(< 20)返回 1.0,远低于 3.0 阈值
pool.maybeReduceTarget()
afterTarget := atomic.LoadInt32(&pool.target)
if afterTarget != initialTarget {
t.Errorf("rttRatio <= 3.0 时 target 不应改变: %d -> %d", initialTarget, afterTarget)
}
}
// TestMaybeReduceTarget_ReducesWhenRTTHigh rttRatio > 3.0 时压低 target 10%
func TestMaybeReduceTarget_ReducesWhenRTTHigh(t *testing.T) {
metrics := &ScanMetrics{}
pool, err := NewAdaptivePool(200, 200, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
defer pool.Release()
// 伪造 RTT:让 fastEMA >> slowEMAratio > 3.0
// 方法:先用大 RTT 建立 fastEMA,再用小 RTT 建立 slowEMA
// 更直接:直接操作 atomic 字段(包内测试可以访问)
for i := 0; i < 25; i++ {
metrics.RecordConnect(10 * time.Millisecond) // 先建 baseline
}
// 现在把 fastEMA 人为拉高(写入一个远大于 slowEMA 的值)
pool.metrics.rttFastNs.Store(int64(400 * time.Millisecond))
pool.metrics.rttSlowNs.Store(int64(10 * time.Millisecond))
initialTarget := atomic.LoadInt32(&pool.target)
pool.maybeReduceTarget()
afterTarget := atomic.LoadInt32(&pool.target)
if afterTarget >= initialTarget {
t.Errorf("rttRatio > 3.0 时 target 应被压低: %d -> %d", initialTarget, afterTarget)
}
// 验证是 ×0.9
expected := int32(float64(initialTarget) * 0.9)
if afterTarget != expected {
t.Errorf("target 应为 %d (×0.9), 实际 %d", expected, afterTarget)
}
}
// TestMaybeReduceTarget_ClampToMinTarget target 压低后不低于 ceiling/5 或 10
func TestMaybeReduceTarget_ClampToMinTarget(t *testing.T) {
metrics := &ScanMetrics{}
// ceiling=20, minTarget = max(20/5, 10) = 10
// target=10, newTarget = int(10*0.9) = 9 → 被 clamp 到 10 → newTarget == target → 不更新
pool, err := NewAdaptivePool(10, 20, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
defer pool.Release()
// 强制设置 target=10(初始值就是 10,但确认一下)
atomic.StoreInt32(&pool.target, 10)
// 伪造 rttRatio > 3.0
for i := 0; i < 25; i++ {
metrics.RecordConnect(10 * time.Millisecond)
}
pool.metrics.rttFastNs.Store(int64(400 * time.Millisecond))
pool.metrics.rttSlowNs.Store(int64(10 * time.Millisecond))
pool.maybeReduceTarget()
afterTarget := atomic.LoadInt32(&pool.target)
// newTarget=9 < minTarget=10 → clamp 到 10 → 10 == target → 不写入
if afterTarget != 10 {
t.Errorf("clamp 后 target 应保持 10, 实际 %d", afterTarget)
}
}
// TestMaybeReduceTarget_LargeCeilingMinTarget ceiling 足够大时 minTarget = ceiling/5
func TestMaybeReduceTarget_LargeCeilingMinTarget(t *testing.T) {
metrics := &ScanMetrics{}
// ceiling=100, minTarget = 100/5 = 20
// target=21 → newTarget = int(21*0.9) = 18 → clamp 到 20
pool, err := NewAdaptivePool(21, 100, func(interface{}) {}, metrics)
if err != nil {
t.Fatalf("创建线程池失败: %v", err)
}
defer pool.Release()
atomic.StoreInt32(&pool.target, 21)
atomic.StoreInt32(&pool.ceiling, 100)
for i := 0; i < 25; i++ {
metrics.RecordConnect(10 * time.Millisecond)
}
pool.metrics.rttFastNs.Store(int64(400 * time.Millisecond))
pool.metrics.rttSlowNs.Store(int64(10 * time.Millisecond))
pool.maybeReduceTarget()
afterTarget := atomic.LoadInt32(&pool.target)
// newTarget=18 < minTarget=20 → store 20; 20 < 21 → 更新
if afterTarget != 20 {
t.Errorf("应 clamp 到 minTarget=20, 实际 %d", afterTarget)
}
}
+5
View File
@@ -88,6 +88,11 @@ func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.Ho
for { for {
hosts, err := iter.NextBatch(ctx, targetHostBatchSize(session.Config)) hosts, err := iter.NextBatch(ctx, targetHostBatchSize(session.Config))
if err != nil { if err != nil {
if ctx.Err() != nil {
session.LogError(i18n.Tr("global_timeout_exceeded",
int(session.Config.GlobalTimeout.Seconds())))
return
}
session.LogError(i18n.Tr("parse_target_failed", err)) session.LogError(i18n.Tr("parse_target_failed", err))
return return
} }
+131
View File
@@ -462,3 +462,134 @@ func TestBaseScanStrategy_ValidateConfiguration(t *testing.T) {
t.Errorf("ValidateConfiguration 应返回 nil, 实际: %v", err) t.Errorf("ValidateConfiguration 应返回 nil, 实际: %v", err)
} }
} }
// =============================================================================
// IsPluginApplicableByName 补充覆盖
// =============================================================================
// TestIsPluginApplicableByName_FullModeWebPlugin 测试 -full 模式下 web 插件对任意端口生效
func TestIsPluginApplicableByName_FullModeWebPlugin(t *testing.T) {
registerTestPlugins(t)
clearServiceCache()
cfg := common.NewConfig()
cfg.POC.Full = true
strategy := NewBaseScanStrategy("service", FilterService)
// webtitle 是 web 插件;-full 模式下不检查 IsMarkedWebService,直接走 passesFilterType
// FilterService 不允许 local/udp,但允许 web 插件
got := strategy.IsPluginApplicableByName("webtitle", "10.0.0.1", 12345, false, cfg)
if !got {
t.Error("full 模式下 web 插件应对任意端口返回 true")
}
}
// TestIsPluginApplicableByName_FullModeNonWebPlugin 确认 -full 不影响非 web 插件的端口匹配
func TestIsPluginApplicableByName_FullModeNonWebPlugin(t *testing.T) {
registerTestPlugins(t)
clearServiceCache()
cfg := common.NewConfig()
cfg.POC.Full = true
strategy := NewBaseScanStrategy("service", FilterService)
// ssh 不是 web 插件,-full 无特殊逻辑,走普通端口匹配
// ssh 默认端口 22;用 99999 端口应该不匹配
got := strategy.IsPluginApplicableByName("ssh", "10.0.0.1", 99999, false, cfg)
if got {
t.Error("-full 模式对非 web 插件不应绕过端口匹配")
}
}
// =============================================================================
// isPluginApplicableToPort 补充覆盖
// =============================================================================
// TestIsPluginApplicableToPort_WebPlugin web 插件忽略端口直接返回 true
func TestIsPluginApplicableToPort_WebPlugin(t *testing.T) {
registerTestPlugins(t)
strategy := NewBaseScanStrategy("service", FilterService)
// webtitle 是 web 插件,任何端口都应返回 true
if !strategy.isPluginApplicableToPort("webtitle", 8080) {
t.Error("web 插件在任意端口应返回 true")
}
if !strategy.isPluginApplicableToPort("webtitle", 0) {
t.Error("web 插件在端口 0 也应返回 true")
}
}
// TestIsPluginApplicableToPort_NonWebPlugin 非 web 插件走端口匹配逻辑
func TestIsPluginApplicableToPort_NonWebPlugin(t *testing.T) {
registerTestPlugins(t)
clearServiceCache()
strategy := NewBaseScanStrategy("service", FilterService)
// ssh 端口 22 匹配
if !strategy.isPluginApplicableToPort("ssh", 22) {
t.Error("ssh 应匹配端口 22")
}
// ssh 端口 9999 不匹配(无服务缓存)
if strategy.isPluginApplicableToPort("ssh", 9999) {
t.Error("ssh 不应匹配端口 9999")
}
}
// =============================================================================
// isPluginPassesFilterType 补充覆盖
// =============================================================================
// TestIsPluginPassesFilterType_CustomMode isCustomMode=true 应直接跳过过滤返回 true(非 UDP)
func TestIsPluginPassesFilterType_CustomMode(t *testing.T) {
registerTestPlugins(t)
cfg := common.NewConfig()
// FilterLocal 策略下 custom mode 也应通过
localStrategy := NewBaseScanStrategy("local", FilterLocal)
if !localStrategy.isPluginPassesFilterType("ssh", true, cfg) {
t.Error("custom mode 下非 UDP 插件应直接返回 true")
}
// FilterService 策略下 custom mode 也应通过
serviceStrategy := NewBaseScanStrategy("service", FilterService)
if !serviceStrategy.isPluginPassesFilterType("ssh", true, cfg) {
t.Error("custom mode 下 service 策略应直接返回 true")
}
}
// TestIsPluginPassesFilterType_FilterNoneNonLocal FilterNone + 普通 TCP 插件 → true
func TestIsPluginPassesFilterType_FilterNoneNonLocal(t *testing.T) {
registerTestPlugins(t)
cfg := common.NewConfig()
noneStrategy := NewBaseScanStrategy("none", FilterNone)
// ssh 不是 local 插件,FilterNone 应直接返回 true
if !noneStrategy.isPluginPassesFilterType("ssh", false, cfg) {
t.Error("FilterNone + 非 local 插件应返回 true")
}
if !noneStrategy.isPluginPassesFilterType("redis", false, cfg) {
t.Error("FilterNone + 非 local 插件 redis 应返回 true")
}
}
// TestIsPluginPassesFilterType_FilterNoneLocalPlugin FilterNone + local 插件:需要 -local 显式指定
func TestIsPluginPassesFilterType_FilterNoneLocalPlugin(t *testing.T) {
plugins.RegisterWithOptions("core_test_local_none", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false)
cfg := common.NewConfig()
noneStrategy := NewBaseScanStrategy("none", FilterNone)
// 未指定 LocalPlugin,应返回 false
if noneStrategy.isPluginPassesFilterType("core_test_local_none", false, cfg) {
t.Error("FilterNone + local 插件未显式指定时应返回 false")
}
// 指定后应返回 true
cfg.LocalPlugin = "core_test_local_none"
if !noneStrategy.isPluginPassesFilterType("core_test_local_none", false, cfg) {
t.Error("FilterNone + local 插件显式指定后应返回 true")
}
}
+1 -1
View File
@@ -147,7 +147,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout
session.LogDebug(i18n.Tr("port_scan_debug_start", len(hosts), config.ThreadNum)) session.LogDebug(i18n.Tr("port_scan_debug_start", len(hosts), config.ThreadNum))
// 大规模扫描预筛:跨多个 /24 时先做网段探活,跳过空网段 // 大规模扫描预筛:跨多个 /24 时先做网段探活,跳过空网段
if len(hosts) > subnetProbeThreshold { if !config.DisableSubnetProbe && len(hosts) > subnetProbeThreshold {
hosts = probeSubnets(ctx, hosts, time.Duration(timeout)*time.Second, session) hosts = probeSubnets(ctx, hosts, time.Duration(timeout)*time.Second, session)
if len(hosts) == 0 { if len(hosts) == 0 {
session.LogInfo(i18n.GetText("port_scan_no_alive_subnet")) session.LogInfo(i18n.GetText("port_scan_no_alive_subnet"))
+69
View File
@@ -529,3 +529,72 @@ func TestExtras_ToMap_EmptyStringFiltering(t *testing.T) {
} }
}) })
} }
// =============================================================================
// ParseVersionInfo 测试
// =============================================================================
func TestParseVersionInfo(t *testing.T) {
tests := []struct {
name string
versionInfo string
foundItems []string
wantVP string // VendorProduct
wantVer string // Version
wantCPE string
}{
{
name: "只有product-斜线分隔符",
versionInfo: " p/Apache/",
wantVP: "Apache",
},
{
name: "product和version-斜线分隔符",
versionInfo: " p/nginx/ v/1.18.0/",
wantVP: "nginx",
wantVer: "1.18.0",
},
{
name: "pipe分隔符",
versionInfo: " p|OpenSSH| v|8.2p1|",
wantVP: "OpenSSH",
wantVer: "8.2p1",
},
{
name: "含$1占位符替换后解析",
versionInfo: " p/OpenSSH/ v/$1/",
foundItems: []string{"8.2p1"},
wantVP: "OpenSSH",
wantVer: "8.2p1",
},
{
name: "CPE解析",
versionInfo: " cpe:/a:apache:httpd:2.4.41",
wantCPE: "a:apache:httpd:2.4.41",
},
{
name: "空VersionInfo返回全空Extras",
versionInfo: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Match{
VersionInfo: tt.versionInfo,
FoundItems: tt.foundItems,
}
got := m.ParseVersionInfo(nil)
if got.VendorProduct != tt.wantVP {
t.Errorf("VendorProduct = %q, want %q", got.VendorProduct, tt.wantVP)
}
if got.Version != tt.wantVer {
t.Errorf("Version = %q, want %q", got.Version, tt.wantVer)
}
if got.CPE != tt.wantCPE {
t.Errorf("CPE = %q, want %q", got.CPE, tt.wantCPE)
}
})
}
}
+112
View File
@@ -2,6 +2,7 @@ package core
import ( import (
"sync" "sync"
"sync/atomic"
"testing" "testing"
"time" "time"
) )
@@ -128,3 +129,114 @@ func TestScanMetrics_ConcurrentSafety(t *testing.T) {
// 验证 RTTRatio 不 panic // 验证 RTTRatio 不 panic
_ = m.RTTRatio() _ = m.RTTRatio()
} }
// =============================================================================
// 补充测试:按题目要求的函数名
// =============================================================================
// TestScanMetricsTotal — 各计数器各调一次,Total() 应返回 4
func TestScanMetricsTotal(t *testing.T) {
m := &ScanMetrics{}
m.RecordConnect(time.Millisecond)
m.RecordRefused(time.Millisecond)
m.RecordTimeout()
m.RecordExhausted()
if got := m.Total(); got != 4 {
t.Errorf("Total() = %d, want 4", got)
}
}
// TestScanMetricsSnapshot — 记录数据后 Snapshot() 返回正确快照
func TestScanMetricsSnapshot(t *testing.T) {
m := &ScanMetrics{}
m.RecordConnect(5 * time.Millisecond)
m.RecordConnect(10 * time.Millisecond)
m.RecordRefused(2 * time.Millisecond)
m.RecordTimeout()
m.RecordExhausted()
snap := m.Snapshot()
tests := []struct {
name string
got int64
want int64
}{
{"Connects", snap.Connects, 2},
{"Refused", snap.Refused, 1},
{"Timeouts", snap.Timeouts, 1},
{"Exhausted", snap.Exhausted, 1},
}
for _, tt := range tests {
if tt.got != tt.want {
t.Errorf("Snapshot.%s = %d, want %d", tt.name, tt.got, tt.want)
}
}
if snap.RTTFastNs <= 0 {
t.Errorf("Snapshot.RTTFastNs = %d, want > 0", snap.RTTFastNs)
}
}
// TestScanMetricsRTTRatio — 样本不足返回 1.020+ 个相同 RTT 接近 1.0
func TestScanMetricsRTTRatio(t *testing.T) {
t.Run("样本不足返回1.0", func(t *testing.T) {
m := &ScanMetrics{}
for i := 0; i < 19; i++ {
m.RecordConnect(time.Millisecond)
}
if r := m.RTTRatio(); r != 1.0 {
t.Errorf("样本不足 RTTRatio() = %f, want 1.0", r)
}
})
t.Run("稳定RTT接近1.0", func(t *testing.T) {
m := &ScanMetrics{}
for i := 0; i < 30; i++ {
m.RecordConnect(10 * time.Millisecond)
}
r := m.RTTRatio()
if r < 0.9 || r > 1.1 {
t.Errorf("稳定RTT下 RTTRatio() = %f, want ~1.0", r)
}
})
}
// TestScanMetricsRTTFast — 初始为 0,记录后非零
func TestScanMetricsRTTFast(t *testing.T) {
m := &ScanMetrics{}
if m.RTTFast() != 0 {
t.Errorf("初始 RTTFast() = %v, want 0", m.RTTFast())
}
m.RecordConnect(5 * time.Millisecond)
if m.RTTFast() == 0 {
t.Errorf("记录后 RTTFast() 仍为 0")
}
}
// TestMetricsSnapshotTotal — MetricsSnapshot 各字段求和
func TestMetricsSnapshotTotal(t *testing.T) {
snap := MetricsSnapshot{Connects: 1, Refused: 2, Timeouts: 3, Exhausted: 4}
if got := snap.Total(); got != 10 {
t.Errorf("MetricsSnapshot.Total() = %d, want 10", got)
}
}
// TestUpdateEMA — 直接测 updateEMA 行为
func TestUpdateEMA(t *testing.T) {
t.Run("target为0时直接设为sample", func(t *testing.T) {
var a atomic.Int64
updateEMA(&a, 100, 10)
if got := a.Load(); got != 100 {
t.Errorf("初始为0时 updateEMA 结果 = %d, want 100", got)
}
})
t.Run("target非零时做EMA更新", func(t *testing.T) {
var a atomic.Int64
a.Store(200)
// next = 200 + (100-200)/10 = 200 - 10 = 190
updateEMA(&a, 100, 10)
if got := a.Load(); got != 190 {
t.Errorf("EMA更新结果 = %d, want 190", got)
}
})
}
+59
View File
@@ -13,6 +13,7 @@ import (
"github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/i18n" "github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/common/output" "github.com/shadow1ng/fscan/common/output"
"github.com/shadow1ng/fscan/common/parsers"
"github.com/shadow1ng/fscan/plugins" "github.com/shadow1ng/fscan/plugins"
"github.com/shadow1ng/fscan/webscan/lib" "github.com/shadow1ng/fscan/webscan/lib"
) )
@@ -96,6 +97,15 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess
start := time.Now() start := time.Now()
config := session.Config config := session.Config
// 全局超时自适应:用户未显式指定 -gt 时,根据扫描规模自动调大
if !config.GlobalTimeoutExplicit && config.GlobalTimeout > 0 {
if adjusted := estimateGlobalTimeout(config, session); adjusted > config.GlobalTimeout {
session.LogInfo(i18n.Tr("global_timeout_adjusted",
int(config.GlobalTimeout.Seconds()), int(adjusted.Seconds())))
config.GlobalTimeout = adjusted
}
}
// 全局超时:-gt 参数设置整个扫描的硬性截止时间 // 全局超时:-gt 参数设置整个扫描的硬性截止时间
var cancel context.CancelFunc var cancel context.CancelFunc
if config.GlobalTimeout > 0 { if config.GlobalTimeout > 0 {
@@ -489,3 +499,52 @@ func addCommonDetails(result *plugins.Result, details map[string]interface{}) {
details["server"] = result.Server details["server"] = result.Server
} }
} }
func estimateGlobalTimeout(config *common.Config, session *common.ScanSession) time.Duration {
portCount := int64(len(parsers.ParsePort(config.Target.Ports)))
if portCount == 0 {
portCount = 10
}
var hostFile string
var hostStr string
if session.Params != nil {
hostFile = session.Params.HostsFile
hostStr = session.Params.Host
}
hostCount := parsers.EstimateHostCount(hostStr, hostFile)
if hostCount <= 0 {
hostCount = 1
}
totalTasks := hostCount * portCount
threads := int64(config.ThreadNum)
if threads <= 0 {
threads = 600
}
// 端口扫描:平均每个任务约 50ms(大部分连接快速失败)
portScanSec := float64(totalTasks) * 0.05 / float64(threads)
// 插件扫描:开放率随端口数下降(全端口约 0.1%,少量端口约 5%)
openRate := 0.05
if portCount > 1000 {
openRate = 0.002
} else if portCount > 100 {
openRate = 0.01
}
moduleThreads := float64(config.ModuleThreadNum)
if moduleThreads <= 0 {
moduleThreads = 20
}
pluginSec := float64(totalTasks) * openRate * 2.0 / moduleThreads
// 总估算 + 20% 余量
estimatedSec := (portScanSec + pluginSec) * 1.2
const maxTimeout = 2 * time.Hour
estimated := time.Duration(estimatedSec) * time.Second
if estimated > maxTimeout {
estimated = maxTimeout
}
return estimated
}
+123
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"sync" "sync"
"testing" "testing"
"time"
"github.com/shadow1ng/fscan/common" "github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/plugins" "github.com/shadow1ng/fscan/plugins"
@@ -433,6 +434,128 @@ func TestSelectStrategy_EmptyHostInfo(t *testing.T) {
} }
} }
// =============================================================================
// buildScanReport 测试
// =============================================================================
// TestBuildScanReport 验证 buildScanReport 字段映射正确
func TestBuildScanReport(t *testing.T) {
state := common.NewState()
// 填充各计数器
state.SetEnd(10)
state.SetNum(7)
state.IncrementTCPSuccessPacketCount() // +1 total, +1 tcp, +1 tcpSuccess
state.IncrementTCPSuccessPacketCount() // +1 total, +1 tcp, +1 tcpSuccess
state.IncrementTCPFailedPacketCount() // +1 total, +1 tcp, +1 tcpFailed
state.IncrementUDPPacketCount() // +1 total, +1 udp
state.IncrementHTTPPacketCount() // +1 total, +1 http
state.IncrementResourceExhaustedCount()
start := time.Now().Add(-time.Second) // 模拟 1 秒前开始
report := buildScanReport(state, start)
if report.TasksTotal != 10 {
t.Errorf("TasksTotal = %d, 期望 10", report.TasksTotal)
}
if report.TasksCompleted != 7 {
t.Errorf("TasksCompleted = %d, 期望 7", report.TasksCompleted)
}
if report.Packets != 5 {
t.Errorf("Packets = %d, 期望 5", report.Packets)
}
if report.TCPPackets != 3 {
t.Errorf("TCPPackets = %d, 期望 3", report.TCPPackets)
}
if report.TCPSuccessPackets != 2 {
t.Errorf("TCPSuccessPackets = %d, 期望 2", report.TCPSuccessPackets)
}
if report.TCPFailedPackets != 1 {
t.Errorf("TCPFailedPackets = %d, 期望 1", report.TCPFailedPackets)
}
if report.UDPPackets != 1 {
t.Errorf("UDPPackets = %d, 期望 1", report.UDPPackets)
}
if report.HTTPPackets != 1 {
t.Errorf("HTTPPackets = %d, 期望 1", report.HTTPPackets)
}
if report.ResourceExhausted != 1 {
t.Errorf("ResourceExhausted = %d, 期望 1", report.ResourceExhausted)
}
if report.Duration < time.Millisecond {
t.Errorf("Duration = %v, 期望 >= 1ms", report.Duration)
}
}
// TestBuildScanReport_ZeroState 验证空 State 返回零值报告
func TestBuildScanReport_ZeroState(t *testing.T) {
state := common.NewState()
start := time.Now()
report := buildScanReport(state, start)
if report.TasksTotal != 0 || report.TasksCompleted != 0 || report.Packets != 0 {
t.Errorf("空 State 期望全零报告,实际 %+v", report)
}
if report.Duration < 0 {
t.Errorf("Duration 不能为负: %v", report.Duration)
}
}
// =============================================================================
// determineScanMode IsLocalMode 分支测试
// =============================================================================
// TestDetermineScanMode_IsLocalModeCallback 覆盖 IsLocalMode 回调分支
func TestDetermineScanMode_IsLocalModeCallback(t *testing.T) {
// 保存原始值
origIsLocalMode := common.IsLocalMode
defer func() { common.IsLocalMode = origIsLocalMode }()
// 注册回调:mode == "localtest" 时认为是本地模式
common.IsLocalMode = func(mode string) bool {
return mode == "localtest"
}
cfg := &common.Config{
AliveOnly: false,
Mode: "localtest",
LocalMode: false,
}
state := common.NewState()
mode := determineScanMode(cfg, state)
if mode != ScanModeLocal {
t.Errorf("determineScanMode() = %v, 期望 ScanModeLocal", mode)
}
// 回调命中后应同时设置 LocalMode 和 LocalPlugin
if !cfg.LocalMode {
t.Error("IsLocalMode 命中后应设置 cfg.LocalMode = true")
}
if cfg.LocalPlugin != "localtest" {
t.Errorf("LocalPlugin = %q, 期望 \"localtest\"", cfg.LocalPlugin)
}
}
// TestDetermineScanMode_IsLocalModeCallbackNoMatch 回调不命中时不影响模式
func TestDetermineScanMode_IsLocalModeCallbackNoMatch(t *testing.T) {
origIsLocalMode := common.IsLocalMode
defer func() { common.IsLocalMode = origIsLocalMode }()
common.IsLocalMode = func(mode string) bool { return false }
cfg := &common.Config{
AliveOnly: false,
Mode: "something",
LocalMode: false,
}
state := common.NewState()
mode := determineScanMode(cfg, state)
if mode != ScanModeService {
t.Errorf("回调不命中时期望 ScanModeService, 实际 %v", mode)
}
}
// TestCountApplicableTasks_EmptyPlugins 测试空插件列表 // TestCountApplicableTasks_EmptyPlugins 测试空插件列表
func TestCountApplicableTasks_EmptyPlugins(t *testing.T) { func TestCountApplicableTasks_EmptyPlugins(t *testing.T) {
targets := []common.HostInfo{ targets := []common.HostInfo{
+4
View File
@@ -392,6 +392,10 @@ func (i *Info) tryProbes(response []byte, probes []*Probe) bool {
// GetInfo 分析响应数据并提取服务信息 // GetInfo 分析响应数据并提取服务信息
func (i *Info) GetInfo(response []byte, probe *Probe) { func (i *Info) GetInfo(response []byte, probe *Probe) {
if probe == nil {
return
}
// 响应数据有效性检查 // 响应数据有效性检查
if len(response) <= 0 { if len(response) <= 0 {
common.LogDebug(i18n.GetText("service_probe_empty_response")) common.LogDebug(i18n.GetText("service_probe_empty_response"))
+5
View File
@@ -161,6 +161,11 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm
for { for {
hosts, err := iter.NextBatch(ctx, targetHostBatchSize(config)) hosts, err := iter.NextBatch(ctx, targetHostBatchSize(config))
if err != nil { if err != nil {
if ctx.Err() != nil {
session.LogError(i18n.Tr("global_timeout_exceeded",
int(config.GlobalTimeout.Seconds())))
return
}
session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err))
return return
} }
+73
View File
@@ -862,3 +862,76 @@ func TestConvertToTargetInfos_DeepCopy(t *testing.T) {
} }
}) })
} }
// =============================================================================
// mergeHostPorts 测试
// =============================================================================
func TestMergeHostPorts(t *testing.T) {
// 结果顺序不确定(map 遍历),用集合比较
toSet := func(ss []string) map[string]struct{} {
m := make(map[string]struct{}, len(ss))
for _, s := range ss {
m[s] = struct{}{}
}
return m
}
setsEqual := func(a, b map[string]struct{}) bool {
if len(a) != len(b) {
return false
}
for k := range a {
if _, ok := b[k]; !ok {
return false
}
}
return true
}
tests := []struct {
name string
a []string
b []string
want []string
}{
{
name: "两个空切片返回空",
a: []string{},
b: []string{},
want: []string{},
},
{
name: "无重复-并集",
a: []string{"1.1.1.1:80"},
b: []string{"2.2.2.2:443"},
want: []string{"1.1.1.1:80", "2.2.2.2:443"},
},
{
name: "有重复-去重",
a: []string{"1.1.1.1:80", "2.2.2.2:443"},
b: []string{"2.2.2.2:443", "3.3.3.3:22"},
want: []string{"1.1.1.1:80", "2.2.2.2:443", "3.3.3.3:22"},
},
{
name: "a为nil-返回b内容",
a: nil,
b: []string{"1.1.1.1:80", "2.2.2.2:443"},
want: []string{"1.1.1.1:80", "2.2.2.2:443"},
},
{
name: "b为nil-返回a内容",
a: []string{"1.1.1.1:80"},
b: nil,
want: []string{"1.1.1.1:80"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mergeHostPorts(tt.a, tt.b)
if !setsEqual(toSet(got), toSet(tt.want)) {
t.Errorf("mergeHostPorts() = %v, want %v", got, tt.want)
}
})
}
}
+1
View File
@@ -411,6 +411,7 @@ func buildFlagVars(config Config, target Target) *common.FlagVars {
GlobalTimeout: 180, GlobalTimeout: 180,
DisablePing: config.DisablePing, DisablePing: config.DisablePing,
DisableTcpProbe: config.DisableTCPProbe, DisableTcpProbe: config.DisableTCPProbe,
DisableSubnetProbe: config.DisableSubnetProbe,
AliveOnly: false, AliveOnly: false,
DisableBrute: config.DisableBrute, DisableBrute: config.DisableBrute,
MaxRetries: maxRetries, MaxRetries: maxRetries,
+4 -3
View File
@@ -133,9 +133,10 @@ type Config struct {
ModuleThreads int ModuleThreads int
MaxRetries int MaxRetries int
DisablePing bool DisablePing bool
DisableTCPProbe bool DisableTCPProbe bool
DisableBrute bool DisableSubnetProbe bool
DisableBrute bool
Usernames []string Usernames []string
Passwords []string Passwords []string
+29
View File
@@ -0,0 +1,29 @@
//go:build plugin_activemq || !plugin_selective
package services
import (
"errors"
"testing"
)
func TestClassifyActiveMQErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil error", nil, ErrorTypeUnknown},
{"authentication failed", errors.New("authentication failed"), ErrorTypeAuth},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyActiveMQErrorType(tt.err)
if got != tt.want {
t.Errorf("classifyActiveMQErrorType(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+8 -7
View File
@@ -74,7 +74,7 @@ func (p *CassandraPlugin) createAuthFunc(info *common.HostInfo, config *common.C
// //
// [1B version|flags] [2B stream] [1B opcode] [4B length] [body] // [1B version|flags] [2B stream] [1B opcode] [4B length] [body]
const ( const (
cqlVersion = 0x84 // version=4, direction=request cqlVersion = 0x04 // version=4, direction=request
cqlOpStartup = 0x01 cqlOpStartup = 0x01
cqlOpAuthRsp = 0x0f cqlOpAuthRsp = 0x0f
cqlOpQuery = 0x07 cqlOpQuery = 0x07
@@ -178,12 +178,13 @@ func nextCQLStreamID() uint16 {
func cqlSend(conn net.Conn, opcode byte, body []byte) error { func cqlSend(conn net.Conn, opcode byte, body []byte) error {
id := nextCQLStreamID() id := nextCQLStreamID()
// frame: [1B version|flags] [2B stream] [1B opcode] [4B length] [body] // CQL v4 frame: [1B version] [1B flags] [2B stream] [1B opcode] [4B length] [body]
header := make([]byte, 8) header := make([]byte, 9)
header[0] = cqlVersion header[0] = cqlVersion // 0x04 = request, version 4
binary.BigEndian.PutUint16(header[1:3], id) header[1] = 0x00 // flags
header[3] = opcode binary.BigEndian.PutUint16(header[2:4], id)
binary.BigEndian.PutUint32(header[4:8], uint32(len(body))) header[4] = opcode
binary.BigEndian.PutUint32(header[5:9], uint32(len(body)))
buf := append(header, body...) buf := append(header, body...)
_, err := conn.Write(buf) _, err := conn.Write(buf)
+72
View File
@@ -5,6 +5,7 @@ package services
import ( import (
"bytes" "bytes"
"encoding/binary" "encoding/binary"
"errors"
"strings" "strings"
"testing" "testing"
) )
@@ -47,3 +48,74 @@ func TestValidateCQLQueryResponseRejectsErrors(t *testing.T) {
t.Fatal("validateCQLQueryResponse() error = nil, want unexpected opcode error") t.Fatal("validateCQLQueryResponse() error = nil, want unexpected opcode error")
} }
} }
func TestClassifyCassandraErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil", nil, ErrorTypeUnknown},
{"auth error", errors.New("authentication failed"), ErrorTypeAuth},
{"bad credentials", errors.New("bad credentials"), ErrorTypeAuth},
{"network error", errors.New("connection refused"), ErrorTypeNetwork},
{"unknown", errors.New("random cassandra error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyCassandraErrorType(tt.err); got != tt.want {
t.Errorf("classifyCassandraErrorType() = %v, want %v", got, tt.want)
}
})
}
}
func TestCqlShortString(t *testing.T) {
got := cqlShortString("AB")
if len(got) != 4 || binary.BigEndian.Uint16(got[:2]) != 2 || string(got[2:]) != "AB" {
t.Errorf("cqlShortString(AB) = %v", got)
}
empty := cqlShortString("")
if len(empty) != 2 || binary.BigEndian.Uint16(empty) != 0 {
t.Errorf("cqlShortString empty = %v", empty)
}
}
func TestCqlLongString(t *testing.T) {
got := cqlLongString("XYZ")
if len(got) != 7 || binary.BigEndian.Uint32(got[:4]) != 3 || string(got[4:]) != "XYZ" {
t.Errorf("cqlLongString(XYZ) = %v", got)
}
}
func TestCqlStringMap(t *testing.T) {
m := map[string]string{"k": "v"}
got := cqlStringMap(m)
if got[0] != 0x00 || got[1] != 0x01 {
t.Errorf("count bytes wrong: %v", got[:2])
}
if !bytes.Contains(got, []byte("k")) || !bytes.Contains(got, []byte("v")) {
t.Errorf("missing key/value in %v", got)
}
}
func TestExtractClusterName(t *testing.T) {
tests := []struct {
name string
data []byte
want string
}{
{"empty", nil, "unknown"},
{"short", []byte{0x01, 0x02}, "unknown"},
{"printable", append([]byte{0x00, 0x00, 0x00, 0x01}, []byte("TestCluster")...), "TestCluster"},
{"binary prefix", append([]byte{0x00, 0x00, 0x00, 0x00, 0x01, 0x02}, []byte("MyCluster")...), "MyCluster"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractClusterName(tt.data)
if !strings.Contains(got, tt.want) && got != tt.want {
t.Errorf("extractClusterName() = %q, want %q", got, tt.want)
}
})
}
}
+183
View File
@@ -0,0 +1,183 @@
//go:build plugin_findnet || !plugin_selective
package services
import (
"strings"
"testing"
)
// --- hexUnicodeToString ---
func TestHexUnicodeToString(t *testing.T) {
p := NewFindNetPlugin()
cases := []struct {
name string
src string
want string
}{
{
name: "empty string",
src: "",
want: "",
},
{
name: "UTF-16LE TEST",
// T=0x54 E=0x45 S=0x53 T=0x54, LE pairs: 5400 4500 5300 5400
src: "54004500530054",
want: "TEST",
},
{
name: "odd length gets padded to 4-multiple",
// 奇数长度补0至4的倍数:"540045005300540" → "5400450053005400" → "TEST"
src: "540045005300540", // 15 hex chars
want: "TEST",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := p.hexUnicodeToString(tc.src)
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
// --- isValidHostname ---
func TestIsValidHostname(t *testing.T) {
p := NewFindNetPlugin()
cases := []struct {
name string
input string
want bool
}{
{name: "empty", input: "", want: false},
{name: "valid hostname", input: "test-pc", want: true},
{name: "single char", input: "a", want: false}, // regex requires at least 2 chars (start+middle+end)
{name: "too long", input: strings.Repeat("a", 256), want: false},
{name: "valid alphanumeric", input: "PC01", want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := p.isValidHostname(tc.input)
if got != tc.want {
t.Errorf("isValidHostname(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
// --- isValidNetworkAddress ---
func TestIsValidNetworkAddress(t *testing.T) {
p := NewFindNetPlugin()
cases := []struct {
name string
input string
want bool
}{
{name: "IPv4", input: "192.168.1.1", want: true},
{name: "IPv6 loopback", input: "::1", want: true},
{name: "valid hostname fallback", input: "test-host", want: true},
{name: "invalid", input: "not_an_ip!!!", want: false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := p.isValidNetworkAddress(tc.input)
if got != tc.want {
t.Errorf("isValidNetworkAddress(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
// --- cleanAndValidateAddress ---
func TestCleanAndValidateAddress(t *testing.T) {
p := NewFindNetPlugin()
cases := []struct {
name string
data []byte
want string
}{
{
name: "valid IPv4 bytes",
data: []byte("192.168.1.100"),
want: "192.168.1.100",
},
{
name: "bytes with unprintable chars around valid IP",
data: append([]byte{0x00, 0x01}, append([]byte("10.0.0.1"), 0x00)...),
want: "10.0.0.1",
},
{
name: "invalid data returns empty",
data: []byte{0x00, 0x01, 0x02, 0x03},
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := p.cleanAndValidateAddress(tc.data)
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
// --- NetworkInfo.Summary ---
func TestNetworkInfoSummary(t *testing.T) {
t.Run("invalid returns discovery failed text", func(t *testing.T) {
ni := &NetworkInfo{Valid: false}
got := ni.Summary()
if got == "" {
t.Error("expected non-empty text for invalid NetworkInfo")
}
// 内容是 i18n key,只验证非空即可
})
t.Run("valid with hostname and IPv4", func(t *testing.T) {
ni := &NetworkInfo{
Valid: true,
Hostname: "PC01",
IPv4Addrs: []string{"192.168.1.1", "10.0.0.1"},
}
got := ni.Summary()
if got == "" {
t.Error("expected non-empty summary")
}
})
}
// --- parseNetworkInfo ---
func TestParseNetworkInfo(t *testing.T) {
p := NewFindNetPlugin()
t.Run("empty data returns invalid", func(t *testing.T) {
info := p.parseNetworkInfo([]byte{})
if info.Valid {
t.Error("expected Valid=false for empty data")
}
})
t.Run("data without valid hostname or IP returns invalid", func(t *testing.T) {
// 全零数据,hostname 解析出空字符串,不会 Valid
info := p.parseNetworkInfo(make([]byte, 64))
if info.Valid {
t.Error("expected Valid=false for zero data")
}
})
}
+33
View File
@@ -0,0 +1,33 @@
//go:build plugin_ftp || !plugin_selective
package services
import (
"errors"
"testing"
)
func TestClassifyFTPErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil error", nil, ErrorTypeUnknown},
{"530 login incorrect", errors.New("530 login incorrect"), ErrorTypeAuth},
{"530 not logged in", errors.New("530 not logged in"), ErrorTypeAuth},
{"authentication failed", errors.New("authentication failed"), ErrorTypeAuth},
{"too many connections", errors.New("421 there are too many connections"), ErrorTypeNetwork},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
{"random error", errors.New("random error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyFTPErrorType(tt.err)
if got != tt.want {
t.Errorf("classifyFTPErrorType(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+23
View File
@@ -4,6 +4,7 @@ package services
import ( import (
"encoding/binary" "encoding/binary"
"errors"
"io" "io"
"testing" "testing"
) )
@@ -61,3 +62,25 @@ func TestKafkaRecvRejectsShortResponse(t *testing.T) {
t.Fatal("kafkaRecv() error = nil, want invalid length error") t.Fatal("kafkaRecv() error = nil, want invalid length error")
} }
} }
func TestClassifyKafkaErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil", nil, ErrorTypeUnknown},
{"sasl auth failed", errors.New("sasl authentication failed"), ErrorTypeAuth},
{"unauthorized", errors.New("unauthorized"), ErrorTypeAuth},
{"broker not available", errors.New("broker not available"), ErrorTypeNetwork},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
{"unknown", errors.New("random kafka error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyKafkaErrorType(tt.err); got != tt.want {
t.Errorf("classifyKafkaErrorType() = %v, want %v", got, tt.want)
}
})
}
}
+23
View File
@@ -3,6 +3,7 @@
package services package services
import ( import (
"errors"
"fmt" "fmt"
"testing" "testing"
@@ -28,3 +29,25 @@ func TestLDAPDNFormatsEscapeUsernameValue(t *testing.T) {
t.Fatalf("escaped DN = %q", got[0]) t.Fatalf("escaped DN = %q", got[0])
} }
} }
func TestClassifyLDAPErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil", nil, ErrorTypeUnknown},
{"invalid credentials", errors.New("invalid credentials"), ErrorTypeAuth},
{"bind failed", errors.New("bind failed"), ErrorTypeAuth},
{"ldap connection lost", errors.New("ldap: connection lost"), ErrorTypeNetwork},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
{"unknown", errors.New("random ldap error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyLDAPErrorType(tt.err); got != tt.want {
t.Errorf("classifyLDAPErrorType() = %v, want %v", got, tt.want)
}
})
}
}
+63 -61
View File
@@ -101,7 +101,7 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo
defer conn.Close() defer conn.Close()
// Step 1: isMaster 获取服务参数 // Step 1: isMaster 获取服务参数
isMasterCmd := buildMongoCommand("admin", "isMaster", mongoDoc{}) isMasterCmd := buildMongoCommand("admin", "isMaster")
if _, err := sendMongoMsg(ctx, conn, isMasterCmd, timeout); err != nil { if _, err := sendMongoMsg(ctx, conn, isMasterCmd, timeout); err != nil {
state.IncrementTCPFailedPacketCount() state.IncrementTCPFailedPacketCount()
return &AuthResult{Success: false, ErrorType: classifyMongoDBErrorType(err), Error: err} return &AuthResult{Success: false, ErrorType: classifyMongoDBErrorType(err), Error: err}
@@ -117,13 +117,12 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo
clientFirstBare := "n=" + cred.Username + ",r=" + nonce clientFirstBare := "n=" + cred.Username + ",r=" + nonce
saslPayload := "n,," + clientFirstBare saslPayload := "n,," + clientFirstBare
saslStartBody := mongoDoc{ saslStartCmd := buildMongoCommand("admin", orderedDoc(
"saslStart": 1, kv("saslStart", 1),
"mechanism": "SCRAM-SHA-1", kv("mechanism", "SCRAM-SHA-1"),
"payload": []byte(saslPayload), kv("payload", []byte(saslPayload)),
"autoAuthorize": 1, kv("autoAuthorize", 1),
} ))
saslStartCmd := buildMongoCommand("admin", saslStartBody)
if _, err := sendMongoMsg(ctx, conn, saslStartCmd, timeout); err != nil { if _, err := sendMongoMsg(ctx, conn, saslStartCmd, timeout); err != nil {
state.IncrementTCPFailedPacketCount() state.IncrementTCPFailedPacketCount()
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
@@ -152,12 +151,11 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err} return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err}
} }
saslContinueBody := mongoDoc{ saslContinueCmd := buildMongoCommand("admin", orderedDoc(
"saslContinue": 1, kv("saslContinue", 1),
"conversationId": int(startReply.conversationID), kv("conversationId", int(startReply.conversationID)),
"payload": []byte(clientFinal), kv("payload", []byte(clientFinal)),
} ))
saslContinueCmd := buildMongoCommand("admin", saslContinueBody)
if _, err := sendMongoMsg(ctx, conn, saslContinueCmd, timeout); err != nil { if _, err := sendMongoMsg(ctx, conn, saslContinueCmd, timeout); err != nil {
state.IncrementTCPFailedPacketCount() state.IncrementTCPFailedPacketCount()
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
@@ -196,64 +194,64 @@ func nextRequestID() uint32 {
} }
// buildMongoCommand 构建 MongoDB 命令的 OP_MSG body (最小 BSON 实现) // buildMongoCommand 构建 MongoDB 命令的 OP_MSG body (最小 BSON 实现)
// key 为字符串时,构建 {key: value} 作为命令名 // MongoDB 要求命令名是 BSON 文档的第一个键,因此使用 orderedDoc 保证顺序。
// key 为 map 时,展开所有字段
func buildMongoCommand(db string, args ...interface{}) []byte { func buildMongoCommand(db string, args ...interface{}) []byte {
var buf []byte var buf []byte
// flags: 0 (ChecksumPresent=0, MoreToCome=0, ExhaustAllowed=0) buf = append(buf, 0, 0, 0, 0) // flags
buf = append(buf, 0, 0, 0, 0) buf = append(buf, 0) // section kind 0: body
// section kind 0: body
buf = append(buf, 0)
// 构建 BSON 文档 var doc []mongoKV
if len(db) > 0 {
// {$db: "admin", ...} for i := 0; i < len(args); i++ {
docs := mongoDoc{"$db": db} switch v := args[i].(type) {
for i := 0; i < len(args); i++ { case string:
switch v := args[i].(type) { if i+1 < len(args) {
case string: doc = append(doc, kv(v, args[i+1]))
if i+1 < len(args) { i++
docs[v] = args[i+1] } else {
i++ doc = append(doc, kv(v, 1))
}
case mongoDoc:
for k, val := range v {
docs[k] = val
}
} }
case mongoDoc:
for k, val := range v {
doc = append(doc, kv(k, val))
}
case []mongoKV:
doc = append(doc, v...)
} }
return append(buf, buildBSON(docs)...)
} }
// 简单命令: {commandName: 1, $db: "admin"} if db != "" {
if len(args) >= 1 { doc = append(doc, kv("$db", db))
docs := mongoDoc{}
if cmdName, ok := args[0].(string); ok {
docs[cmdName] = 1
}
if len(args) >= 2 {
switch v := args[1].(type) {
case mongoDoc:
for k, val := range v {
docs[k] = val
}
}
}
if db != "" {
docs["$db"] = db
}
return append(buf, buildBSON(docs)...)
} }
return buf return append(buf, buildBSON(doc)...)
} }
type mongoDoc map[string]interface{} type mongoDoc = map[string]interface{}
// buildBSON 构建最小 BSON 文档(仅支持 string/int32/double/binary/subdocument type mongoKV struct {
func buildBSON(doc mongoDoc) []byte { Key string
Value interface{}
}
func orderedDoc(kvs ...mongoKV) []mongoKV { return kvs }
func kv(k string, v interface{}) mongoKV { return mongoKV{k, v} }
func buildBSON(doc interface{}) []byte {
var pairs []mongoKV
switch d := doc.(type) {
case []mongoKV:
pairs = d
case mongoDoc:
for k, v := range d {
pairs = append(pairs, mongoKV{k, v})
}
default:
return []byte{5, 0, 0, 0, 0}
}
var buf []byte var buf []byte
for k, v := range doc { for _, p := range pairs {
k, v := p.Key, p.Value
switch val := v.(type) { switch val := v.(type) {
case string: case string:
buf = append(buf, 0x02) // type string buf = append(buf, 0x02) // type string
@@ -284,8 +282,12 @@ func buildBSON(doc mongoDoc) []byte {
buf = append(buf, 0x03) // type document buf = append(buf, 0x03) // type document
buf = append(buf, []byte(k)...) buf = append(buf, []byte(k)...)
buf = append(buf, 0x00) buf = append(buf, 0x00)
sub := buildBSON(val) buf = append(buf, buildBSON(val)...)
buf = append(buf, sub...) case []mongoKV:
buf = append(buf, 0x03) // type document
buf = append(buf, []byte(k)...)
buf = append(buf, 0x00)
buf = append(buf, buildBSON(val)...)
case []byte: case []byte:
buf = append(buf, 0x05) // type binary buf = append(buf, 0x05) // type binary
buf = append(buf, []byte(k)...) buf = append(buf, []byte(k)...)
+23
View File
@@ -6,6 +6,7 @@ import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"errors"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -128,3 +129,25 @@ func TestBuildMongoSCRAMClientFinalBuildsProof(t *testing.T) {
t.Fatalf("client final = %q", got) t.Fatalf("client final = %q", got)
} }
} }
func TestClassifyMongoDBErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil", nil, ErrorTypeUnknown},
{"authentication failed", errors.New("authentication failed"), ErrorTypeAuth},
{"bad auth", errors.New("bad auth"), ErrorTypeAuth},
{"dial tcp", errors.New("dial tcp connection refused"), ErrorTypeNetwork},
{"eof", errors.New("eof"), ErrorTypeNetwork},
{"unknown", errors.New("random mongodb error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyMongoDBErrorType(tt.err); got != tt.want {
t.Errorf("classifyMongoDBErrorType() = %v, want %v", got, tt.want)
}
})
}
}
+30
View File
@@ -0,0 +1,30 @@
//go:build plugin_mssql || !plugin_selective
package services
import (
"errors"
"testing"
)
func TestClassifyMSSQLErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil error", nil, ErrorTypeUnknown},
{"login failed", errors.New("login failed"), ErrorTypeAuth},
{"account locked", errors.New("account locked"), ErrorTypeAuth},
{"context deadline exceeded", errors.New("context deadline exceeded"), ErrorTypeNetwork},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyMSSQLErrorType(tt.err)
if got != tt.want {
t.Errorf("classifyMSSQLErrorType(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+49
View File
@@ -3,6 +3,7 @@
package services package services
import ( import (
"errors"
"io" "io"
"net" "net"
"strings" "strings"
@@ -85,3 +86,51 @@ func TestMySQLConnStringRejectsUnsupportedUsernameDelimiters(t *testing.T) {
t.Fatal("mySQLConnString() error = nil, want unsupported delimiter error") t.Fatal("mySQLConnString() error = nil, want unsupported delimiter error")
} }
} }
func TestMySQLConnStringRejectsAtSign(t *testing.T) {
info := &common.HostInfo{Host: "127.0.0.1", Port: 3306}
if _, err := mySQLConnString("user@host", "pass", info, time.Second); err == nil {
t.Fatal("mySQLConnString() error = nil, want unsupported delimiter error for @")
}
}
func TestMySQLConnStringRejectsSlash(t *testing.T) {
info := &common.HostInfo{Host: "127.0.0.1", Port: 3306}
if _, err := mySQLConnString("user/name", "pass", info, time.Second); err == nil {
t.Fatal("mySQLConnString() error = nil, want unsupported delimiter error for /")
}
}
func TestMySQLConnStringValidUser(t *testing.T) {
info := &common.HostInfo{Host: "127.0.0.1", Port: 3306}
dsn, err := mySQLConnString("root", "password", info, 3*time.Second)
if err != nil {
t.Fatalf("mySQLConnString() error = %v", err)
}
if dsn == "" {
t.Fatal("mySQLConnString() returned empty DSN")
}
}
func TestClassifyMySQLErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil error", nil, ErrorTypeUnknown},
{"access denied for user", errors.New("access denied for user"), ErrorTypeAuth},
{"host is not allowed", errors.New("host is not allowed"), ErrorTypeAuth},
{"too many connections", errors.New("too many connections"), ErrorTypeNetwork},
{"can't connect to mysql server", errors.New("can't connect to mysql server"), ErrorTypeNetwork},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyMySQLErrorType(tt.err)
if got != tt.want {
t.Errorf("classifyMySQLErrorType(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+22
View File
@@ -4,6 +4,7 @@ package services
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@@ -32,3 +33,24 @@ func TestNeo4jUnauthorizedRequiresNeo4jBody(t *testing.T) {
t.Fatalf("testUnauthorizedAccess reported generic 200 as Neo4j: %#v", result) t.Fatalf("testUnauthorizedAccess reported generic 200 as Neo4j: %#v", result)
} }
} }
func TestClassifyNeo4jErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil", nil, ErrorTypeUnknown},
{"authentication failed", errors.New("authentication failed"), ErrorTypeAuth},
{"401 unauthorized", errors.New("401 unauthorized"), ErrorTypeAuth},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
{"unknown", errors.New("random neo4j error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyNeo4jErrorType(tt.err); got != tt.want {
t.Errorf("classifyNeo4jErrorType() = %v, want %v", got, tt.want)
}
})
}
}
+189
View File
@@ -38,3 +38,192 @@ func appendNTLMAVPair(dst []byte, id uint16, value string) []byte {
} }
return append(dst, buf...) return append(dst, buf...)
} }
// --- NetBIOSInfo.Summary ---
func TestNetBIOSInfoSummary(t *testing.T) {
p := NewNetBIOSPlugin()
_ = p // 仅用于确认插件可实例化,Summary 是值方法
cases := []struct {
name string
info NetBIOSInfo
want string
}{
{
name: "invalid returns empty",
info: NetBIOSInfo{Valid: false},
want: "",
},
{
name: "computer + domain no dot",
info: NetBIOSInfo{Valid: true, ComputerName: "PC01", DomainName: "CORP"},
want: "CORP\\PC01",
},
{
name: "computer with dot ignores domain prefix",
info: NetBIOSInfo{Valid: true, ComputerName: "pc01.corp.local", DomainName: "CORP"},
want: "pc01.corp.local",
},
{
name: "no computer uses server service + domain",
info: NetBIOSInfo{Valid: true, ServerService: "SRV01", DomainName: "CORP"},
want: "CORP\\SRV01",
},
{
name: "no computer uses workstation + netbios domain",
info: NetBIOSInfo{Valid: true, WorkstationService: "WKS01", NetBIOSDomainName: "WORKGROUP"},
want: "WORKGROUP\\WKS01",
},
{
name: "domain controller prefix",
info: NetBIOSInfo{Valid: true, ComputerName: "DC1", DomainName: "CORP", DomainControllers: "CORP"},
want: "DC:CORP\\DC1",
},
{
name: "os version appended",
info: NetBIOSInfo{Valid: true, ComputerName: "PC01", DomainName: "CORP", OSVersion: "Windows 10"},
want: "CORP\\PC01 Windows 10",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tc.info.Summary()
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
// --- parseNetBIOSNames ---
func TestParseNetBIOSNames(t *testing.T) {
p := &NetBIOSPlugin{}
t.Run("data too short", func(t *testing.T) {
_, err := p.parseNetBIOSNames(make([]byte, 40))
if err == nil {
t.Fatal("expected error for short data")
}
})
t.Run("numNames zero", func(t *testing.T) {
data := make([]byte, 57) // index 56 = 0
_, err := p.parseNetBIOSNames(data)
if err == nil {
t.Fatal("expected error for zero numNames")
}
})
t.Run("parses workstation and domain records", func(t *testing.T) {
header := make([]byte, 57)
header[56] = 2 // 2 records
// Record 1: WorkstationService — flagByte=0x00, nameFlags=0x04 (unique, <128)
rec1 := make([]byte, 18)
copy(rec1, []byte("TESTPC ")) // 15 bytes
rec1[15] = 0x00 // flagByte = WorkstationService
rec1[16] = 0x04 // nameFlags unique
rec1[17] = 0x00
// Record 2: DomainName — flagByte=0x00, nameFlags=0x84 (group, >=128)
rec2 := make([]byte, 18)
copy(rec2, []byte("WORKGROUP ")) // 15 bytes
rec2[15] = 0x00 // flagByte = DomainName for group
rec2[16] = 0x84 // nameFlags group
rec2[17] = 0x00
data := append(header, rec1...)
data = append(data, rec2...)
info, err := p.parseNetBIOSNames(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !info.Valid {
t.Fatal("expected Valid=true")
}
if info.WorkstationService != "TESTPC" {
t.Errorf("WorkstationService = %q, want TESTPC", info.WorkstationService)
}
if info.DomainName != "WORKGROUP" {
t.Errorf("DomainName = %q, want WORKGROUP", info.DomainName)
}
})
}
// --- cleanOSString ---
func TestCleanOSString(t *testing.T) {
p := &NetBIOSPlugin{}
cases := []struct {
name string
data []byte
want string
}{
{
name: "empty",
data: []byte{},
want: "",
},
{
name: "plain ascii",
data: []byte("Windows Server 2019"),
want: "Windows Server 2019",
},
{
name: "double null splits sections, first is returned",
data: append([]byte("Windows 10\x00\x00"), []byte("Service Pack 1")...),
want: "Windows 10",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := p.cleanOSString(tc.data)
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
// --- parseUnicodeString (NetBIOSPlugin) ---
func TestNetBIOSParseUnicodeString(t *testing.T) {
p := &NetBIOSPlugin{}
cases := []struct {
name string
data []byte
want string
}{
{
name: "empty",
data: []byte{},
want: "",
},
{
name: "odd length returns empty",
data: []byte{0x41},
want: "",
},
{
name: "UTF-16LE AB",
data: []byte{0x41, 0x00, 0x42, 0x00},
want: "AB",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := p.parseUnicodeString(tc.data)
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
+10 -21
View File
@@ -66,34 +66,22 @@ func (p *OraclePlugin) createAuthFunc(info *common.HostInfo, config *common.Conf
} }
} }
// doOracleAuth 执行Oracle认证 // doOracleAuth 执行 Oracle 认证raw TNS 协议)
func (p *OraclePlugin) doOracleAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult { func (p *OraclePlugin) doOracleAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult {
target := info.Target() serviceNames := []string{"XE", "ORCL", "XEPDB1"}
serviceNames := []string{"ORCL", "XE", "XEPDB1", target}
for _, serviceName := range serviceNames { for _, serviceName := range serviceNames {
connectCtx, cancel := context.WithTimeout(ctx, config.ModuleTimeout()) connectCtx, cancel := context.WithTimeout(ctx, config.ModuleTimeout())
err := oracleRawAuth(connectCtx, info.Host, info.Port, serviceName, cred.Username, cred.Password, config.ModuleTimeout()) err := oracleRawAuth(connectCtx, info.Host, info.Port, serviceName, cred.Username, cred.Password, config.ModuleTimeout())
if err != nil { cancel()
cancel() if err == nil {
errorType := classifyOracleErrorType(err) state.IncrementTCPSuccessPacketCount()
if errorType == ErrorTypeAuth { return &AuthResult{Success: true}
return &AuthResult{
Success: false,
ErrorType: errorType,
Error: err,
}
}
continue
} }
cancel() errorType := classifyOracleErrorType(err)
state.IncrementTCPSuccessPacketCount() if errorType == ErrorTypeAuth {
return &AuthResult{Success: false, ErrorType: errorType, Error: err}
return &AuthResult{
Success: true,
ErrorType: ErrorTypeUnknown,
Error: nil,
} }
} }
@@ -105,6 +93,7 @@ func (p *OraclePlugin) doOracleAuth(ctx context.Context, info *common.HostInfo,
} }
} }
// classifyOracleErrorType Oracle错误分类 // classifyOracleErrorType Oracle错误分类
func classifyOracleErrorType(err error) ErrorType { func classifyOracleErrorType(err error) ErrorType {
if err == nil { if err == nil {
+67 -26
View File
@@ -117,7 +117,7 @@ func oracleRawAuth(ctx context.Context, host string, port int, serviceName, user
} }
if s.acfl0&1 != 0 && s.acfl0&4 == 0 && s.acfl1&8 == 0 { if s.acfl0&1 != 0 && s.acfl0&4 == 0 && s.acfl1&8 == 0 {
if err := s.advancedNegotiation(); err != nil { if err := s.advancedNegotiation(); err != nil {
return err return fmt.Errorf("ANO: %w", err)
} }
} }
nego, err := s.protocolNegotiation() nego, err := s.protocolNegotiation()
@@ -156,20 +156,36 @@ func (s *oracleSession) connect(ctx context.Context, host string, port int, serv
if len(connectData) <= 230 { if len(connectData) <= 230 {
copy(buf[70:], connectData) copy(buf[70:], connectData)
} }
if err := s.writeRaw(ctx, buf); err != nil { sendConnect := func() error {
if err := s.writeRaw(ctx, buf); err != nil {
return err
}
if len(connectData) > 230 {
s.reset()
s.putBytes([]byte(connectData)...)
return s.writeData()
}
return nil
}
if err := sendConnect(); err != nil {
return err return err
} }
if len(connectData) > 230 {
s.reset() var p *oraclePacket
s.putBytes([]byte(connectData)...) for resends := 0; resends < 3; resends++ {
if err := s.writeData(); err != nil { var err error
p, err = s.readPacket()
if err != nil {
return err
}
if p.typ != oraclePacketResend {
break
}
if err := sendConnect(); err != nil {
return err return err
} }
} }
p, err := s.readPacket()
if err != nil {
return err
}
switch p.typ { switch p.typ {
case oraclePacketAccept: case oraclePacketAccept:
if len(p.raw) < 40 { if len(p.raw) < 40 {
@@ -188,7 +204,9 @@ func (s *oracleSession) connect(ctx context.Context, host string, port int, serv
} }
s.acfl0 = p.raw[22] s.acfl0 = p.raw[22]
s.acfl1 = p.raw[23] s.acfl1 = p.raw[23]
s.handshakeComplete = true if s.version >= 315 {
s.handshakeComplete = true
}
return nil return nil
case oraclePacketRefuse: case oraclePacketRefuse:
return oracleRefuseError(p.raw) return oracleRefuseError(p.raw)
@@ -658,22 +676,45 @@ func toUint64(v interface{}) uint64 {
} }
func (s *oracleSession) advancedNegotiation() error { func (s *oracleSession) advancedNegotiation() error {
// 按 go-ora 参考实现,构造 ANO 请求
// Service 4 (supervisor): version + cid + servArray
// Service 1 (auth): version + UB2(0xE0E1) + status(0xFCFF)
// Service 2 (encrypt): version + algorithms([0]=rejected) + UB1(1)
// Service 3 (data integrity): version + algorithms([0]=rejected)
// 构建 ANO body 到临时 buffer 计算精确 length
var ab oracleSession
ab.clrChunkSize = s.clrChunkSize
// Service 4 (supervisor): cid + service array
ab.writeANOServiceHeader(4, 3)
ab.writeANOVersion()
ab.writeANOBytes([]byte{0, 0, 16, 28, 102, 236, 40, 234})
ab.writeANOUB2Array([]int{4, 1, 2, 3})
// Service 1 (auth): UB2(0xE0E1) + status(0xFCFF)
ab.writeANOServiceHeader(1, 3)
ab.writeANOVersion()
ab.writeANOPacketHeader(2, 3)
ab.putInt(0xE0E1, 2, true, false)
ab.writeANOStatus(0xfcff)
// Service 2 (encrypt): supported algos + driver
ab.writeANOServiceHeader(2, 3)
ab.writeANOVersion()
ab.writeANOBytes([]byte{0, 1, 8, 10, 6, 2, 15, 16, 17})
ab.writeANOUB1(1)
// Service 3 (data integrity): supported algos
ab.writeANOServiceHeader(3, 2)
ab.writeANOVersion()
ab.writeANOBytes([]byte{0, 1, 3, 4, 5, 6})
body := ab.out.Bytes()
s.reset() s.reset()
s.writeANOHeader(101, 4, 0) s.writeANOHeader(13+len(body), 4, 0)
s.writeANOServiceHeader(4, 3) s.putBytes(body...)
s.writeANOVersion()
s.writeANOBytes([]byte{0, 0, 16, 28, 102, 236, 40, 234})
s.writeANOUB2Array([]int{4, 1, 2, 3})
s.writeANOServiceHeader(1, 3)
s.writeANOVersion()
s.writeANOStatus(0xfcff)
s.writeANOServiceHeader(2, 3)
s.writeANOVersion()
s.writeANOBytes([]byte{0})
s.writeANOUB1(1)
s.writeANOServiceHeader(3, 2)
s.writeANOVersion()
s.writeANOBytes([]byte{0})
if err := s.writeData(); err != nil { if err := s.writeData(); err != nil {
return err return err
} }
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
//go:build plugin_oracle || !plugin_selective
package services
import (
"errors"
"testing"
)
func TestClassifyOracleErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil error", nil, ErrorTypeUnknown},
{"ORA-01017 invalid username/password", errors.New("ORA-01017: invalid username/password"), ErrorTypeAuth},
{"TNS-12541 no listener", errors.New("TNS-12541 no listener"), ErrorTypeNetwork},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyOracleErrorType(tt.err)
if got != tt.want {
t.Errorf("classifyOracleErrorType(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+23
View File
@@ -3,6 +3,7 @@
package services package services
import ( import (
"errors"
"strings" "strings"
"testing" "testing"
@@ -30,3 +31,25 @@ func TestPostgreSQLVulnInfoTruncatesByRune(t *testing.T) {
t.Fatalf("postgresql truncation helper = %q", got) t.Fatalf("postgresql truncation helper = %q", got)
} }
} }
func TestClassifyPostgreSQLErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil", nil, ErrorTypeUnknown},
{"password authentication failed", errors.New("password authentication failed"), ErrorTypeAuth},
{"pq role", errors.New("pq: role \"foo\" does not exist"), ErrorTypeAuth},
{"dial tcp", errors.New("dial tcp connection refused"), ErrorTypeNetwork},
{"eof", errors.New("eof"), ErrorTypeNetwork},
{"unknown", errors.New("random pg error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyPostgreSQLErrorType(tt.err); got != tt.want {
t.Errorf("classifyPostgreSQLErrorType() = %v, want %v", got, tt.want)
}
})
}
}
+22
View File
@@ -5,6 +5,7 @@ package services
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -60,3 +61,24 @@ func (r *chunkedByteReader) Read(p []byte) (int, error) {
r.data = r.data[n:] r.data = r.data[n:]
return n, nil return n, nil
} }
func TestClassifyRabbitMQErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil", nil, ErrorTypeUnknown},
{"authentication failed", errors.New("authentication failed"), ErrorTypeAuth},
{"401 unauthorized", errors.New("401 unauthorized"), ErrorTypeAuth},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
{"unknown", errors.New("random rabbitmq error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyRabbitMQErrorType(tt.err); got != tt.want {
t.Errorf("classifyRabbitMQErrorType() = %v, want %v", got, tt.want)
}
})
}
}
+100
View File
@@ -0,0 +1,100 @@
//go:build plugin_rdp || !plugin_selective
package services
import (
"testing"
"github.com/shadow1ng/fscan/common/i18n"
)
func TestBuildBanner(t *testing.T) {
p := &RDPPlugin{}
fallback := i18n.GetText("rdp_remote_desktop_service")
tests := []struct {
name string
osInfo map[string]any
want string
}{
{
name: "nil map",
osInfo: nil,
want: fallback,
},
{
name: "empty map",
osInfo: map[string]any{},
want: fallback,
},
{
name: "OsVerion and NetBIOSComputerName",
osInfo: map[string]any{"OsVerion": "Windows 10", "NetBIOSComputerName": "DESKTOP-01"},
want: "RDP (Windows 10, DESKTOP-01)",
},
{
name: "only OsVerion",
osInfo: map[string]any{"OsVerion": "Windows Server 2019"},
want: "RDP (Windows Server 2019)",
},
{
name: "only NetBIOSComputerName",
osInfo: map[string]any{"NetBIOSComputerName": "MY-HOST"},
want: "RDP (Hostname:MY-HOST)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := p.buildBanner(tt.osInfo)
if got != tt.want {
t.Errorf("buildBanner() = %q, want %q", got, tt.want)
}
})
}
}
func TestExtractStringField(t *testing.T) {
p := &RDPPlugin{}
tests := []struct {
name string
osInfo map[string]any
key string
want string
}{
{
name: "key exists and is string",
osInfo: map[string]any{"foo": "bar"},
key: "foo",
want: "bar",
},
{
name: "key exists but not string",
osInfo: map[string]any{"foo": 42},
key: "foo",
want: "",
},
{
name: "key does not exist",
osInfo: map[string]any{"foo": "bar"},
key: "missing",
want: "",
},
{
name: "nil map",
osInfo: nil,
key: "foo",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := p.extractStringField(tt.osInfo, tt.key)
if got != tt.want {
t.Errorf("extractStringField(%q) = %q, want %q", tt.key, got, tt.want)
}
})
}
}
+22
View File
@@ -3,6 +3,7 @@
package services package services
import ( import (
"errors"
"net" "net"
"strings" "strings"
"testing" "testing"
@@ -32,3 +33,24 @@ func (c *redisReplyTestConn) RemoteAddr() net.Addr { return nil }
func (c *redisReplyTestConn) SetDeadline(time.Time) error { return nil } func (c *redisReplyTestConn) SetDeadline(time.Time) error { return nil }
func (c *redisReplyTestConn) SetReadDeadline(time.Time) error { return nil } func (c *redisReplyTestConn) SetReadDeadline(time.Time) error { return nil }
func (c *redisReplyTestConn) SetWriteDeadline(time.Time) error { return nil } func (c *redisReplyTestConn) SetWriteDeadline(time.Time) error { return nil }
func TestClassifyRedisErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil", nil, ErrorTypeUnknown},
{"wrongpass", errors.New("wrongpass invalid password"), ErrorTypeAuth},
{"noauth", errors.New("noauth authentication required"), ErrorTypeAuth},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
{"unknown", errors.New("random redis error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyRedisErrorType(tt.err); got != tt.want {
t.Errorf("classifyRedisErrorType() = %v, want %v", got, tt.want)
}
})
}
}
+22
View File
@@ -3,6 +3,7 @@
package services package services
import ( import (
"errors"
"io" "io"
"testing" "testing"
) )
@@ -37,3 +38,24 @@ func TestReadRsyncLineHandlesChunkedReads(t *testing.T) {
t.Fatalf("readRsyncLine() = %q", got) t.Fatalf("readRsyncLine() = %q", got)
} }
} }
func TestClassifyRsyncErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil", nil, ErrorTypeUnknown},
{"authentication failed", errors.New("authentication failed"), ErrorTypeAuth},
{"access denied", errors.New("access denied"), ErrorTypeAuth},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
{"unknown", errors.New("random rsync error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyRsyncErrorType(tt.err); got != tt.want {
t.Errorf("classifyRsyncErrorType() = %v, want %v", got, tt.want)
}
})
}
}
+464
View File
@@ -3,6 +3,7 @@
package services package services
import ( import (
"fmt"
"io" "io"
"net" "net"
"testing" "testing"
@@ -49,3 +50,466 @@ func TestReadSMBMessageHandlesChunkedReads(t *testing.T) {
t.Fatalf("readSMBMessage() = %q", got) t.Fatalf("readSMBMessage() = %q", got)
} }
} }
// ---- parseUnicodeString ----
func TestParseUnicodeString(t *testing.T) {
tests := []struct {
name string
data []byte
want string
}{
{"empty", []byte{}, ""},
{"odd length", []byte{0x41}, ""},
{"null terminated", []byte{0x41, 0x00, 0x00, 0x00}, "A"},
{"ascii", []byte{0x41, 0x00, 0x42, 0x00, 0x43, 0x00}, "ABC"},
{"chinese", []byte{0x2d, 0x4e, 0x87, 0x65}, "中文"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := parseUnicodeString(tt.data); got != tt.want {
t.Errorf("parseUnicodeString() = %q, want %q", got, tt.want)
}
})
}
}
// ---- bytesToUint16 / bytesToUint32 ----
func TestBytesToUint16(t *testing.T) {
if got := bytesToUint16([]byte{}); got != 0 {
t.Errorf("short data: got %d", got)
}
if got := bytesToUint16([]byte{0x01}); got != 0 {
t.Errorf("single byte: got %d", got)
}
if got := bytesToUint16([]byte{0x34, 0x12}); got != 0x1234 {
t.Errorf("LE decode: got 0x%04x", got)
}
}
func TestBytesToUint32(t *testing.T) {
if got := bytesToUint32([]byte{}); got != 0 {
t.Errorf("empty: got %d", got)
}
if got := bytesToUint32([]byte{0x01, 0x02, 0x03}); got != 0 {
t.Errorf("short: got %d", got)
}
if got := bytesToUint32([]byte{0x78, 0x56, 0x34, 0x12}); got != 0x12345678 {
t.Errorf("LE decode: got 0x%08x", got)
}
}
// ---- trimSMBString ----
func TestTrimSMBString(t *testing.T) {
tests := []struct {
input string
want string
}{
{"hello\x00", "hello"},
{"\x00hello\x00", "hello"},
{" hello ", "hello"},
{"\x00", ""},
{"", ""},
}
for _, tt := range tests {
if got := trimSMBString(tt.input); got != tt.want {
t.Errorf("trimSMBString(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// ---- parseNTLMFlags ----
func TestParseNTLMFlags(t *testing.T) {
// 无标志
if got := parseNTLMFlags(0); len(got) != 0 {
t.Errorf("zero flags: want empty, got %v", got)
}
// 单标志 NEGOTIATE_UNICODE
flags := parseNTLMFlags(0x00000001)
if len(flags) != 1 || flags[0] != "NEGOTIATE_UNICODE" {
t.Errorf("single flag: got %v", flags)
}
// 多标志 NEGOTIATE_OEM | NEGOTIATE_NTLM
multi := parseNTLMFlags(0x00000002 | 0x00000200)
if len(multi) != 2 {
t.Errorf("multi flags: want 2, got %d: %v", len(multi), multi)
}
}
// ---- parseOSVersion ----
func TestParseOSVersion(t *testing.T) {
tests := []struct {
name string
data []byte
check func(s string) bool
}{
{
"Windows 10",
[]byte{10, 0, 0x00, 0x47, 0, 0, 0, 0}, // build 18176 < 22000
func(s string) bool { return s != "" && contains(s, "Windows 10") },
},
{
"Windows 11",
[]byte{10, 0, 0x00, 0x5B, 0, 0, 0, 0}, // build 23296 >= 22000
func(s string) bool { return contains(s, "Windows 11") },
},
{
"Windows 7",
[]byte{6, 1, 0x00, 0x09, 0, 0, 0, 0},
func(s string) bool { return contains(s, "Windows 7") },
},
{
"Windows XP",
[]byte{5, 1, 0x00, 0x0A, 0, 0, 0, 0},
func(s string) bool { return contains(s, "Windows XP") },
},
{
"Windows 2000",
[]byte{5, 0, 0x00, 0x07, 0, 0, 0, 0},
func(s string) bool { return contains(s, "Windows 2000") },
},
{
"unknown",
[]byte{4, 0, 0x00, 0x01, 0, 0, 0, 0},
func(s string) bool { return contains(s, "Windows 4.0") },
},
{
"too short",
[]byte{10, 0},
func(s string) bool { return s == "" },
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := &SMBTarget{}
parseOSVersion(tt.data, info)
if !tt.check(info.OSVersion) {
t.Errorf("OSVersion = %q", info.OSVersion)
}
})
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
func() bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
// ---- parseTargetInfo ----
func TestParseTargetInfo(t *testing.T) {
t.Run("empty", func(t *testing.T) {
info := &SMBTarget{}
parseTargetInfo([]byte{}, info)
if info.ComputerName != "" || info.DomainName != "" {
t.Error("expected empty fields")
}
})
makeAVPair := func(avId uint16, value []byte) []byte {
b := []byte{
byte(avId), byte(avId >> 8),
byte(len(value)), byte(len(value) >> 8),
}
b = append(b, value...)
// terminator
b = append(b, 0x00, 0x00, 0x00, 0x00)
return b
}
encodeUTF16LE := func(s string) []byte {
var b []byte
for _, r := range s {
b = append(b, byte(r), byte(uint16(r)>>8))
}
return b
}
t.Run("MsvAvNbComputerName", func(t *testing.T) {
info := &SMBTarget{}
parseTargetInfo(makeAVPair(0x0001, encodeUTF16LE("MYPC")), info)
if info.ComputerName != "MYPC" {
t.Errorf("ComputerName = %q", info.ComputerName)
}
})
t.Run("MsvAvNbDomainName", func(t *testing.T) {
info := &SMBTarget{}
parseTargetInfo(makeAVPair(0x0002, encodeUTF16LE("DOMAIN")), info)
if info.DomainName != "DOMAIN" {
t.Errorf("DomainName = %q", info.DomainName)
}
})
t.Run("MsvAvDnsComputerName_fallback", func(t *testing.T) {
info := &SMBTarget{}
parseTargetInfo(makeAVPair(0x0003, encodeUTF16LE("dns.host")), info)
if info.ComputerName != "dns.host" {
t.Errorf("ComputerName = %q", info.ComputerName)
}
})
t.Run("terminator only", func(t *testing.T) {
info := &SMBTarget{}
parseTargetInfo([]byte{0x00, 0x00, 0x00, 0x00}, info)
if info.ComputerName != "" || info.DomainName != "" {
t.Error("expected empty fields")
}
})
}
// ---- parseNTLMChallenge ----
// buildNTLMChallengePacket 构建测试用 NTLM Challenge 包。
// targetName 和 targetInfo 均为 UTF-16LE 编码字节。
// flags 应包含 0x02000000 (NEGOTIATE_VERSION) 才会有 version 字段。
func buildNTLMChallengePacket(targetName []byte, flags uint32, targetInfo []byte, version []byte) []byte {
// 固定头:signature(8) + msgType(4) + targetLen(2) + targetMaxLen(2) + targetOffset(4)
// + flags(4) + challenge(8) + reserved(8) + targetInfoLen(2) + targetInfoMaxLen(2) + targetInfoOffset(4)
// + version(8, optional) + payload
headerSize := 56 // 8+4+2+2+4+4+8+8+2+2+4+8 (version always included here)
targetOffset := uint32(headerSize)
targetInfoOffset := targetOffset + uint32(len(targetName))
buf := make([]byte, 0, headerSize+len(targetName)+len(targetInfo))
// signature
buf = append(buf, []byte("NTLMSSP\x00")...)
// messageType = 2
buf = append(buf, 0x02, 0x00, 0x00, 0x00)
// targetLength
buf = append(buf, byte(len(targetName)), byte(len(targetName)>>8))
// targetMaxLength
buf = append(buf, byte(len(targetName)), byte(len(targetName)>>8))
// targetOffset
buf = append(buf, byte(targetOffset), byte(targetOffset>>8), byte(targetOffset>>16), byte(targetOffset>>24))
// flags
buf = append(buf, byte(flags), byte(flags>>8), byte(flags>>16), byte(flags>>24))
// challenge (8 bytes)
buf = append(buf, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08)
// reserved (8 bytes)
buf = append(buf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
// targetInfoLength
buf = append(buf, byte(len(targetInfo)), byte(len(targetInfo)>>8))
// targetInfoMaxLength
buf = append(buf, byte(len(targetInfo)), byte(len(targetInfo)>>8))
// targetInfoOffset
buf = append(buf, byte(targetInfoOffset), byte(targetInfoOffset>>8), byte(targetInfoOffset>>16), byte(targetInfoOffset>>24))
// version (8 bytes)
if len(version) == 8 {
buf = append(buf, version...)
} else {
buf = append(buf, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
}
// payload
buf = append(buf, targetName...)
buf = append(buf, targetInfo...)
return buf
}
func TestParseNTLMChallenge(t *testing.T) {
t.Run("too short", func(t *testing.T) {
info := &SMBTarget{}
parseNTLMChallenge(make([]byte, 10), info)
if info.DomainName != "" {
t.Error("expected no domain")
}
})
t.Run("bad signature", func(t *testing.T) {
data := make([]byte, 64)
copy(data, "BADMAGIC")
info := &SMBTarget{}
parseNTLMChallenge(data, info)
if info.DomainName != "" {
t.Error("expected no domain")
}
})
t.Run("wrong message type", func(t *testing.T) {
data := make([]byte, 64)
copy(data, "NTLMSSP\x00")
data[8] = 0x01 // messageType = 1, not 2
info := &SMBTarget{}
parseNTLMChallenge(data, info)
if info.DomainName != "" {
t.Error("expected no domain for wrong message type")
}
})
t.Run("valid challenge with domain", func(t *testing.T) {
encodeUTF16LE := func(s string) []byte {
var b []byte
for _, r := range s {
b = append(b, byte(r), byte(uint16(r)>>8))
}
return b
}
targetName := encodeUTF16LE("WORKGROUP")
flags := uint32(0x00000001 | 0x00000200) // UNICODE | NTLM, no VERSION flag
data := buildNTLMChallengePacket(targetName, flags, nil, nil)
info := &SMBTarget{}
parseNTLMChallenge(data, info)
if info.DomainName != "WORKGROUP" {
t.Errorf("DomainName = %q, want WORKGROUP", info.DomainName)
}
})
t.Run("valid challenge with targetInfo and version", func(t *testing.T) {
encodeUTF16LE := func(s string) []byte {
var b []byte
for _, r := range s {
b = append(b, byte(r), byte(uint16(r)>>8))
}
return b
}
targetName := encodeUTF16LE("CORP")
// AV_PAIR: MsvAvNbComputerName = "SERVER"
computerNameBytes := encodeUTF16LE("SERVER")
avPair := []byte{
0x01, 0x00,
byte(len(computerNameBytes)), byte(len(computerNameBytes) >> 8),
}
avPair = append(avPair, computerNameBytes...)
avPair = append(avPair, 0x00, 0x00, 0x00, 0x00) // terminator
// NEGOTIATE_VERSION flag = 0x02000000
flags := uint32(0x02000000 | 0x00000001 | 0x00000200)
// Windows 10 build 19041
version := []byte{10, 0, 0xA1, 0x4A, 0x00, 0x00, 0x00, 0x0F}
data := buildNTLMChallengePacket(targetName, flags, avPair, version)
info := &SMBTarget{}
parseNTLMChallenge(data, info)
if info.DomainName != "CORP" {
t.Errorf("DomainName = %q, want CORP", info.DomainName)
}
if info.ComputerName != "SERVER" {
t.Errorf("ComputerName = %q, want SERVER", info.ComputerName)
}
if info.OSVersion == "" {
t.Error("OSVersion should not be empty")
}
if len(info.NTLMFlags) == 0 {
t.Error("NTLMFlags should not be empty")
}
})
}
// ---- classifySMBError ----
func TestClassifySMBError(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
if got := classifySMBError(nil); got != ErrorTypeUnknown {
t.Errorf("nil: got %v", got)
}
})
t.Run("auth error keyword", func(t *testing.T) {
err := fmt.Errorf("authentication failed")
if got := classifySMBError(err); got != ErrorTypeAuth {
t.Errorf("auth keyword: got %v", got)
}
})
t.Run("NT status code", func(t *testing.T) {
err := fmt.Errorf("nt_status_logon_failure")
if got := classifySMBError(err); got != ErrorTypeAuth {
t.Errorf("NT status: got %v", got)
}
})
t.Run("network error", func(t *testing.T) {
err := fmt.Errorf("connection refused")
if got := classifySMBError(err); got != ErrorTypeNetwork {
t.Errorf("network: got %v", got)
}
})
}
// ---- SMBProtocol.String() ----
func TestSMBProtocolString(t *testing.T) {
tests := []struct {
p SMBProtocol
want string
}{
{SMBProtocol1, "SMBv1"},
{SMBProtocol2, "SMBv2"},
{SMBProtocolUnknown, "Unknown"},
{SMBProtocol(99), "Unknown"},
}
for _, tt := range tests {
if got := tt.p.String(); got != tt.want {
t.Errorf("SMBProtocol(%d).String() = %q, want %q", tt.p, got, tt.want)
}
}
}
// ---- SMBTarget.Summary() ----
func TestSMBTargetSummary(t *testing.T) {
t.Run("only protocol", func(t *testing.T) {
info := &SMBTarget{Protocol: SMBProtocol2}
if got := info.Summary(); got != "SMBv2" {
t.Errorf("got %q", got)
}
})
t.Run("full fields", func(t *testing.T) {
info := &SMBTarget{
Protocol: SMBProtocol1,
OSVersion: "Windows 10 (Build 19041)",
ComputerName: "MYPC",
}
got := info.Summary()
if !contains(got, "SMBv1") || !contains(got, "Windows 10") || !contains(got, "MYPC") {
t.Errorf("Summary() = %q", got)
}
})
t.Run("empty optional fields", func(t *testing.T) {
info := &SMBTarget{Protocol: SMBProtocolUnknown}
if got := info.Summary(); got != "Unknown" {
t.Errorf("got %q", got)
}
})
}
// ---- buildNTLMSSPData ----
func TestBuildNTLMSSPData(t *testing.T) {
flags := []byte{0x07, 0x82, 0x08, 0xA2}
got := buildNTLMSSPData(flags)
if len(got) == 0 {
t.Fatal("buildNTLMSSPData returned empty")
}
// 长度固定(实际为158字节)
const wantLen = 158
if len(got) != wantLen {
t.Errorf("len = %d, want %d", len(got), wantLen)
}
// flags 嵌入在偏移138处
const flagsOffset = 138
if got[flagsOffset] != flags[0] || got[flagsOffset+1] != flags[1] ||
got[flagsOffset+2] != flags[2] || got[flagsOffset+3] != flags[3] {
t.Errorf("flags not embedded correctly at offset %d: got %x %x %x %x",
flagsOffset, got[flagsOffset], got[flagsOffset+1], got[flagsOffset+2], got[flagsOffset+3])
}
}
+30
View File
@@ -0,0 +1,30 @@
//go:build plugin_smtp || !plugin_selective
package services
import (
"errors"
"testing"
)
func TestClassifySMTPErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil error", nil, ErrorTypeUnknown},
{"535 authentication failed", errors.New("535 authentication failed"), ErrorTypeAuth},
{"relay access denied", errors.New("relay access denied"), ErrorTypeAuth},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifySMTPErrorType(tt.err)
if got != tt.want {
t.Errorf("classifySMTPErrorType(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+156
View File
@@ -0,0 +1,156 @@
//go:build plugin_snmp || !plugin_selective
package services
import (
"encoding/asn1"
"testing"
"github.com/shadow1ng/fscan/common"
)
// --- buildSNMPGetRequest ---
func TestBuildSNMPGetRequest(t *testing.T) {
oid := []int{1, 3, 6, 1, 2, 1, 1, 1, 0}
t.Run("returns non-empty bytes starting with ASN.1 SEQUENCE", func(t *testing.T) {
pkt := buildSNMPGetRequest("public", oid)
if len(pkt) == 0 {
t.Fatal("expected non-empty packet")
}
if pkt[0] != 0x30 {
t.Errorf("first byte = 0x%02x, want 0x30 (ASN.1 SEQUENCE)", pkt[0])
}
})
t.Run("different communities produce different lengths", func(t *testing.T) {
pkt1 := buildSNMPGetRequest("public", oid)
pkt2 := buildSNMPGetRequest("longercommunitystringhere", oid)
if len(pkt1) >= len(pkt2) {
t.Errorf("expected longer community to produce longer packet: len(public)=%d len(long)=%d", len(pkt1), len(pkt2))
}
})
}
// --- marshalOIDWithNull ---
func TestMarshalOIDWithNull(t *testing.T) {
oid := []int{1, 3, 6, 1, 2, 1, 1, 1, 0}
result := marshalOIDWithNull(oid)
if len(result) == 0 {
t.Fatal("expected non-empty bytes")
}
// 应包含 OID tag (0x06) 和 NULL tag (0x05)
foundOID := false
foundNull := false
for _, b := range result {
if b == 0x06 {
foundOID = true
}
if b == 0x05 {
foundNull = true
}
}
if !foundOID {
t.Error("expected OID tag 0x06 in output")
}
if !foundNull {
t.Error("expected NULL tag 0x05 in output")
}
}
// --- parseSNMPResponse ---
// buildTestSNMPResponse 构造最小合法 SNMPv2c GetResponse 包含 OctetString value
func buildTestSNMPResponse(community string, value string) []byte {
valBytes, _ := asn1.Marshal(asn1.RawValue{Class: 0, Tag: 4, Bytes: []byte(value)})
oidBytes, _ := asn1.Marshal(asn1.ObjectIdentifier{1, 3, 6, 1, 2, 1, 1, 1, 0})
var vbContent []byte
vbContent = append(vbContent, oidBytes...)
vbContent = append(vbContent, valBytes...)
varbind, _ := asn1.Marshal(asn1.RawValue{Class: 0, Tag: 16, IsCompound: true, Bytes: vbContent})
varbindList, _ := asn1.Marshal(asn1.RawValue{Class: 0, Tag: 16, IsCompound: true, Bytes: varbind})
reqID, _ := asn1.Marshal(12345)
errStatus, _ := asn1.Marshal(0)
errIndex, _ := asn1.Marshal(0)
var pduContent []byte
pduContent = append(pduContent, reqID...)
pduContent = append(pduContent, errStatus...)
pduContent = append(pduContent, errIndex...)
pduContent = append(pduContent, varbindList...)
// GetResponse PDU: context-specific tag 2
pdu, _ := asn1.Marshal(asn1.RawValue{Class: 2, Tag: 2, IsCompound: true, Bytes: pduContent})
version, _ := asn1.Marshal(1) // SNMPv2c
comm, _ := asn1.Marshal([]byte(community))
var msgContent []byte
msgContent = append(msgContent, version...)
msgContent = append(msgContent, comm...)
msgContent = append(msgContent, pdu...)
msg, _ := asn1.Marshal(asn1.RawValue{Class: 0, Tag: 16, IsCompound: true, Bytes: msgContent})
return msg
}
func TestParseSNMPResponse(t *testing.T) {
t.Run("empty data returns empty", func(t *testing.T) {
got := parseSNMPResponse([]byte{})
if got != "" {
t.Errorf("got %q, want empty", got)
}
})
t.Run("invalid ASN.1 returns empty", func(t *testing.T) {
got := parseSNMPResponse([]byte{0xFF, 0xFF, 0xFF})
if got != "" {
t.Errorf("got %q, want empty", got)
}
})
t.Run("valid response returns sysDescr value", func(t *testing.T) {
want := "Linux router 5.4.0"
pkt := buildTestSNMPResponse("public", want)
got := parseSNMPResponse(pkt)
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
}
// --- buildCommunityList ---
func TestBuildCommunityList(t *testing.T) {
p := NewSNMPPlugin()
cfg := &common.Config{}
list := p.buildCommunityList(cfg)
if len(list) == 0 {
t.Fatal("community list must not be empty")
}
hasPublic := false
hasPrivate := false
for _, c := range list {
if c == "public" {
hasPublic = true
}
if c == "private" {
hasPrivate = true
}
}
if !hasPublic {
t.Error("community list must contain 'public'")
}
if !hasPrivate {
t.Error("community list must contain 'private'")
}
}
+60
View File
@@ -0,0 +1,60 @@
//go:build plugin_ssh || !plugin_selective
package services
import (
"errors"
"testing"
)
func TestClassifySSHErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil error", nil, ErrorTypeUnknown},
{"unable to authenticate", errors.New("unable to authenticate"), ErrorTypeAuth},
{"no supported methods remain", errors.New("no supported methods remain"), ErrorTypeAuth},
{"handshake failed", errors.New("handshake failed"), ErrorTypeThrottle},
{"ssh disconnect", errors.New("ssh: disconnect"), ErrorTypeThrottle},
{"max startups", errors.New("max startups"), ErrorTypeThrottle},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
{"random error", errors.New("random error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifySSHErrorType(tt.err)
if got != tt.want {
t.Errorf("classifySSHErrorType(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestClassifySSHError(t *testing.T) {
authKeywords := []string{"bad password", "invalid key"}
throttleKeywords := []string{"rate limited", "too fast"}
tests := []struct {
name string
err error
want ErrorType
}{
{"nil error", nil, ErrorTypeUnknown},
{"custom auth keyword", errors.New("bad password provided"), ErrorTypeAuth},
{"custom throttle keyword", errors.New("rate limited by server"), ErrorTypeThrottle},
{"network error", errors.New("connection refused"), ErrorTypeNetwork},
{"no match", errors.New("something else"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifySSHError(tt.err, authKeywords, throttleKeywords)
if got != tt.want {
t.Errorf("classifySSHError(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+39 -10
View File
@@ -297,10 +297,6 @@ func (p *TelnetPlugin) performTelnetAuth(conn net.Conn, username, password strin
cleaned := p.cleanResponse(response) cleaned := p.cleanResponse(response)
cleanedLower := strings.ToLower(cleaned) cleanedLower := strings.ToLower(cleaned)
if p.isShellPrompt(cleaned) {
return true
}
if strings.Contains(cleanedLower, "login") || if strings.Contains(cleanedLower, "login") ||
strings.Contains(cleanedLower, "username") || strings.Contains(cleanedLower, "username") ||
strings.Contains(cleaned, ":") { strings.Contains(cleaned, ":") {
@@ -429,14 +425,47 @@ func (p *TelnetPlugin) isShellPrompt(data string) bool {
return false return false
} }
data = strings.ToLower(strings.TrimSpace(data)) data = strings.TrimSpace(data)
shellPrompts := []string{"$", "#", ">", "~$", "]$", ")#", "bash", "shell", "cmd"} for _, line := range strings.Split(data, "\n") {
line = strings.TrimSpace(line)
for _, prompt := range shellPrompts { if line == "" {
if strings.Contains(data, prompt) { continue
return true
} }
lineLower := strings.ToLower(line)
// 关键字匹配(整行包含即可)
for _, kw := range []string{"bash", "shell", "cmd"} {
if strings.Contains(lineLower, kw) {
return true
}
}
// 行尾 prompt 符号匹配:取最后一个非空格字符
trimmed := strings.TrimRight(line, " ")
if len(trimmed) == 0 {
continue
}
tail := trimmed[len(trimmed)-1]
if tail != '#' && tail != '$' && tail != '>' {
continue
}
// 排除装饰线:整行都是同一个字符(如 #### 或 >>>>
allSame := true
for _, c := range trimmed {
if byte(c) != tail {
allSame = false
break
}
}
if allSame {
continue
}
return true
} }
return false return false
+67
View File
@@ -3,6 +3,7 @@
package services package services
import ( import (
"errors"
"strings" "strings"
"testing" "testing"
"unicode/utf8" "unicode/utf8"
@@ -15,3 +16,69 @@ func TestTelnetExtractEvidenceTruncatesByRune(t *testing.T) {
t.Fatalf("extractEvidence() = %q", got) t.Fatalf("extractEvidence() = %q", got)
} }
} }
func TestClassifyTelnetErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil", nil, ErrorTypeUnknown},
{"login failed", errors.New("login failed"), ErrorTypeAuth},
{"credentials rejected", errors.New("credentials rejected"), ErrorTypeAuth},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
{"unknown", errors.New("random telnet error"), ErrorTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := classifyTelnetErrorType(tt.err); got != tt.want {
t.Errorf("classifyTelnetErrorType() = %v, want %v", got, tt.want)
}
})
}
}
func TestIsShellPrompt(t *testing.T) {
p := NewTelnetPlugin()
positive := []struct {
name, data string
}{
{"linux root", "root@host:~#"},
{"linux user", "user@host:~$"},
{"cisco", "Router>"},
{"cisco enable", "Router#"},
{"bracket prompt", "[admin@host ~]$"},
{"paren prompt", "host(config)#"},
{"bash keyword", "bash-4.2$"},
{"trailing space", "root@host:~# "},
{"multiline last", "Welcome\nroot@host:~#"},
}
negative := []struct {
name, data string
}{
{"empty", ""},
{"decoration hashes", "################"},
{"decoration arrows", ">>>>>>>>"},
{"decoration dollars", "$$$$$$$$"},
{"cisco motd border", "###################################################"},
{"motd with hash mid", "# Welcome to Cisco IOS"},
{"plain text", "Cisco IOS Software, Version 12.2"},
{"login prompt", "Login:"},
{"password prompt", "Password:"},
{"motd multiline", "##########\nWelcome to Router\n##########"},
}
for _, tt := range positive {
if !p.isShellPrompt(tt.data) {
t.Errorf("isShellPrompt(%q) = false, want true [%s]", tt.data, tt.name)
}
}
for _, tt := range negative {
if p.isShellPrompt(tt.data) {
t.Errorf("isShellPrompt(%q) = true, want false [%s]", tt.data, tt.name)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
//go:build plugin_vnc || !plugin_selective
package services
import (
"errors"
"testing"
)
func TestClassifyVNCErrorType(t *testing.T) {
tests := []struct {
name string
err error
want ErrorType
}{
{"nil error", nil, ErrorTypeUnknown},
{"authentication failed", errors.New("authentication failed"), ErrorTypeAuth},
{"too many authentication failures", errors.New("too many authentication failures"), ErrorTypeNetwork},
{"connection refused", errors.New("connection refused"), ErrorTypeNetwork},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := classifyVNCErrorType(tt.err)
if got != tt.want {
t.Errorf("classifyVNCErrorType(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+1
View File
@@ -270,6 +270,7 @@ func (p *WebTitlePlugin) identifyFingerprintsMulti(ctx context.Context, info *co
// 非全量模式下,基于指纹触发POC扫描 // 非全量模式下,基于指纹触发POC扫描
if !config.POC.Full && !config.POC.Disabled { if !config.POC.Full && !config.POC.Disabled {
info.URL = baseURL
p.triggerPocScan(ctx, info, fingerprints, config, session) p.triggerPocScan(ctx, info, fingerprints, config, session)
} }
+191
View File
@@ -101,3 +101,194 @@ func TestReadWebTitleBodyIsBounded(t *testing.T) {
t.Fatalf("body len = %d, want %d", len(got), maxWebTitleBodyBytes) t.Fatalf("body len = %d, want %d", len(got), maxWebTitleBodyBytes)
} }
} }
func TestResolveRedirectURL(t *testing.T) {
p := NewWebTitlePlugin()
base := "http://example.com/path"
tests := []struct {
name string
location string
want string
}{
{"absolute http", "http://other.com/page", "http://other.com/page"},
{"absolute https", "https://other.com/page", "https://other.com/page"},
{"relative path", "/admin/login", "http://example.com/admin/login"},
{"relative no slash", "login", "http://example.com/login"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := p.resolveRedirectURL(base, tt.location)
if got != tt.want {
t.Fatalf("resolveRedirectURL(%q, %q) = %q, want %q", base, tt.location, got, tt.want)
}
})
}
}
func TestResolveRedirectURLInvalidBase(t *testing.T) {
p := NewWebTitlePlugin()
got := p.resolveRedirectURL("://bad-url", "/path")
if got != "" {
t.Fatalf("expected empty string for invalid base, got %q", got)
}
}
func TestResolveRedirectURLInvalidLocation(t *testing.T) {
p := NewWebTitlePlugin()
// 百分号开头的无效 URL
got := p.resolveRedirectURL("http://example.com", "://")
// net/url.Parse 对 "://" 不一定报错,只要不 panic 即可
_ = got
}
func TestFormatHeaders(t *testing.T) {
p := NewWebTitlePlugin()
// 空 header
if got := p.formatHeaders(http.Header{}); got != "" {
t.Fatalf("empty headers = %q, want empty string", got)
}
// 单个 header
h := http.Header{}
h.Set("Content-Type", "text/html")
got := p.formatHeaders(h)
if !strings.Contains(got, "Content-Type") || !strings.Contains(got, "text/html") {
t.Fatalf("formatHeaders missing expected content: %q", got)
}
// 多值 header
h2 := http.Header{}
h2.Add("X-Custom", "val1")
h2.Add("X-Custom", "val2")
got2 := p.formatHeaders(h2)
if !strings.Contains(got2, "val1") || !strings.Contains(got2, "val2") {
t.Fatalf("formatHeaders missing multi-value: %q", got2)
}
}
func TestURLHost(t *testing.T) {
tests := []struct {
input string
want string
}{
{"127.0.0.1", "127.0.0.1"},
{"example.com", "example.com"},
{"::1", "[::1]"},
{"[::1]", "[::1]"}, // 已经括起来的不要双重括号
}
for _, tt := range tests {
got := urlHost(tt.input)
if got != tt.want {
t.Fatalf("urlHost(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestTruncateRunes(t *testing.T) {
// 负数 maxRunes → 原样返回
s := "hello"
if got := truncateRunes(s, -1); got != s {
t.Fatalf("truncateRunes negative = %q, want %q", got, s)
}
// 短于 maxRunes → 原样返回
if got := truncateRunes("ab", 10); got != "ab" {
t.Fatalf("truncateRunes short = %q, want %q", got, "ab")
}
// 超过 maxRunes → 截断加 "..."
long := strings.Repeat("x", 5)
got := truncateRunes(long, 3)
if got != "xxx..." {
t.Fatalf("truncateRunes long = %q, want %q", got, "xxx...")
}
// maxRunes=0 → 立刻截断
if got := truncateRunes("hello", 0); got != "..." {
t.Fatalf("truncateRunes zero = %q, want %q", got, "...")
}
}
func TestExtractTitleInvalidUTF8(t *testing.T) {
p := NewWebTitlePlugin()
// 构造含非法 UTF-8 字节的 title
html := "<html><title>\xff\xfe</title></html>"
got := p.extractTitle(html)
// 非法 UTF-8 应返回空
if got != "" {
t.Fatalf("extractTitle with invalid UTF-8 = %q, want empty", got)
}
}
func TestExtractTitleNoMatch(t *testing.T) {
p := NewWebTitlePlugin()
got := p.extractTitle("<html><body>no title here</body></html>")
if got != "" {
t.Fatalf("extractTitle no match = %q, want empty", got)
}
}
func TestWebTitleHTTPClientsGM(t *testing.T) {
previousGM, previousNoRedirectGM := lib.ClientGM, lib.ClientNoRedirectGM
defer func() {
lib.ClientGM, lib.ClientNoRedirectGM = previousGM, previousNoRedirectGM
}()
// 设置 GM 客户端为非 nil
lib.ClientGM = &http.Client{}
lib.ClientNoRedirectGM = &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
clientNR, clientR := webTitleHTTPClients(true)
if clientNR == nil || clientR == nil {
t.Fatal("webTitleHTTPClients(GM) returned nil")
}
}
func TestFirstHTTPClientAllNil(t *testing.T) {
got := firstHTTPClient(nil, nil, nil)
if got != http.DefaultClient {
t.Fatalf("firstHTTPClient all nil = %v, want http.DefaultClient", got)
}
}
func TestFetchFaviconHashNon200(t *testing.T) {
previous := lib.Client
lib.Client = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusNotFound,
Body: http.NoBody,
}, nil
}),
}
defer func() { lib.Client = previous }()
p := NewWebTitlePlugin()
hashes := p.fetchFaviconHash(context.Background(), "http://example.com")
if len(hashes.MMH3) != 0 || len(hashes.MD5) != 0 {
t.Fatalf("fetchFaviconHash non-200 returned hashes: %#v", hashes)
}
}
func TestFetchFaviconHashBadURL(t *testing.T) {
p := NewWebTitlePlugin()
// 无效 URL 应返回空 hash,不 panic
hashes := p.fetchFaviconHash(context.Background(), "://bad")
if len(hashes.MMH3) != 0 || len(hashes.MD5) != 0 {
t.Fatalf("fetchFaviconHash bad URL returned hashes: %#v", hashes)
}
}
// roundTripFunc 允许用函数实现 http.RoundTripper
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
+264
View File
@@ -0,0 +1,264 @@
services:
redis:
image: redis:7-alpine
command: redis-server --requirepass test123
ports:
- "16379:6379"
healthcheck:
test: ["CMD", "redis-cli", "-a", "test123", "ping"]
interval: 3s
retries: 10
redis-noauth:
image: redis:7-alpine
ports:
- "16380:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
retries: 10
mysql:
image: mysql:8.0
command: --default-authentication-plugin=mysql_native_password
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_ROOT_HOST: "%"
MYSQL_DATABASE: testdb
ports:
- "13307:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot123"]
interval: 5s
retries: 20
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres123
POSTGRES_DB: testdb
ports:
- "15432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
retries: 10
ftp:
image: fauria/vsftpd
environment:
FTP_USER: ftpuser
FTP_PASS: ftp123
PASV_MIN_PORT: 21100
PASV_MAX_PORT: 21110
PASV_ADDRESS: 127.0.0.1
ports:
- "10021:21"
- "21100-21110:21100-21110"
healthcheck:
test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/21' || exit 1"]
interval: 5s
retries: 10
ssh:
image: lscr.io/linuxserver/openssh-server:latest
environment:
PUID: 1000
PGID: 1000
USER_NAME: sshuser
USER_PASSWORD: ssh123
PASSWORD_ACCESS: "true"
ports:
- "10022:2222"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 2222 || exit 1"]
interval: 3s
retries: 10
mongodb:
image: mongo:4.4
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: mongo123
ports:
- "17017:27017"
healthcheck:
test: ["CMD", "mongo", "--eval", "db.adminCommand('ping')", "-u", "admin", "-p", "mongo123"]
interval: 5s
retries: 20
memcached:
image: memcached:1-alpine
ports:
- "11211:11211"
healthcheck:
test: ["CMD-SHELL", "echo stats | nc localhost 11211 | grep -q pid"]
interval: 3s
retries: 10
elasticsearch:
image: elasticsearch:7.17.24
environment:
discovery.type: single-node
xpack.security.enabled: "false"
ES_JAVA_OPTS: "-Xms256m -Xmx256m"
ports:
- "19200:9200"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
interval: 5s
retries: 20
mssql:
image: mcr.microsoft.com/mssql/server:2019-latest
environment:
ACCEPT_EULA: "Y"
SA_PASSWORD: "MssqlTest123!"
MSSQL_PID: Express
ports:
- "11433:1433"
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'MssqlTest123!' -Q 'SELECT 1' || exit 1"]
interval: 5s
retries: 30
rabbitmq:
image: rabbitmq:3-management-alpine
environment:
RABBITMQ_DEFAULT_USER: admin
RABBITMQ_DEFAULT_PASS: rabbit123
ports:
- "15672:15672"
- "15673:5672"
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_running"]
interval: 5s
retries: 20
mqtt:
image: eclipse-mosquitto:2
ports:
- "11883:1883"
volumes:
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
healthcheck:
test: ["CMD-SHELL", "mosquitto_sub -t '$$SYS/#' -C 1 -W 2 || exit 1"]
interval: 5s
retries: 10
openldap:
image: osixia/openldap:1.5.0
environment:
LDAP_ORGANISATION: "Test"
LDAP_DOMAIN: "test.local"
LDAP_ADMIN_PASSWORD: "ldap123"
ports:
- "10389:389"
healthcheck:
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost -b 'dc=test,dc=local' -D 'cn=admin,dc=test,dc=local' -w ldap123 || exit 1"]
interval: 5s
retries: 10
cassandra:
image: cassandra:4.1
environment:
CASSANDRA_AUTHENTICATOR: AllowAllAuthenticator
ports:
- "19042:9042"
healthcheck:
test: ["CMD-SHELL", "cqlsh -e 'DESCRIBE CLUSTER' || exit 1"]
interval: 10s
retries: 30
neo4j:
image: neo4j:5
environment:
NEO4J_AUTH: "neo4j/neo4jtest123"
ports:
- "17687:7687"
- "17474:7474"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
interval: 5s
retries: 20
kafka:
image: apache/kafka:3.7.0
ports:
- "19092:9092"
healthcheck:
test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list || exit 1"]
interval: 10s
retries: 20
smtp:
image: mailhog/mailhog
ports:
- "11025:1025"
- "18025:8025"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8025/api/v2/messages || exit 1"]
interval: 5s
retries: 10
oracle:
image: gvenzl/oracle-xe:18-slim
environment:
ORACLE_PASSWORD: oracle123
ports:
- "11521:1521"
healthcheck:
test: ["CMD-SHELL", "healthcheck.sh"]
interval: 10s
retries: 30
activemq:
image: rmohr/activemq:5.15.9
ports:
- "11613:61613"
- "18161:8161"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://admin:admin@localhost:8161/api/jolokia || exit 1"]
interval: 5s
retries: 15
zookeeper:
image: zookeeper:3.9
ports:
- "12181:2181"
healthcheck:
test: ["CMD-SHELL", "echo ruok | nc localhost 2181 | grep -q imok"]
interval: 5s
retries: 10
rsync:
image: vimagick/rsyncd
ports:
- "10873:873"
volumes:
- ./rsyncd.conf:/etc/rsyncd.conf:ro
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 873 || exit 1"]
interval: 5s
retries: 10
vnc:
image: consol/debian-xfce-vnc:latest
environment:
VNC_PW: vnc123
ports:
- "15901:5901"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 5901 || exit 1"]
interval: 5s
retries: 15
snmp:
image: polinux/snmpd
ports:
- "10161:161/udp"
healthcheck:
test: ["CMD-SHELL", "snmpget -v2c -c public localhost sysDescr.0 || exit 0"]
interval: 5s
retries: 10
+570
View File
@@ -0,0 +1,570 @@
//go:build integration
package integration
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/config"
"github.com/shadow1ng/fscan/plugins/services"
)
const (
testHost = "127.0.0.1"
)
func testSession() *common.ScanSession {
cfg := common.NewConfig()
cfg.Timeout = 10 * time.Second
cfg.ModuleThreadNum = 5
cfg.MaxRetries = 2
cfg.Credentials.Userdict = nil
cfg.Credentials.Passwords = nil
state := common.NewState()
return common.NewScanSession(cfg, state, &common.FlagVars{})
}
func hostInfo(host string, port int) *common.HostInfo {
return &common.HostInfo{Host: host, Port: port}
}
func TestMain(m *testing.M) {
fmt.Println("integration tests: ensure docker-compose services are running")
os.Exit(m.Run())
}
// ── Redis ──────────────────────────────────────────────────────
func TestRedisUnauthorized(t *testing.T) {
session := testSession()
info := hostInfo(testHost, 16380)
plugin := services.NewRedisPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected unauthorized redis to succeed, got error: %v", result.Error)
}
t.Logf("redis noauth: %+v", result)
}
func TestRedisBrute(t *testing.T) {
session := testSession()
session.Config.Credentials.UserPassPairs = []config.CredentialPair{
{Username: "", Password: "wrong1"},
{Username: "", Password: "test123"},
}
info := hostInfo(testHost, 16379)
plugin := services.NewRedisPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected redis brute to succeed with test123, got error: %v", result.Error)
}
if result.Password != "test123" {
t.Errorf("expected password test123, got %q", result.Password)
}
t.Logf("redis brute: %+v", result)
}
// ── MySQL ──────────────────────────────────────────────────────
func TestMySQLBrute(t *testing.T) {
session := testSession()
session.Config.Credentials.UserPassPairs = []config.CredentialPair{
{Username: "root", Password: "wrong"},
{Username: "root", Password: "root123"},
}
info := hostInfo(testHost, 13307)
plugin := services.NewMySQLPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected mysql brute to succeed, got error: %v", result.Error)
}
t.Logf("mysql brute: user=%s pass=%s", result.Username, result.Password)
}
// ── PostgreSQL ─────────────────────────────────────────────────
func TestPostgreSQLBrute(t *testing.T) {
session := testSession()
session.Config.Credentials.UserPassPairs = []config.CredentialPair{
{Username: "postgres", Password: "wrong"},
{Username: "postgres", Password: "postgres123"},
}
info := hostInfo(testHost, 15432)
plugin := services.NewPostgreSQLPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected postgresql brute to succeed, got error: %v", result.Error)
}
t.Logf("postgresql brute: user=%s pass=%s", result.Username, result.Password)
}
// ── FTP ────────────────────────────────────────────────────────
func TestFTPBrute(t *testing.T) {
session := testSession()
session.Config.Credentials.UserPassPairs = []config.CredentialPair{
{Username: "ftpuser", Password: "wrong"},
{Username: "ftpuser", Password: "ftp123"},
}
info := hostInfo(testHost, 10021)
plugin := services.NewFTPPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected ftp brute to succeed, got error: %v", result.Error)
}
t.Logf("ftp brute: user=%s pass=%s", result.Username, result.Password)
}
// ── SSH ────────────────────────────────────────────────────────
func TestSSHBrute(t *testing.T) {
session := testSession()
session.Config.Credentials.UserPassPairs = []config.CredentialPair{
{Username: "sshuser", Password: "wrong"},
{Username: "sshuser", Password: "ssh123"},
}
info := hostInfo(testHost, 10022)
plugin := services.NewSSHPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected ssh brute to succeed, got error: %v", result.Error)
}
t.Logf("ssh brute: user=%s pass=%s", result.Username, result.Password)
}
// ── MongoDB ────────────────────────────────────────────────────
func TestMongoDBBrute(t *testing.T) {
// Fixed: BSON key ordering was non-deterministic (Go map), MongoDB requires command name first
session := testSession()
session.Config.Credentials.UserPassPairs = []config.CredentialPair{
{Username: "admin", Password: "wrong"},
{Username: "admin", Password: "mongo123"},
}
info := hostInfo(testHost, 17017)
plugin := services.NewMongoDBPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected mongodb brute to succeed, got error: %v", result.Error)
}
t.Logf("mongodb brute: user=%s pass=%s", result.Username, result.Password)
}
// ── Memcached ──────────────────────────────────────────────────
func TestMemcachedUnauthorized(t *testing.T) {
session := testSession()
info := hostInfo(testHost, 11211)
plugin := services.NewMemcachedPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected memcached to succeed, got error: %v", result.Error)
}
t.Logf("memcached: type=%s banner=%s", result.Type, result.Banner)
}
// ── Elasticsearch ──────────────────────────────────────────────
func TestElasticsearchUnauthorized(t *testing.T) {
session := testSession()
info := hostInfo(testHost, 19200)
plugin := services.NewElasticsearchPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected elasticsearch to succeed, got error: %v", result.Error)
}
t.Logf("elasticsearch: type=%s vulinfo=%s", result.Type, result.VulInfo)
}
// ── MSSQL ──────────────────────────────────────────────────────
func TestMSSQLBrute(t *testing.T) {
session := testSession()
session.Config.Credentials.UserPassPairs = []config.CredentialPair{
{Username: "sa", Password: "wrong"},
{Username: "sa", Password: "MssqlTest123!"},
}
info := hostInfo(testHost, 11433)
plugin := services.NewMSSQLPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected mssql brute to succeed, got error: %v", result.Error)
}
t.Logf("mssql brute: user=%s pass=%s", result.Username, result.Password)
}
// ── RabbitMQ ───────────────────────────────────────────────────
func TestRabbitMQBrute(t *testing.T) {
session := testSession()
session.Config.Credentials.UserPassPairs = []config.CredentialPair{
{Username: "admin", Password: "wrong"},
{Username: "admin", Password: "rabbit123"},
}
info := hostInfo(testHost, 15672)
plugin := services.NewRabbitMQPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected rabbitmq brute to succeed, got error: %v", result.Error)
}
t.Logf("rabbitmq brute: user=%s pass=%s", result.Username, result.Password)
}
// ── MQTT ───────────────────────────────────────────────────────
func TestMQTTServiceDetect(t *testing.T) {
session := testSession()
info := hostInfo(testHost, 11883)
plugin := services.NewMQTTPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected mqtt service detect to succeed, got error: %v", result.Error)
}
t.Logf("mqtt: service=%s banner=%s", result.Service, result.Banner)
}
// ── SMB ────────────────────────────────────────────────────────
func TestSMBBrute(t *testing.T) {
t.Skip("SMB requires port 445 which is reserved on WSL2")
}
// ── LDAP ───────────────────────────────────────────────────────
func TestLDAPBrute(t *testing.T) {
session := testSession()
session.Config.Credentials.UserPassPairs = []config.CredentialPair{
{Username: "cn=admin,dc=test,dc=local", Password: "wrong"},
{Username: "cn=admin,dc=test,dc=local", Password: "ldap123"},
}
info := hostInfo(testHost, 10389)
plugin := services.NewLDAPPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected ldap brute to succeed, got error: %v", result.Error)
}
t.Logf("ldap brute: user=%s pass=%s", result.Username, result.Password)
}
// ── Cassandra ──────────────────────────────────────────────────
func TestCassandraServiceDetect(t *testing.T) {
session := testSession()
session.Config.DisableBrute = true
info := hostInfo(testHost, 19042)
plugin := services.NewCassandraPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected cassandra service detect to succeed, got error: %v", result.Error)
}
t.Logf("cassandra: type=%s banner=%s", result.Type, result.Banner)
}
// ── Neo4j ──────────────────────────────────────────────────────
func TestNeo4jBrute(t *testing.T) {
session := testSession()
session.Config.Credentials.UserPassPairs = []config.CredentialPair{
{Username: "neo4j", Password: "wrong"},
{Username: "neo4j", Password: "neo4jtest123"},
}
info := hostInfo(testHost, 17687)
plugin := services.NewNeo4jPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected neo4j brute to succeed, got error: %v", result.Error)
}
t.Logf("neo4j brute: user=%s pass=%s", result.Username, result.Password)
}
// ── Kafka ──────────────────────────────────────────────────────
func TestKafkaNoAuth(t *testing.T) {
session := testSession()
info := hostInfo(testHost, 19092)
plugin := services.NewKafkaPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected kafka to succeed, got error: %v", result.Error)
}
t.Logf("kafka: type=%s banner=%s", result.Type, result.Banner)
}
// ── SMTP ───────────────────────────────────────────────────────
func TestSMTPServiceDetect(t *testing.T) {
session := testSession()
info := hostInfo(testHost, 11025)
plugin := services.NewSMTPPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected smtp to succeed, got error: %v", result.Error)
}
t.Logf("smtp: type=%s banner=%s", result.Type, result.Banner)
}
// ── Oracle ─────────────────────────────────────────────────────
func TestOracleServiceDetect(t *testing.T) {
session := testSession()
session.Config.DisableBrute = true
info := hostInfo(testHost, 11521)
plugin := services.NewOraclePlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
t.Logf("oracle detect: success=%v type=%s banner=%s error=%v", result.Success, result.Type, result.Banner, result.Error)
}
func TestOracleBrute(t *testing.T) {
t.Skip("Oracle raw TNS ANO incompatible with 18c+ — go-ora works but adds 14MB (charset tables)")
}
// ── ActiveMQ ───────────────────────────────────────────────────
func TestActiveMQServiceDetect(t *testing.T) {
session := testSession()
session.Config.DisableBrute = true
info := hostInfo(testHost, 11613)
plugin := services.NewActiveMQPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected activemq service detect to succeed, got error: %v", result.Error)
}
t.Logf("activemq: type=%s banner=%s", result.Type, result.Banner)
}
// ── Zookeeper ──────────────────────────────────────────────────
func TestZookeeperServiceDetect(t *testing.T) {
session := testSession()
info := hostInfo(testHost, 12181)
plugin := services.NewZooKeeperPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected zookeeper to succeed, got error: %v", result.Error)
}
t.Logf("zookeeper: type=%s banner=%s", result.Type, result.Banner)
}
// ── Rsync ──────────────────────────────────────────────────────
func TestRsyncServiceDetect(t *testing.T) {
session := testSession()
session.Config.DisableBrute = true
info := hostInfo(testHost, 10873)
plugin := services.NewRsyncPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected rsync service detect to succeed, got error: %v", result.Error)
}
t.Logf("rsync: type=%s banner=%s", result.Type, result.Banner)
}
// ── VNC ────────────────────────────────────────────────────────
func TestVNCBrute(t *testing.T) {
session := testSession()
session.Config.Credentials.Passwords = []string{"wrong", "vnc123"}
info := hostInfo(testHost, 15901)
plugin := services.NewVNCPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected vnc brute to succeed, got error: %v", result.Error)
}
t.Logf("vnc brute: pass=%s", result.Password)
}
// ── SNMP ───────────────────────────────────────────────────────
func TestSNMPServiceDetect(t *testing.T) {
session := testSession()
info := hostInfo(testHost, 10161)
plugin := services.NewSNMPPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result == nil {
t.Fatal("result is nil")
}
if !result.Success {
t.Fatalf("expected snmp to succeed, got error: %v", result.Error)
}
t.Logf("snmp: type=%s banner=%s", result.Type, result.Banner)
}
// ── 连接失败场景 ──────────────────────────────────────────────
func TestRedisConnectionRefused(t *testing.T) {
session := testSession()
session.Config.Timeout = 3 * time.Second
info := hostInfo(testHost, 19999)
plugin := services.NewRedisPlugin()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
result := plugin.Scan(ctx, info, session)
if result != nil && result.Success {
t.Fatal("expected failure on closed port")
}
}
+2
View File
@@ -0,0 +1,2 @@
listener 1883
allow_anonymous true
+11
View File
@@ -0,0 +1,11 @@
uid = nobody
gid = nogroup
use chroot = no
max connections = 4
log file = /dev/stdout
[public]
path = /data
comment = Public
read only = yes
list = yes
+4 -2
View File
@@ -38,8 +38,9 @@ type ScanRequest struct {
ThreadNum int `json:"thread_num"` ThreadNum int `json:"thread_num"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
ModuleThreadNum int `json:"module_thread_num"` ModuleThreadNum int `json:"module_thread_num"`
DisablePing bool `json:"disable_ping"` DisablePing bool `json:"disable_ping"`
DisableBrute bool `json:"disable_brute"` DisableBrute bool `json:"disable_brute"`
DisableSubnetProbe bool `json:"disable_subnet_probe"`
AliveOnly bool `json:"alive_only"` AliveOnly bool `json:"alive_only"`
// 认证 // 认证
@@ -199,6 +200,7 @@ func (h *ScanHandler) runScan(req ScanRequest) {
} }
fv.DisablePing = req.DisablePing fv.DisablePing = req.DisablePing
fv.DisableBrute = req.DisableBrute fv.DisableBrute = req.DisableBrute
fv.DisableSubnetProbe = req.DisableSubnetProbe
fv.AliveOnly = req.AliveOnly fv.AliveOnly = req.AliveOnly
fv.Username = req.Username fv.Username = req.Username
fv.Password = req.Password fv.Password = req.Password
+191
View File
@@ -0,0 +1,191 @@
package fingerprint
import (
"testing"
)
// =============================================================================
// calcPriority 测试
// =============================================================================
func TestCalcPriority_FaviconHighest(t *testing.T) {
fp := &EnhancedFingerprint{}
p := calcPriority(fp, "favicon")
if p != 100 {
t.Errorf("favicon 优先级应为 100,实际 %d", p)
}
}
func TestCalcPriority_RegexMedium(t *testing.T) {
fp := &EnhancedFingerprint{}
p := calcPriority(fp, "regex")
if p != 50 {
t.Errorf("regex 优先级应为 50,实际 %d", p)
}
}
func TestCalcPriority_WordLow(t *testing.T) {
fp := &EnhancedFingerprint{}
p := calcPriority(fp, "word")
if p != 30 {
t.Errorf("word 优先级应为 30,实际 %d", p)
}
}
func TestCalcPriority_UnknownTypeZero(t *testing.T) {
fp := &EnhancedFingerprint{}
p := calcPriority(fp, "unknown")
if p != 0 {
t.Errorf("未知类型优先级应为 0,实际 %d", p)
}
}
func TestCalcPriority_VerifiedBonus(t *testing.T) {
fp := &EnhancedFingerprint{}
fp.Info.Metadata = map[string]interface{}{
"verified": true,
}
p := calcPriority(fp, "word")
// word(30) + verified(20) = 50
if p != 50 {
t.Errorf("word+verified 优先级应为 50,实际 %d", p)
}
}
func TestCalcPriority_VerifiedFavicon(t *testing.T) {
fp := &EnhancedFingerprint{}
fp.Info.Metadata = map[string]interface{}{
"verified": true,
}
p := calcPriority(fp, "favicon")
// favicon(100) + verified(20) = 120
if p != 120 {
t.Errorf("favicon+verified 优先级应为 120,实际 %d", p)
}
}
func TestCalcPriority_VerifiedFalse(t *testing.T) {
fp := &EnhancedFingerprint{}
fp.Info.Metadata = map[string]interface{}{
"verified": false,
}
p := calcPriority(fp, "regex")
// verified=false 不加分
if p != 50 {
t.Errorf("verified=false 时优先级应为 50,实际 %d", p)
}
}
func TestCalcPriority_NilMetadata(t *testing.T) {
fp := &EnhancedFingerprint{}
// Metadata 为 nil,不加分
p := calcPriority(fp, "favicon")
if p != 100 {
t.Errorf("nil metadata 时 favicon 优先级应为 100,实际 %d", p)
}
}
// =============================================================================
// matchRegex 测试 - 需要初始化 enhancedDB
// =============================================================================
func initEnhancedDBForTest(t *testing.T) {
t.Helper()
if enhancedDB == nil {
if err := LoadEnhancedFingerprints(); err != nil {
t.Fatalf("LoadEnhancedFingerprints 失败: %v", err)
}
}
}
func TestMatchRegex_BodyMatch(t *testing.T) {
initEnhancedDBForTest(t)
matcher := createMatcher("regex", nil, []string{`nginx/[\d.]+`}, nil, "body", "", false)
result := matchRegex(matcher, "Server: nginx/1.18.0 running", "")
if !result {
t.Error("body 中应匹配 nginx 版本正则")
}
}
func TestMatchRegex_HeaderMatch(t *testing.T) {
initEnhancedDBForTest(t)
matcher := createMatcher("regex", nil, []string{`X-Powered-By: PHP/[\d.]+`}, nil, "header", "", false)
result := matchRegex(matcher, "", "X-Powered-By: PHP/7.4.3")
if !result {
t.Error("header 中应匹配 PHP 版本正则")
}
}
func TestMatchRegex_NoMatch(t *testing.T) {
initEnhancedDBForTest(t)
matcher := createMatcher("regex", nil, []string{`apache/[\d.]+`}, nil, "body", "", false)
result := matchRegex(matcher, "nginx server running", "")
if result {
t.Error("不应匹配 apache 正则")
}
}
func TestMatchRegex_ANDConditionAllMatch(t *testing.T) {
initEnhancedDBForTest(t)
matcher := createMatcher("regex", nil, []string{`nginx`, `1\.18`}, nil, "body", "and", false)
result := matchRegex(matcher, "nginx/1.18.0 server", "")
if !result {
t.Error("AND 条件下两个正则都匹配应返回 true")
}
}
func TestMatchRegex_ANDConditionPartialMatch(t *testing.T) {
initEnhancedDBForTest(t)
matcher := createMatcher("regex", nil, []string{`nginx`, `apache`}, nil, "body", "and", false)
result := matchRegex(matcher, "nginx server", "")
if result {
t.Error("AND 条件下只有一个匹配应返回 false")
}
}
func TestMatchRegex_ORConditionOneMatch(t *testing.T) {
initEnhancedDBForTest(t)
matcher := createMatcher("regex", nil, []string{`nginx`, `apache`}, nil, "body", "or", false)
result := matchRegex(matcher, "apache httpd", "")
if !result {
t.Error("OR 条件下至少一个匹配应返回 true")
}
}
func TestMatchRegex_CaseInsensitive(t *testing.T) {
initEnhancedDBForTest(t)
matcher := createMatcher("regex", nil, []string{`NGINX`}, nil, "body", "", true)
result := matchRegex(matcher, "nginx/1.18.0", "")
if !result {
t.Error("大小写不敏感模式下应匹配")
}
}
func TestMatchRegex_InvalidPattern(t *testing.T) {
initEnhancedDBForTest(t)
// 无效正则不应崩溃
matcher := createMatcher("regex", nil, []string{`[invalid regex(`}, nil, "body", "", false)
result := matchRegex(matcher, "test content", "")
if result {
t.Error("无效正则不应产生匹配")
}
}
func TestMatchRegex_EmptyPatterns(t *testing.T) {
initEnhancedDBForTest(t)
matcher := createMatcher("regex", nil, []string{}, nil, "body", "and", false)
result := matchRegex(matcher, "nginx", "")
// AND 条件且无 patternisAnd && len(Regex) > 0 为 false
if result {
t.Error("AND 条件下空 patterns 应返回 false")
}
}
+209
View File
@@ -0,0 +1,209 @@
package WebScan
import (
"crypto/md5" //nolint:gosec
"fmt"
"testing"
"github.com/shadow1ng/fscan/webscan/fingerprint"
)
// =============================================================================
// removeDuplicateElement 测试
// =============================================================================
func TestRemoveDuplicateElement_Basic(t *testing.T) {
input := []string{"nginx", "apache", "nginx", "iis", "apache"}
result := removeDuplicateElement(input)
if len(result) != 3 {
t.Errorf("期望3个唯一元素,实际 %d: %v", len(result), result)
}
seen := make(map[string]int)
for _, v := range result {
seen[v]++
if seen[v] > 1 {
t.Errorf("元素 %q 出现了多次", v)
}
}
}
func TestRemoveDuplicateElement_Empty(t *testing.T) {
result := removeDuplicateElement([]string{})
if len(result) != 0 {
t.Errorf("空输入应返回空切片,实际 %d", len(result))
}
}
func TestRemoveDuplicateElement_NoDup(t *testing.T) {
input := []string{"a", "b", "c"}
result := removeDuplicateElement(input)
if len(result) != 3 {
t.Errorf("无重复时应保留全部元素,实际 %d", len(result))
}
}
func TestRemoveDuplicateElement_AllSame(t *testing.T) {
input := []string{"dup", "dup", "dup", "dup"}
result := removeDuplicateElement(input)
if len(result) != 1 {
t.Errorf("全部相同时应只保留1个,实际 %d", len(result))
}
if result[0] != "dup" {
t.Errorf("保留的元素应为 'dup',实际 %q", result[0])
}
}
func TestRemoveDuplicateElement_PreservesOrder(t *testing.T) {
input := []string{"c", "a", "b", "a", "c"}
result := removeDuplicateElement(input)
if len(result) != 3 {
t.Fatalf("期望3个元素,实际 %d", len(result))
}
// 第一次出现的顺序应被保留
if result[0] != "c" || result[1] != "a" || result[2] != "b" {
t.Errorf("顺序不符合预期: %v", result)
}
}
// =============================================================================
// matchByMd5 测试
// =============================================================================
func TestMatchByMd5_KnownHash(t *testing.T) {
// 从真实的 Md5Datas 取第一条:{"BIG-IP", "04d9541338e525258daf47cc844d59f3"}
if len(fingerprint.Md5Datas) == 0 {
t.Skip("Md5Datas 为空,跳过测试")
}
entry := fingerprint.Md5Datas[0]
// 找到能产生这个 md5 的数据——直接暴力:构造一个有已知 md5 的 body
// 实际上 md5 是 favicon 的 hash,这里测试找不到匹配的情况
emptyResult := matchByMd5([]byte("no match content here"))
if emptyResult != "" {
t.Logf("意外匹配了 %q(不影响功能,跳过断言)", emptyResult)
}
// 验证函数正确返回空字符串——主要检测无崩溃
_ = entry
}
func TestMatchByMd5_NoMatch(t *testing.T) {
result := matchByMd5([]byte("definitely not matching any fingerprint 12345"))
if result != "" {
t.Errorf("不应匹配任何指纹,实际匹配了 %q", result)
}
}
func TestMatchByMd5_Empty(t *testing.T) {
// 空 body 的 md5 固定值 d41d8cd98f00b204e9800998ecf8427e
// 检查是否在数据库中(不是,所以应返回空)
result := matchByMd5([]byte{})
// 不强断言结果,只验证不崩溃
_ = result
}
// 构造一个真实 md5 让 matchByMd5 命中
func TestMatchByMd5_ActualMatch(t *testing.T) {
if len(fingerprint.Md5Datas) == 0 {
t.Skip("Md5Datas 为空")
}
// 找一条已知 md5,反向构造:我们不能反推原始数据
// 但可以直接测试 md5 计算逻辑:手动计算 body 的 md5 并与函数对比
body := []byte("test content for md5 check")
//nolint:gosec
expected := fmt.Sprintf("%x", md5.Sum(body))
// matchByMd5 内部会对 body 计算 md5,然后在 Md5Datas 中查找
// 因为这个 md5 不在 Md5Datas 中,应返回 ""
result := matchByMd5(body)
if result != "" {
t.Logf("巧合命中: body_md5=%s matched=%q", expected, result)
}
// 主要验证逻辑路径可以走通
}
// =============================================================================
// matchByRegex 测试
// =============================================================================
func TestMatchByRegex_CodeType(t *testing.T) {
// 宝塔指纹:Type="code",匹配 body
data := CheckDatas{
Body: []byte("app.bt.cn/static/app.png"),
Headers: "",
}
result := matchByRegex(data)
found := false
for _, name := range result {
if name == "宝塔" {
found = true
break
}
}
if !found {
t.Errorf("应匹配宝塔指纹,实际结果: %v", result)
}
}
func TestMatchByRegex_HeaderType(t *testing.T) {
// CloudFlare 指纹:Type="headers",匹配 headers
data := CheckDatas{
Body: []byte(""),
Headers: "CF-RAY: cloudflare-abc123",
}
result := matchByRegex(data)
found := false
for _, name := range result {
if name == "CloudFlare" {
found = true
break
}
}
if !found {
t.Errorf("应匹配CloudFlare指纹,实际结果: %v", result)
}
}
func TestMatchByRegex_NoMatch(t *testing.T) {
data := CheckDatas{
Body: []byte("hello world nothing special"),
Headers: "Content-Type: text/plain",
}
result := matchByRegex(data)
// 普通内容不应匹配特征指纹
// 不强断言数量,只验证不崩溃
_ = result
}
func TestMatchByRegex_EmptyData(t *testing.T) {
data := CheckDatas{}
result := matchByRegex(data)
if result == nil {
result = []string{}
}
// 空数据不崩溃即可
_ = result
}
func TestMatchByRegex_DeepInserve(t *testing.T) {
// 深信服防火墙:body 中包含 "SANGFOR FW"
data := CheckDatas{
Body: []byte(`<html>SANGFOR FW product page</html>`),
Headers: "",
}
result := matchByRegex(data)
found := false
for _, name := range result {
if name == "深信服防火墙类产品" {
found = true
break
}
}
if !found {
t.Errorf("应匹配深信服防火墙指纹,实际结果: %v", result)
}
}
+109 -1
View File
@@ -1,6 +1,114 @@
package lib package lib
import "testing" import (
"testing"
"gopkg.in/yaml.v2"
)
// =============================================================================
// UnmarshalYAML 测试
// =============================================================================
func TestStrMapUnmarshalYAML(t *testing.T) {
t.Run("正常键值对", func(t *testing.T) {
data := []byte("key1: val1\nkey2: val2\n")
var m StrMap
if err := yaml.Unmarshal(data, &m); err != nil {
t.Fatalf("yaml.Unmarshal error = %v", err)
}
if len(m) != 2 {
t.Fatalf("len = %d, want 2", len(m))
}
if m[0].Key != "key1" || m[0].Value != "val1" {
t.Errorf("m[0] = %+v, want {key1 val1}", m[0])
}
if m[1].Key != "key2" || m[1].Value != "val2" {
t.Errorf("m[1] = %+v, want {key2 val2}", m[1])
}
})
t.Run("单项", func(t *testing.T) {
data := []byte("only: one\n")
var m StrMap
if err := yaml.Unmarshal(data, &m); err != nil {
t.Fatalf("yaml.Unmarshal error = %v", err)
}
if len(m) != 1 || m[0].Key != "only" || m[0].Value != "one" {
t.Fatalf("m = %+v", m)
}
})
t.Run("randomInt 值保留为字符串", func(t *testing.T) {
data := []byte("port: randomInt(1000, 9000)\n")
var m StrMap
if err := yaml.Unmarshal(data, &m); err != nil {
t.Fatalf("yaml.Unmarshal error = %v", err)
}
if len(m) != 1 || m[0].Value != "randomInt(1000, 9000)" {
t.Fatalf("m = %+v", m)
}
})
}
func TestListMapUnmarshalYAML(t *testing.T) {
t.Run("正常列表值", func(t *testing.T) {
data := []byte("users:\n - admin\n - root\npasses:\n - 123\n - 456\n")
var m ListMap
if err := yaml.Unmarshal(data, &m); err != nil {
t.Fatalf("yaml.Unmarshal error = %v", err)
}
if len(m) != 2 {
t.Fatalf("len = %d, want 2", len(m))
}
if m[0].Key != "users" || len(m[0].Value) != 2 || m[0].Value[0] != "admin" || m[0].Value[1] != "root" {
t.Errorf("m[0] = %+v", m[0])
}
if m[1].Key != "passes" || len(m[1].Value) != 2 || m[1].Value[0] != "123" || m[1].Value[1] != "456" {
t.Errorf("m[1] = %+v", m[1])
}
})
t.Run("单个列表", func(t *testing.T) {
data := []byte("cmd:\n - whoami\n")
var m ListMap
if err := yaml.Unmarshal(data, &m); err != nil {
t.Fatalf("yaml.Unmarshal error = %v", err)
}
if len(m) != 1 || m[0].Key != "cmd" || m[0].Value[0] != "whoami" {
t.Fatalf("m = %+v", m)
}
})
t.Run("数字值转字符串", func(t *testing.T) {
data := []byte("ports:\n - 80\n - 443\n")
var m ListMap
if err := yaml.Unmarshal(data, &m); err != nil {
t.Fatalf("yaml.Unmarshal error = %v", err)
}
if m[0].Value[0] != "80" || m[0].Value[1] != "443" {
t.Errorf("数字未转为字符串: %+v", m[0].Value)
}
})
}
func TestStrMapUnmarshalYAML_InvalidValue(t *testing.T) {
// value 是嵌套 map,不是字符串,应报错
data := []byte("key:\n nested: val\n")
var m StrMap
if err := yaml.Unmarshal(data, &m); err == nil {
t.Fatal("期望错误,实际 nil")
}
}
func TestListMapUnmarshalYAML_InvalidValue(t *testing.T) {
// value 是普通字符串而非列表,应报错
data := []byte("key: notalist\n")
var m ListMap
if err := yaml.Unmarshal(data, &m); err == nil {
t.Fatal("期望错误,实际 nil")
}
}
func TestNormalizeHTTPProxyURL(t *testing.T) { func TestNormalizeHTTPProxyURL(t *testing.T) {
tests := []struct { tests := []struct {
+108
View File
@@ -0,0 +1,108 @@
package lib
import (
"testing"
"github.com/google/cel-go/common/types"
)
func TestRegisterCryptoImplementations(t *testing.T) {
overloads := registerCryptoImplementations()
// 建立 operator → index 映射
idx := make(map[string]int, len(overloads))
for i, o := range overloads {
idx[o.Operator] = i
}
t.Run("md5_string", func(t *testing.T) {
i, ok := idx["md5_string"]
if !ok {
t.Fatal("overload md5_string not found")
}
unary := overloads[i].Unary
tests := []struct {
name string
input types.String
want types.String
wantErr bool
}{
{"hello", "hello", "5d41402abc4b2a76b9719d911017c592", false},
{"empty", "", "d41d8cd98f00b204e9800998ecf8427e", false},
{"abc", "abc", "900150983cd24fb0d6963f7d28e17f72", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := unary(tc.input)
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
got, ok := result.(types.String)
if !ok {
t.Fatalf("expected types.String, got %T", result)
}
if got != tc.want {
t.Errorf("md5(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
})
t.Run("md5_string_wrong_type", func(t *testing.T) {
i := idx["md5_string"]
result := overloads[i].Unary(types.Int(42))
if !types.IsError(result) {
t.Errorf("expected error for non-String input, got %v", result)
}
})
t.Run("shiro_key_valid", func(t *testing.T) {
i, ok := idx["shiro_key"]
if !ok {
t.Fatal("overload shiro_key not found")
}
binary := overloads[i].Binary
// kPH+bIxk5D2deZiIxcaaaA== 是常见 shiro 默认 key
result := binary(types.String("kPH+bIxk5D2deZiIxcaaaA=="), types.String("cbc"))
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
got, ok := result.(types.String)
if !ok {
t.Fatalf("expected types.String, got %T", result)
}
if got == "" {
t.Error("shiro_key returned empty string")
}
})
t.Run("shiro_key_invalid_base64", func(t *testing.T) {
i := idx["shiro_key"]
binary := overloads[i].Binary
// 无效 base64GetShrioCookie 会返回 "",函数返回 NewErr
result := binary(types.String("!!!not_valid_base64!!!"), types.String("cbc"))
if !types.IsError(result) {
t.Errorf("expected error for invalid base64 key, got %v", result)
}
})
t.Run("shiro_key_wrong_key_type", func(t *testing.T) {
i := idx["shiro_key"]
result := overloads[i].Binary(types.Int(1), types.String("cbc"))
if !types.IsError(result) {
t.Error("expected error for non-String key")
}
})
t.Run("shiro_key_wrong_mode_type", func(t *testing.T) {
i := idx["shiro_key"]
result := overloads[i].Binary(types.String("kPH+bIxk5D2deZiIxcaaaA=="), types.Int(0))
if !types.IsError(result) {
t.Error("expected error for non-String mode")
}
})
}
+259
View File
@@ -0,0 +1,259 @@
package lib
import (
"testing"
"github.com/google/cel-go/common/types"
)
func TestRegisterEncodingImplementations(t *testing.T) {
overloads := registerEncodingImplementations()
idx := make(map[string]int, len(overloads))
for i, o := range overloads {
idx[o.Operator] = i
}
t.Run("base64_string", func(t *testing.T) {
unary := overloads[idx["base64_string"]].Unary
tests := []struct {
input types.String
want types.String
}{
{"hello", "aGVsbG8="},
{"", ""},
{"hello world", "aGVsbG8gd29ybGQ="},
}
for _, tc := range tests {
result := unary(tc.input)
if types.IsError(result) {
t.Fatalf("base64_string(%q): unexpected error %v", tc.input, result)
}
got, ok := result.(types.String)
if !ok {
t.Fatalf("expected types.String, got %T", result)
}
if got != tc.want {
t.Errorf("base64_string(%q) = %q, want %q", tc.input, got, tc.want)
}
}
})
t.Run("base64_string_wrong_type", func(t *testing.T) {
result := overloads[idx["base64_string"]].Unary(types.Int(1))
if !types.IsError(result) {
t.Error("expected error for non-String input")
}
})
t.Run("base64_bytes", func(t *testing.T) {
unary := overloads[idx["base64_bytes"]].Unary
tests := []struct {
input types.Bytes
want types.String
}{
{types.Bytes([]byte("hello")), "aGVsbG8="},
{types.Bytes([]byte{}), ""},
}
for _, tc := range tests {
result := unary(tc.input)
if types.IsError(result) {
t.Fatalf("base64_bytes: unexpected error %v", result)
}
got, ok := result.(types.String)
if !ok {
t.Fatalf("expected types.String, got %T", result)
}
if got != tc.want {
t.Errorf("base64_bytes(%v) = %q, want %q", []byte(tc.input), got, tc.want)
}
}
})
t.Run("base64_bytes_wrong_type", func(t *testing.T) {
result := overloads[idx["base64_bytes"]].Unary(types.String("hello"))
if !types.IsError(result) {
t.Error("expected error for non-Bytes input")
}
})
t.Run("base64Decode_string", func(t *testing.T) {
unary := overloads[idx["base64Decode_string"]].Unary
tests := []struct {
input types.String
want types.String
}{
{"aGVsbG8=", "hello"},
{"", ""},
{"aGVsbG8gd29ybGQ=", "hello world"},
}
for _, tc := range tests {
result := unary(tc.input)
if types.IsError(result) {
t.Fatalf("base64Decode_string(%q): unexpected error %v", tc.input, result)
}
got, ok := result.(types.String)
if !ok {
t.Fatalf("expected types.String, got %T", result)
}
if got != tc.want {
t.Errorf("base64Decode_string(%q) = %q, want %q", tc.input, got, tc.want)
}
}
})
t.Run("base64Decode_string_invalid", func(t *testing.T) {
result := overloads[idx["base64Decode_string"]].Unary(types.String("!!!"))
if !types.IsError(result) {
t.Error("expected error for invalid base64 input")
}
})
t.Run("base64Decode_string_wrong_type", func(t *testing.T) {
result := overloads[idx["base64Decode_string"]].Unary(types.Bool(true))
if !types.IsError(result) {
t.Error("expected error for non-String input")
}
})
t.Run("base64Decode_bytes", func(t *testing.T) {
unary := overloads[idx["base64Decode_bytes"]].Unary
result := unary(types.Bytes([]byte("aGVsbG8=")))
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
got, ok := result.(types.String)
if !ok {
t.Fatalf("expected types.String, got %T", result)
}
if got != "hello" {
t.Errorf("base64Decode_bytes = %q, want %q", got, "hello")
}
})
t.Run("base64Decode_bytes_invalid", func(t *testing.T) {
result := overloads[idx["base64Decode_bytes"]].Unary(types.Bytes([]byte("!!!")))
if !types.IsError(result) {
t.Error("expected error for invalid base64 bytes")
}
})
t.Run("urlencode_string", func(t *testing.T) {
unary := overloads[idx["urlencode_string"]].Unary
// url.QueryEscape: 空格 → "+"
tests := []struct {
input types.String
want types.String
}{
{"hello world", "hello+world"},
{"a=1&b=2", "a%3D1%26b%3D2"},
{"", ""},
}
for _, tc := range tests {
result := unary(tc.input)
if types.IsError(result) {
t.Fatalf("urlencode_string(%q): unexpected error %v", tc.input, result)
}
got, ok := result.(types.String)
if !ok {
t.Fatalf("expected types.String, got %T", result)
}
if got != tc.want {
t.Errorf("urlencode_string(%q) = %q, want %q", tc.input, got, tc.want)
}
}
})
t.Run("urlencode_string_wrong_type", func(t *testing.T) {
result := overloads[idx["urlencode_string"]].Unary(types.Int(0))
if !types.IsError(result) {
t.Error("expected error for non-String input")
}
})
t.Run("urldecode_string", func(t *testing.T) {
unary := overloads[idx["urldecode_string"]].Unary
tests := []struct {
input types.String
want types.String
}{
{"hello%20world", "hello world"},
{"hello+world", "hello world"},
{"", ""},
}
for _, tc := range tests {
result := unary(tc.input)
if types.IsError(result) {
t.Fatalf("urldecode_string(%q): unexpected error %v", tc.input, result)
}
got, ok := result.(types.String)
if !ok {
t.Fatalf("expected types.String, got %T", result)
}
if got != tc.want {
t.Errorf("urldecode_string(%q) = %q, want %q", tc.input, got, tc.want)
}
}
})
t.Run("urldecode_string_invalid", func(t *testing.T) {
// % 后跟非法字符
result := overloads[idx["urldecode_string"]].Unary(types.String("hello%ZZ"))
if !types.IsError(result) {
t.Error("expected error for invalid percent-encoding")
}
})
t.Run("urldecode_string_wrong_type", func(t *testing.T) {
result := overloads[idx["urldecode_string"]].Unary(types.Bool(false))
if !types.IsError(result) {
t.Error("expected error for non-String input")
}
})
t.Run("hexdecode", func(t *testing.T) {
unary := overloads[idx["hexdecode"]].Unary
tests := []struct {
input types.String
want []byte
}{
{"48656c6c6f", []byte("Hello")},
{"", []byte{}},
{"deadbeef", []byte{0xde, 0xad, 0xbe, 0xef}},
}
for _, tc := range tests {
result := unary(tc.input)
if types.IsError(result) {
t.Fatalf("hexdecode(%q): unexpected error %v", tc.input, result)
}
got, ok := result.(types.Bytes)
if !ok {
t.Fatalf("expected types.Bytes, got %T", result)
}
if string(got) != string(tc.want) {
t.Errorf("hexdecode(%q) = %v, want %v", tc.input, []byte(got), tc.want)
}
}
})
t.Run("hexdecode_invalid", func(t *testing.T) {
result := overloads[idx["hexdecode"]].Unary(types.String("zz"))
if !types.IsError(result) {
t.Error("expected error for invalid hex input")
}
})
t.Run("hexdecode_wrong_type", func(t *testing.T) {
result := overloads[idx["hexdecode"]].Unary(types.Int(99))
if !types.IsError(result) {
t.Error("expected error for non-String input")
}
})
}
+53
View File
@@ -0,0 +1,53 @@
package lib
import (
"testing"
"unicode"
"github.com/google/cel-go/common/types"
)
func TestRegisterMiscImplementations_TongdaDate(t *testing.T) {
overloads := registerMiscImplementations()
idx := make(map[string]int, len(overloads))
for i, o := range overloads {
idx[o.Operator] = i
}
i, ok := idx["tongda_date"]
if !ok {
t.Fatal("overload tongda_date not found")
}
fn := overloads[i].Function
if fn == nil {
t.Fatal("tongda_date Function field is nil")
}
result := fn()
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
got, ok := result.(types.String)
if !ok {
t.Fatalf("expected types.String, got %T", result)
}
s := string(got)
t.Run("length_is_4", func(t *testing.T) {
if len(s) != 4 {
t.Errorf("tongda_date returned %q, want 4-char string", s)
}
})
t.Run("all_digits", func(t *testing.T) {
for _, r := range s {
if !unicode.IsDigit(r) {
t.Errorf("tongda_date returned %q, contains non-digit char %q", s, r)
}
}
})
}
+264
View File
@@ -0,0 +1,264 @@
package lib
import (
"testing"
"unicode"
"github.com/google/cel-go/common/types"
)
func TestRegisterRandomImplementations(t *testing.T) {
overloads := registerRandomImplementations()
idx := make(map[string]int, len(overloads))
for i, o := range overloads {
idx[o.Operator] = i
}
t.Run("randomInt_int_int", func(t *testing.T) {
i, ok := idx["randomInt_int_int"]
if !ok {
t.Fatal("overload randomInt_int_int not found")
}
binary := overloads[i].Binary
t.Run("returns_Int_type", func(t *testing.T) {
result := binary(types.Int(0), types.Int(100))
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
if _, ok := result.(types.Int); !ok {
t.Errorf("expected types.Int, got %T", result)
}
})
t.Run("value_in_range", func(t *testing.T) {
min, max := types.Int(10), types.Int(20)
for range 50 {
result := binary(min, max)
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
v := int64(result.(types.Int))
if v < 10 || v >= 20 {
t.Errorf("randomInt(10,20) = %d, out of [10,20)", v)
}
}
})
t.Run("max_le_min_returns_error", func(t *testing.T) {
result := binary(types.Int(5), types.Int(5))
if !types.IsError(result) {
t.Errorf("expected error when max == min, got %v", result)
}
})
t.Run("wrong_lhs_type", func(t *testing.T) {
result := binary(types.String("x"), types.Int(10))
if !types.IsError(result) {
t.Error("expected error for non-Int lhs")
}
})
t.Run("wrong_rhs_type", func(t *testing.T) {
result := binary(types.Int(0), types.String("x"))
if !types.IsError(result) {
t.Error("expected error for non-Int rhs")
}
})
})
t.Run("randomLowercase_int", func(t *testing.T) {
i, ok := idx["randomLowercase_int"]
if !ok {
t.Fatal("overload randomLowercase_int not found")
}
unary := overloads[i].Unary
t.Run("returns_String_type", func(t *testing.T) {
result := unary(types.Int(8))
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
if _, ok := result.(types.String); !ok {
t.Errorf("expected types.String, got %T", result)
}
})
t.Run("correct_length", func(t *testing.T) {
for _, n := range []int{0, 1, 8, 16} {
result := unary(types.Int(n))
if types.IsError(result) {
t.Fatalf("unexpected error for n=%d: %v", n, result)
}
got := string(result.(types.String))
if len(got) != n {
t.Errorf("randomLowercase(%d) returned length %d", n, len(got))
}
}
})
t.Run("all_lowercase", func(t *testing.T) {
result := unary(types.Int(32))
got := string(result.(types.String))
for _, r := range got {
if !unicode.IsLower(r) {
t.Errorf("randomLowercase returned non-lowercase char %q in %q", r, got)
}
}
})
t.Run("invalid_length_negative", func(t *testing.T) {
result := unary(types.Int(-1))
if !types.IsError(result) {
t.Error("expected error for negative length")
}
})
t.Run("invalid_length_too_large", func(t *testing.T) {
result := unary(types.Int(maxRandomStringLength + 1))
if !types.IsError(result) {
t.Error("expected error for length > maxRandomStringLength")
}
})
t.Run("wrong_type", func(t *testing.T) {
result := unary(types.String("x"))
if !types.IsError(result) {
t.Error("expected error for non-Int input")
}
})
})
t.Run("randomUppercase_int", func(t *testing.T) {
i, ok := idx["randomUppercase_int"]
if !ok {
t.Fatal("overload randomUppercase_int not found")
}
unary := overloads[i].Unary
t.Run("returns_String_type", func(t *testing.T) {
result := unary(types.Int(8))
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
if _, ok := result.(types.String); !ok {
t.Errorf("expected types.String, got %T", result)
}
})
t.Run("correct_length", func(t *testing.T) {
for _, n := range []int{0, 1, 8, 16} {
result := unary(types.Int(n))
if types.IsError(result) {
t.Fatalf("unexpected error for n=%d: %v", n, result)
}
got := string(result.(types.String))
if len(got) != n {
t.Errorf("randomUppercase(%d) returned length %d", n, len(got))
}
}
})
t.Run("all_uppercase", func(t *testing.T) {
result := unary(types.Int(32))
got := string(result.(types.String))
for _, r := range got {
if !unicode.IsUpper(r) {
t.Errorf("randomUppercase returned non-uppercase char %q in %q", r, got)
}
}
})
t.Run("wrong_type", func(t *testing.T) {
result := unary(types.String("x"))
if !types.IsError(result) {
t.Error("expected error for non-Int input")
}
})
})
t.Run("randomString_int", func(t *testing.T) {
i, ok := idx["randomString_int"]
if !ok {
t.Fatal("overload randomString_int not found")
}
unary := overloads[i].Unary
t.Run("returns_String_type", func(t *testing.T) {
result := unary(types.Int(8))
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
if _, ok := result.(types.String); !ok {
t.Errorf("expected types.String, got %T", result)
}
})
t.Run("correct_length", func(t *testing.T) {
for _, n := range []int{0, 1, 8, 16} {
result := unary(types.Int(n))
if types.IsError(result) {
t.Fatalf("unexpected error for n=%d: %v", n, result)
}
got := string(result.(types.String))
if len(got) != n {
t.Errorf("randomString(%d) returned length %d", n, len(got))
}
}
})
t.Run("wrong_type", func(t *testing.T) {
result := unary(types.String("x"))
if !types.IsError(result) {
t.Error("expected error for non-Int input")
}
})
})
}
func TestRandomIntSpan(t *testing.T) {
t.Run("normal_range", func(t *testing.T) {
span, err := randomIntSpan(10, 20)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if span != 10 {
t.Errorf("randomIntSpan(10,20) = %d, want 10", span)
}
})
t.Run("min_zero", func(t *testing.T) {
span, err := randomIntSpan(0, 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if span != 100 {
t.Errorf("randomIntSpan(0,100) = %d, want 100", span)
}
})
t.Run("negative_min", func(t *testing.T) {
span, err := randomIntSpan(-5, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if span != 10 {
t.Errorf("randomIntSpan(-5,5) = %d, want 10", span)
}
})
t.Run("max_eq_min_returns_error", func(t *testing.T) {
_, err := randomIntSpan(7, 7)
if err == nil {
t.Error("expected error when max == min")
}
})
t.Run("max_lt_min_returns_error", func(t *testing.T) {
_, err := randomIntSpan(10, 5)
if err == nil {
t.Error("expected error when max < min")
}
})
}
+337
View File
@@ -0,0 +1,337 @@
package lib
import (
"testing"
"github.com/google/cel-go/common/types"
"github.com/google/cel-go/common/types/ref"
)
func TestRegisterStringImplementations(t *testing.T) {
overloads := registerStringImplementations()
idx := make(map[string]int, len(overloads))
for i, o := range overloads {
idx[o.Operator] = i
}
t.Run("bytes_bcontains_bytes", func(t *testing.T) {
binary := overloads[idx["bytes_bcontains_bytes"]].Binary
tests := []struct {
name string
lhs types.Bytes
rhs types.Bytes
want types.Bool
}{
{"contains", types.Bytes([]byte("hello world")), types.Bytes([]byte("world")), true},
{"not_contains", types.Bytes([]byte("hello world")), types.Bytes([]byte("xyz")), false},
{"empty_needle", types.Bytes([]byte("hello")), types.Bytes([]byte{}), true},
{"both_empty", types.Bytes([]byte{}), types.Bytes([]byte{}), true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := binary(tc.lhs, tc.rhs)
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
got, ok := result.(types.Bool)
if !ok {
t.Fatalf("expected types.Bool, got %T", result)
}
if got != tc.want {
t.Errorf("bcontains = %v, want %v", got, tc.want)
}
})
}
})
t.Run("bytes_bcontains_bytes_wrong_lhs", func(t *testing.T) {
result := overloads[idx["bytes_bcontains_bytes"]].Binary(types.String("hello"), types.Bytes([]byte("x")))
if !types.IsError(result) {
t.Error("expected error for non-Bytes lhs")
}
})
t.Run("bytes_bcontains_bytes_wrong_rhs", func(t *testing.T) {
result := overloads[idx["bytes_bcontains_bytes"]].Binary(types.Bytes([]byte("hello")), types.String("x"))
if !types.IsError(result) {
t.Error("expected error for non-Bytes rhs")
}
})
t.Run("string_bmatches_bytes", func(t *testing.T) {
binary := overloads[idx["string_bmatches_bytes"]].Binary
tests := []struct {
name string
pattern types.String
input types.Bytes
want types.Bool
}{
{"digits_match", `\d+`, types.Bytes([]byte("abc123")), true},
{"digits_no_match", `\d+`, types.Bytes([]byte("abc")), false},
{"any", `.*`, types.Bytes([]byte("hello")), true},
{"empty_pattern", ``, types.Bytes([]byte("hello")), true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := binary(tc.pattern, tc.input)
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
got, ok := result.(types.Bool)
if !ok {
t.Fatalf("expected types.Bool, got %T", result)
}
if got != tc.want {
t.Errorf("bmatches(%q, %q) = %v, want %v", tc.pattern, tc.input, got, tc.want)
}
})
}
})
t.Run("string_bmatches_bytes_invalid_regex", func(t *testing.T) {
result := overloads[idx["string_bmatches_bytes"]].Binary(types.String(`[invalid`), types.Bytes([]byte("hello")))
if !types.IsError(result) {
t.Error("expected error for invalid regex pattern")
}
})
t.Run("string_bmatches_bytes_wrong_lhs", func(t *testing.T) {
result := overloads[idx["string_bmatches_bytes"]].Binary(types.Int(0), types.Bytes([]byte("hello")))
if !types.IsError(result) {
t.Error("expected error for non-String lhs")
}
})
t.Run("string_bmatches_bytes_wrong_rhs", func(t *testing.T) {
result := overloads[idx["string_bmatches_bytes"]].Binary(types.String(`\d+`), types.String("123"))
if !types.IsError(result) {
t.Error("expected error for non-Bytes rhs")
}
})
t.Run("icontains_string", func(t *testing.T) {
binary := overloads[idx["icontains_string"]].Binary
tests := []struct {
name string
lhs types.String
rhs types.String
want types.Bool
}{
{"case_insensitive_match", "Hello World", "hello", true},
{"exact_match", "Hello World", "Hello", true},
{"upper_needle", "hello world", "WORLD", true},
{"not_contains", "hello world", "xyz", false},
{"empty_needle", "hello", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := binary(tc.lhs, tc.rhs)
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
got, ok := result.(types.Bool)
if !ok {
t.Fatalf("expected types.Bool, got %T", result)
}
if got != tc.want {
t.Errorf("icontains(%q, %q) = %v, want %v", tc.lhs, tc.rhs, got, tc.want)
}
})
}
})
t.Run("icontains_string_wrong_lhs", func(t *testing.T) {
result := overloads[idx["icontains_string"]].Binary(types.Bool(true), types.String("x"))
if !types.IsError(result) {
t.Error("expected error for non-String lhs")
}
})
t.Run("icontains_string_wrong_rhs", func(t *testing.T) {
result := overloads[idx["icontains_string"]].Binary(types.String("hello"), types.Int(1))
if !types.IsError(result) {
t.Error("expected error for non-String rhs")
}
})
t.Run("substr_string_int_int", func(t *testing.T) {
fn := overloads[idx["substr_string_int_int"]].Function
tests := []struct {
name string
str types.String
start types.Int
length types.Int
want types.String
}{
{"basic", "hello world", 0, 5, "hello"},
{"middle", "hello world", 6, 5, "world"},
{"single_char", "hello", 1, 1, "e"},
{"full", "hello", 0, 5, "hello"},
{"zero_length", "hello", 2, 0, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := fn(tc.str, tc.start, tc.length)
if types.IsError(result) {
t.Fatalf("substr(%q, %d, %d): unexpected error %v", tc.str, tc.start, tc.length, result)
}
got, ok := result.(types.String)
if !ok {
t.Fatalf("expected types.String, got %T", result)
}
if got != tc.want {
t.Errorf("substr(%q, %d, %d) = %q, want %q", tc.str, tc.start, tc.length, got, tc.want)
}
})
}
})
t.Run("substr_out_of_bounds", func(t *testing.T) {
fn := overloads[idx["substr_string_int_int"]].Function
oob := []struct {
name string
str types.String
start types.Int
length types.Int
}{
{"negative_start", "hello", -1, 2},
{"negative_length", "hello", 0, -1},
{"start_too_large", "hello", 10, 1},
{"length_overflow", "hello", 3, 10},
}
for _, tc := range oob {
t.Run(tc.name, func(t *testing.T) {
result := fn(tc.str, tc.start, tc.length)
if !types.IsError(result) {
t.Errorf("expected error for substr(%q, %d, %d), got %v", tc.str, tc.start, tc.length, result)
}
})
}
})
t.Run("substr_wrong_arg_count", func(t *testing.T) {
fn := overloads[idx["substr_string_int_int"]].Function
result := fn(types.String("hello"), types.Int(0))
if !types.IsError(result) {
t.Error("expected error for wrong argument count")
}
})
t.Run("substr_wrong_types", func(t *testing.T) {
fn := overloads[idx["substr_string_int_int"]].Function
cases := []struct {
name string
args []ref.Val
}{
{"wrong_str", []ref.Val{types.Int(0), types.Int(0), types.Int(1)}},
{"wrong_start", []ref.Val{types.String("hello"), types.String("x"), types.Int(1)}},
{"wrong_length", []ref.Val{types.String("hello"), types.Int(0), types.String("x")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
result := fn(tc.args...)
if !types.IsError(result) {
t.Errorf("expected error, got %v", result)
}
})
}
})
t.Run("startsWith_bytes", func(t *testing.T) {
binary := overloads[idx["startsWith_bytes"]].Binary
tests := []struct {
name string
lhs types.Bytes
rhs types.Bytes
want types.Bool
}{
{"match", types.Bytes([]byte("hello world")), types.Bytes([]byte("hello")), true},
{"no_match", types.Bytes([]byte("hello world")), types.Bytes([]byte("world")), false},
{"empty_prefix", types.Bytes([]byte("hello")), types.Bytes([]byte{}), true},
{"exact", types.Bytes([]byte("hello")), types.Bytes([]byte("hello")), true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := binary(tc.lhs, tc.rhs)
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
got, ok := result.(types.Bool)
if !ok {
t.Fatalf("expected types.Bool, got %T", result)
}
if got != tc.want {
t.Errorf("startsWith_bytes = %v, want %v", got, tc.want)
}
})
}
})
t.Run("startsWith_bytes_wrong_lhs", func(t *testing.T) {
result := overloads[idx["startsWith_bytes"]].Binary(types.String("hello"), types.Bytes([]byte("h")))
if !types.IsError(result) {
t.Error("expected error for non-Bytes lhs")
}
})
t.Run("startsWith_bytes_wrong_rhs", func(t *testing.T) {
result := overloads[idx["startsWith_bytes"]].Binary(types.Bytes([]byte("hello")), types.String("h"))
if !types.IsError(result) {
t.Error("expected error for non-Bytes rhs")
}
})
t.Run("startsWith_string", func(t *testing.T) {
binary := overloads[idx["startsWith_string"]].Binary
tests := []struct {
name string
lhs types.String
rhs types.String
want types.Bool
}{
{"case_insensitive_match", "Hello World", "hello", true},
{"upper_prefix", "hello world", "HELLO", true},
{"no_match", "hello world", "world", false},
{"empty_prefix", "hello", "", true},
{"exact", "Hello", "Hello", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := binary(tc.lhs, tc.rhs)
if types.IsError(result) {
t.Fatalf("unexpected error: %v", result)
}
got, ok := result.(types.Bool)
if !ok {
t.Fatalf("expected types.Bool, got %T", result)
}
if got != tc.want {
t.Errorf("startsWith_string(%q, %q) = %v, want %v", tc.lhs, tc.rhs, got, tc.want)
}
})
}
})
t.Run("startsWith_string_wrong_lhs", func(t *testing.T) {
result := overloads[idx["startsWith_string"]].Binary(types.Int(0), types.String("h"))
if !types.IsError(result) {
t.Error("expected error for non-String lhs")
}
})
t.Run("startsWith_string_wrong_rhs", func(t *testing.T) {
result := overloads[idx["startsWith_string"]].Binary(types.String("hello"), types.Bool(true))
if !types.IsError(result) {
t.Error("expected error for non-String rhs")
}
})
}
+51
View File
@@ -1342,3 +1342,54 @@ func TestRandomStrRejectsNegativeLength(t *testing.T) {
t.Fatalf("RandomStr negative length = %q, want empty", got) t.Fatalf("RandomStr negative length = %q, want empty", got)
} }
} }
// =============================================================================
// MakeVarDecl 测试
// =============================================================================
func TestMakeVarDecl(t *testing.T) {
tests := []struct {
name string
key string
value string
wantIdent string // 期望 Decl.Name
wantKind string // "int" / "string" / "object"
}{
{"randomInt 前缀 -> Int", "myrand", "randomInt(1,100)", "myrand", "int"},
{"newReverse 前缀 -> Object", "myrev", "newReverse()", "myrev", "object"},
{"普通字符串 -> String", "myvar", "somevalue", "myvar", "string"},
{"空值 -> String", "empty", "", "empty", "string"},
{"randomIntExtra -> Int", "n", "randomInt(0, 65535)", "n", "int"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
decl := MakeVarDecl(tt.key, tt.value)
if decl == nil {
t.Fatal("MakeVarDecl() returned nil")
}
if decl.Name != tt.wantIdent {
t.Errorf("Decl.Name = %q, want %q", decl.Name, tt.wantIdent)
}
// 通过 Type 字段判断类型种类
tp := decl.GetIdent().GetType()
if tp == nil {
t.Fatal("Decl.GetIdent().GetType() == nil")
}
switch tt.wantKind {
case "int":
if tp.GetPrimitive().String() != "INT64" {
t.Errorf("type = %v, want INT64", tp)
}
case "string":
if tp.GetPrimitive().String() != "STRING" {
t.Errorf("type = %v, want STRING", tp)
}
case "object":
if tp.GetMessageType() == "" {
t.Errorf("type = %v, want MessageType", tp)
}
}
})
}
}
+25
View File
@@ -2,9 +2,11 @@ package lib
import ( import (
"crypto/md5" //nolint:gosec // G501: MD5用于POC规则去重,非加密用途 "crypto/md5" //nolint:gosec // G501: MD5用于POC规则去重,非加密用途
"errors"
"fmt" "fmt"
"io" "io"
"math/rand" //nolint:gosec // G404: math/rand用于生成测试数据,非加密用途 "math/rand" //nolint:gosec // G404: math/rand用于生成测试数据,非加密用途
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -236,6 +238,9 @@ func executeRules(oReq *http.Request, p *Poc, variableMap map[string]interface{}
resp, err := DoRequest(newRequest, rule.FollowRedirects, session) resp, err := DoRequest(newRequest, rule.FollowRedirects, session)
newRequest = nil newRequest = nil
if err != nil { if err != nil {
if isTransportError(err) {
return false, nil
}
return false, err return false, err
} }
@@ -793,6 +798,9 @@ func clustersend(oReq *http.Request, variableMap map[string]interface{}, req *Re
// 发送请求 // 发送请求
resp, err := DoRequest(newRequest, rule.FollowRedirects, session) resp, err := DoRequest(newRequest, rule.FollowRedirects, session)
if err != nil { if err != nil {
if isTransportError(err) {
return false, nil
}
return false, fmt.Errorf("%s: %w", i18n.GetText("webscan_request_send_error"), err) return false, fmt.Errorf("%s: %w", i18n.GetText("webscan_request_send_error"), err)
} }
@@ -936,3 +944,20 @@ func GetHeader(header map[string]string) string {
builder.WriteString("\r\n") builder.WriteString("\r\n")
return builder.String() return builder.String()
} }
func isTransportError(err error) bool {
if err == nil {
return false
}
var netErr *net.OpError
if errors.As(err, &netErr) {
return true
}
s := err.Error()
return strings.Contains(s, "malformed HTTP") ||
strings.Contains(s, "transport connection broken") ||
strings.Contains(s, "connection reset") ||
strings.Contains(s, "connection refused") ||
strings.Contains(s, "i/o timeout") ||
strings.Contains(s, "EOF")
}
+332
View File
@@ -517,6 +517,74 @@ func TestPocExecutorPureHelpers(t *testing.T) {
}) })
} }
// =============================================================================
// isPlainLiteral 测试
// =============================================================================
func TestIsPlainLiteral_EmptyString(t *testing.T) {
if isPlainLiteral("", nil) {
t.Error("空字符串不是字面量")
}
}
func TestIsPlainLiteral_PlainWord(t *testing.T) {
if !isPlainLiteral("database", nil) {
t.Error("纯单词 'database' 应视为字面量")
}
}
func TestIsPlainLiteral_WithParens(t *testing.T) {
if isPlainLiteral("func()", nil) {
t.Error("含括号的表达式不是字面量")
}
}
func TestIsPlainLiteral_WithOperator(t *testing.T) {
for _, expr := range []string{"a+b", "a*b", "a==b", "a!=b", "a<b", "a>b", "a&&b", "a||b"} {
if isPlainLiteral(expr, nil) {
t.Errorf("含运算符的表达式 %q 不是字面量", expr)
}
}
}
func TestIsPlainLiteral_WithQuotes(t *testing.T) {
if isPlainLiteral(`"hello"`, nil) {
t.Error("含引号的表达式不是字面量")
}
if isPlainLiteral("'hello'", nil) {
t.Error("含单引号的表达式不是字面量")
}
}
func TestIsPlainLiteral_VariableRef(t *testing.T) {
// 如果 expr 是已声明变量的名字,应走 CEL 求值
varMap := map[string]interface{}{"token": "abc123"}
if isPlainLiteral("token", varMap) {
t.Error("已声明变量不应被视为字面量")
}
}
func TestIsPlainLiteral_UndeclaredVariable(t *testing.T) {
varMap := map[string]interface{}{"token": "abc123"}
// 未声明的变量名且无特殊字符 -> 字面量
if !isPlainLiteral("sql", varMap) {
t.Error("未声明的纯单词 'sql' 应视为字面量")
}
}
func TestIsPlainLiteral_WithBracket(t *testing.T) {
if isPlainLiteral("arr[0]", nil) {
t.Error("含方括号的表达式不是字面量")
}
}
func TestIsPlainLiteral_PathLike(t *testing.T) {
// 路径中可能含 /,但 / 不在排除字符中,视为字面量
if !isPlainLiteral("admin", nil) {
t.Error("纯字母字符串应为字面量")
}
}
func stringMatrixEqual(a, b [][]string) bool { func stringMatrixEqual(a, b [][]string) bool {
if len(a) != len(b) { if len(a) != len(b) {
return false return false
@@ -533,3 +601,267 @@ func stringMatrixEqual(a, b [][]string) bool {
} }
return true return true
} }
// =============================================================================
// buildVulnDetails 测试
// =============================================================================
func TestBuildVulnDetails(t *testing.T) {
tests := []struct {
name string
pocDef *Poc
vulName string
params StrMap
wantKeys []string
wantNoKeys []string
wantVulnType string
wantVulnName string
wantParamVal string
wantParamKey string
}{
{
name: "最小Poc只有Name",
pocDef: &Poc{Name: "poc-yaml-test"},
vulName: "poc-yaml-test",
params: nil,
wantKeys: []string{"vulnerability_type", "vulnerability_name"},
wantNoKeys: []string{"author", "references", "description", "parameters"},
wantVulnType: "poc-yaml-test",
wantVulnName: "poc-yaml-test",
},
{
name: "完整Poc含Author+Links+Description",
pocDef: &Poc{
Name: "poc-yaml-full",
Detail: Detail{
Author: "kei",
Links: []string{"https://example.com"},
Description: "test vuln",
},
},
vulName: "Full Vuln",
params: nil,
wantKeys: []string{"vulnerability_type", "vulnerability_name", "author", "references", "description"},
wantNoKeys: []string{"parameters"},
wantVulnType: "poc-yaml-full",
wantVulnName: "Full Vuln",
},
{
name: "有params则details含parameters字段",
pocDef: &Poc{Name: "poc-yaml-params"},
vulName: "Params Vuln",
params: StrMap{
{Key: "user", Value: "admin"},
{Key: "pass", Value: "123456"},
},
wantKeys: []string{"vulnerability_type", "vulnerability_name", "parameters"},
wantNoKeys: []string{"author"},
wantParamKey: "user",
wantParamVal: "admin",
},
{
name: "空params不含parameters字段",
pocDef: &Poc{Name: "poc-yaml-empty-params"},
vulName: "Empty Params",
params: StrMap{},
wantKeys: []string{"vulnerability_type", "vulnerability_name"},
wantNoKeys: []string{"parameters"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
details := buildVulnDetails(tt.pocDef, tt.vulName, tt.params)
for _, k := range tt.wantKeys {
if _, ok := details[k]; !ok {
t.Errorf("details 缺少字段 %q", k)
}
}
for _, k := range tt.wantNoKeys {
if _, ok := details[k]; ok {
t.Errorf("details 不应含字段 %q", k)
}
}
if tt.wantVulnType != "" {
if got, _ := details["vulnerability_type"].(string); got != tt.wantVulnType {
t.Errorf("vulnerability_type = %q, want %q", got, tt.wantVulnType)
}
}
if tt.wantVulnName != "" {
if got, _ := details["vulnerability_name"].(string); got != tt.wantVulnName {
t.Errorf("vulnerability_name = %q, want %q", got, tt.wantVulnName)
}
}
if tt.wantParamKey != "" {
pm, ok := details["parameters"].(map[string]string)
if !ok {
t.Fatalf("parameters 类型错误,实际 %T", details["parameters"])
}
if got := pm[tt.wantParamKey]; got != tt.wantParamVal {
t.Errorf("parameters[%q] = %q, want %q", tt.wantParamKey, got, tt.wantParamVal)
}
}
})
}
}
// =============================================================================
// buildVulnLogMsg 测试
// =============================================================================
func TestBuildVulnLogMsg(t *testing.T) {
tests := []struct {
name string
targetURL string
pocDef *Poc
vulName string
params StrMap
}{
{
name: "backup-file名称走特殊模板",
targetURL: "http://example.com",
pocDef: &Poc{Name: "poc-yaml-backup-file"},
vulName: "poc-yaml-backup-file",
params: nil,
},
{
name: "sql-file名称走特殊模板",
targetURL: "http://example.com",
pocDef: &Poc{Name: "poc-yaml-sql-file"},
vulName: "poc-yaml-sql-file",
params: nil,
},
{
name: "有params走params模板",
targetURL: "http://example.com",
pocDef: &Poc{Name: "poc-yaml-rce"},
vulName: "RCE",
params: StrMap{{Key: "cmd", Value: "id"}},
},
{
name: "无params走detail_header模板",
targetURL: "http://example.com",
pocDef: &Poc{
Name: "poc-yaml-sqli",
Detail: Detail{
Author: "kei",
Links: []string{"https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2024-0001"},
Description: "SQL injection",
},
},
vulName: "SQLi",
params: nil,
},
{
name: "无params无detail只走header",
targetURL: "http://example.com",
pocDef: &Poc{Name: "poc-yaml-generic"},
vulName: "Generic",
params: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := buildVulnLogMsg(tt.targetURL, tt.pocDef, tt.vulName, tt.params)
if msg == "" {
t.Errorf("buildVulnLogMsg() 返回空字符串")
}
})
}
}
// =============================================================================
// collectVarDeclarations 测试
// =============================================================================
func TestCollectVarDeclarations(t *testing.T) {
t.Run("空 POC 返回空切片", func(t *testing.T) {
p := &Poc{}
decls := collectVarDeclarations(p)
if len(decls) != 0 {
t.Fatalf("len = %d, want 0", len(decls))
}
})
t.Run("仅 Set 字段", func(t *testing.T) {
p := &Poc{
Set: StrMap{
{Key: "token", Value: "randomLowercase(8)"},
{Key: "port", Value: "randomInt(1000, 9000)"},
},
}
decls := collectVarDeclarations(p)
if len(decls) != 2 {
t.Fatalf("len = %d, want 2", len(decls))
}
if decls[0].Name != "token" {
t.Errorf("decls[0].Name = %q, want token", decls[0].Name)
}
if decls[1].Name != "port" {
t.Errorf("decls[1].Name = %q, want port", decls[1].Name)
}
})
t.Run("仅 Sets 字段", func(t *testing.T) {
p := &Poc{
Sets: ListMap{
{Key: "user", Value: []string{"admin", "root"}},
},
}
decls := collectVarDeclarations(p)
if len(decls) != 1 {
t.Fatalf("len = %d, want 1", len(decls))
}
if decls[0].Name != "user" {
t.Errorf("decls[0].Name = %q, want user", decls[0].Name)
}
})
t.Run("Sets 空值列表不 panic", func(t *testing.T) {
p := &Poc{
Sets: ListMap{
{Key: "empty", Value: []string{}},
},
}
decls := collectVarDeclarations(p)
if len(decls) != 1 {
t.Fatalf("len = %d, want 1", len(decls))
}
if decls[0].Name != "empty" {
t.Errorf("decls[0].Name = %q, want empty", decls[0].Name)
}
})
t.Run("Set 和 Sets 合并", func(t *testing.T) {
p := &Poc{
Set: StrMap{
{Key: "a", Value: "x"},
},
Sets: ListMap{
{Key: "b", Value: []string{"y"}},
},
}
decls := collectVarDeclarations(p)
if len(decls) != 2 {
t.Fatalf("len = %d, want 2", len(decls))
}
})
t.Run("newReverse 前缀推断 Object 类型", func(t *testing.T) {
p := &Poc{
Set: StrMap{
{Key: "rev", Value: "newReverse()"},
},
}
decls := collectVarDeclarations(p)
if len(decls) != 1 {
t.Fatalf("len = %d, want 1", len(decls))
}
tp := decls[0].GetIdent().GetType()
if tp == nil || tp.GetMessageType() == "" {
t.Errorf("期望 Object 类型,实际 %v", tp)
}
})
}
+237
View File
@@ -0,0 +1,237 @@
package lib
import (
"encoding/base64"
"strings"
"testing"
)
// =============================================================================
// Padding 测试
// =============================================================================
func TestPadding_BasicBlockAlignment(t *testing.T) {
tests := []struct {
name string
input []byte
blockSize int
wantLen int // 期望长度
}{
{
name: "空输入填充整个块",
input: []byte{},
blockSize: 16,
wantLen: 16,
},
{
name: "15字节填充1字节",
input: make([]byte, 15),
blockSize: 16,
wantLen: 16,
},
{
name: "整块对齐追加完整块",
input: make([]byte, 16),
blockSize: 16,
wantLen: 32,
},
{
name: "1字节填充15字节",
input: []byte{0x01},
blockSize: 16,
wantLen: 16,
},
{
name: "blockSize=8时的对齐",
input: make([]byte, 5),
blockSize: 8,
wantLen: 8,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Padding(tt.input, tt.blockSize)
if len(result) != tt.wantLen {
t.Errorf("Padding() len=%d, want %d", len(result), tt.wantLen)
}
// 验证填充字节值符合 PKCS7 规范
if len(result) > 0 {
padLen := int(result[len(result)-1])
if padLen == 0 || padLen > tt.blockSize {
t.Errorf("填充字节值 %d 超出 blockSize=%d", padLen, tt.blockSize)
}
// 验证所有填充字节相同
for i := len(result) - padLen; i < len(result); i++ {
if result[i] != byte(padLen) {
t.Errorf("填充字节[%d]=%d 不等于 padLen=%d", i, result[i], padLen)
}
}
}
})
}
}
func TestPadding_ResultLength(t *testing.T) {
// 任意长度输入,结果都应该是 blockSize 的整数倍
blockSize := 16
for inputLen := 0; inputLen < 50; inputLen++ {
input := make([]byte, inputLen)
result := Padding(input, blockSize)
if len(result)%blockSize != 0 {
t.Errorf("输入长度 %d: 填充后长度 %d 不是 %d 的倍数", inputLen, len(result), blockSize)
}
}
}
// =============================================================================
// AESCBCEncrypt 测试
// =============================================================================
func TestAESCBCEncrypt_ValidKey128(t *testing.T) {
// 128-bit AES key (16 bytes)
key := base64.StdEncoding.EncodeToString(make([]byte, 16))
result := AESCBCEncrypt(key)
if result == "" {
t.Error("有效的128位密钥应返回非空结果")
}
// 结果应为有效的 base64
_, err := base64.StdEncoding.DecodeString(result)
if err != nil {
t.Errorf("AESCBCEncrypt 结果应为有效 base64: %v", err)
}
}
func TestAESCBCEncrypt_ValidKey256(t *testing.T) {
// 256-bit AES key (32 bytes)
key := base64.StdEncoding.EncodeToString(make([]byte, 32))
result := AESCBCEncrypt(key)
if result == "" {
t.Error("有效的256位密钥应返回非空结果")
}
}
func TestAESCBCEncrypt_InvalidBase64Key(t *testing.T) {
result := AESCBCEncrypt("!!!not-valid-base64!!!")
if result != "" {
t.Error("无效 base64 密钥应返回空字符串")
}
}
func TestAESCBCEncrypt_InvalidKeySize(t *testing.T) {
// AES 要求密钥为 16/24/32 字节,10 字节无效
key := base64.StdEncoding.EncodeToString(make([]byte, 10))
result := AESCBCEncrypt(key)
if result != "" {
t.Error("无效密钥长度应返回空字符串")
}
}
func TestAESCBCEncrypt_NonDeterministic(t *testing.T) {
// 因为 IV 是随机的,两次加密结果应不同
key := base64.StdEncoding.EncodeToString(make([]byte, 16))
r1 := AESCBCEncrypt(key)
r2 := AESCBCEncrypt(key)
if r1 == r2 {
// 极小概率相同,记录即可
t.Log("两次加密结果相同(极低概率事件)")
}
}
// =============================================================================
// AESGCMEncrypt 测试
// =============================================================================
func TestAESGCMEncrypt_ValidKey128(t *testing.T) {
key := base64.StdEncoding.EncodeToString(make([]byte, 16))
result := AESGCMEncrypt(key)
if result == "" {
t.Error("有效的128位密钥应返回非空结果")
}
_, err := base64.StdEncoding.DecodeString(result)
if err != nil {
t.Errorf("AESGCMEncrypt 结果应为有效 base64: %v", err)
}
}
func TestAESGCMEncrypt_InvalidKey(t *testing.T) {
result := AESGCMEncrypt("invalid-base64!!!")
if result != "" {
t.Error("无效 base64 密钥应返回空字符串")
}
}
func TestAESGCMEncrypt_NonDeterministic(t *testing.T) {
key := base64.StdEncoding.EncodeToString(make([]byte, 16))
r1 := AESGCMEncrypt(key)
r2 := AESGCMEncrypt(key)
// GCM nonce 随机,结果不应相同
if r1 == r2 {
t.Log("两次 GCM 加密结果相同(极低概率事件)")
}
}
// =============================================================================
// GetShrioCookie 测试
// =============================================================================
func TestGetShrioCookie_CBCMode(t *testing.T) {
key := base64.StdEncoding.EncodeToString(make([]byte, 16))
result := GetShrioCookie(key, "cbc")
if result == "" {
t.Error("CBC 模式应返回非空 cookie")
}
}
func TestGetShrioCookie_GCMMode(t *testing.T) {
key := base64.StdEncoding.EncodeToString(make([]byte, 16))
result := GetShrioCookie(key, "gcm")
if result == "" {
t.Error("GCM 模式应返回非空 cookie")
}
}
func TestGetShrioCookie_DefaultMode(t *testing.T) {
// 非 gcm 模式走 CBC
key := base64.StdEncoding.EncodeToString(make([]byte, 16))
result := GetShrioCookie(key, "other")
cbcResult := AESCBCEncrypt(key)
// 两个结果都应为非空 base64,但由于随机 IV 不一定相同
if result == "" {
t.Error("默认(非gcm)模式应使用 CBC 加密并返回非空结果")
}
_ = cbcResult
}
func TestGetShrioCookie_RealShiroKey(t *testing.T) {
// 使用真实的 Shiro 默认密钥
shiroDefaultKey := "kPH+bIxk5D2deZiIxcaaaA=="
result := GetShrioCookie(shiroDefaultKey, "cbc")
if result == "" {
t.Error("使用默认 Shiro 密钥应能生成有效 cookie")
}
// 验证结果是 base64 编码
decoded, err := base64.StdEncoding.DecodeString(result)
if err != nil {
t.Errorf("结果应为有效 base64: %v", err)
}
// CBC 模式:IV(16字节) + 密文,结果至少 32 字节
if len(decoded) < 32 {
t.Errorf("CBC 加密结果太短: %d 字节", len(decoded))
}
}
func TestGetShrioCookie_ResultIsBase64(t *testing.T) {
key := base64.StdEncoding.EncodeToString(make([]byte, 16))
for _, mode := range []string{"cbc", "gcm"} {
result := GetShrioCookie(key, mode)
if result == "" {
t.Errorf("mode=%s: 结果不应为空", mode)
continue
}
// base64 只含 [A-Za-z0-9+/=]
if strings.ContainsAny(result, " \t\n\r") {
t.Errorf("mode=%s: base64 结果不应含空白字符", mode)
}
}
}
-26
View File
@@ -1,26 +0,0 @@
id: test-nuclei-example
info:
name: Test Nuclei Example Template
author: fscan-dev
severity: info
description: |
This is a test template to demonstrate Nuclei format support in fscan.
It will be automatically converted to fscan format during loading.
reference:
- https://github.com/shadow1ng/fscan
http:
- method: GET
path:
- "{{BaseURL}}/robots.txt"
matchers:
- type: word
words:
- "User-agent"
- "Disallow"
condition: and
- type: status
status:
- 200
+14 -1
View File
@@ -97,7 +97,11 @@ func WebScan(ctx context.Context, info *common.HostInfo, cfg *common.Config, ses
func buildTargetURL(info *common.HostInfo) (string, error) { func buildTargetURL(info *common.HostInfo) (string, error) {
// 自动构建URL // 自动构建URL
if info.URL == "" { if info.URL == "" {
info.URL = protocolHTTP + net.JoinHostPort(info.Host, fmt.Sprint(info.Port)) protocol := protocolHTTP
if isTLSPort(info.Port) {
protocol = protocolHTTPS
}
info.URL = protocol + net.JoinHostPort(info.Host, fmt.Sprint(info.Port))
} else if !hasProtocolPrefix(info.URL) { } else if !hasProtocolPrefix(info.URL) {
info.URL = protocolHTTP + normalizeSchemelessWebTarget(info.URL) info.URL = protocolHTTP + normalizeSchemelessWebTarget(info.URL)
} }
@@ -132,6 +136,15 @@ func hasProtocolPrefix(urlStr string) bool {
return strings.HasPrefix(urlStr, protocolHTTP) || strings.HasPrefix(urlStr, protocolHTTPS) return strings.HasPrefix(urlStr, protocolHTTP) || strings.HasPrefix(urlStr, protocolHTTPS)
} }
func isTLSPort(port int) bool {
switch port {
case 443, 8443, 4443, 9443:
return true
default:
return false
}
}
func normalizeSchemelessWebTarget(rawURL string) string { func normalizeSchemelessWebTarget(rawURL string) string {
authority := rawURL authority := rawURL
suffix := "" suffix := ""