mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 03:10:42 +08:00
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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. 打 tag(RC 手动打;正式版合并到 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
内网综合扫描工具,一键自动化漏扫。
|
内网综合扫描工具,一键自动化漏扫。
|
||||||
|
|
||||||
**版本**: 2.2.0-rc.1
|
**版本**: 2.2.0
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 无元素结果为 nil,len 为 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 返回的切片与源共享底层数组")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 获取格式类型
|
||||||
|
|||||||
@@ -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() = %q,want %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() = %q,want %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() = %v,want %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() = %q,want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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=false,Close 返回 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 应非 nil,Close 应正常关闭它
|
||||||
|
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 > end,looksLikeIPRange 通过(前半部分是有效 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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=1,capacity=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()
|
||||||
|
|||||||
@@ -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 >> slowEMA,ratio > 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.0;20+ 个相同 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)...)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
listener 1883
|
||||||
|
allow_anonymous true
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 条件且无 pattern:isAnd && len(Regex) > 0 为 false
|
||||||
|
if result {
|
||||||
|
t.Error("AND 条件下空 patterns 应返回 false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
// 无效 base64,GetShrioCookie 会返回 "",函数返回 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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 := ""
|
||||||
|
|||||||
Reference in New Issue
Block a user