mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-23 11:41:53 +08:00
v2.1.2 核心优化与多架构发布 (#561)
* feat: v2.1.0 核心重构与功能增强
## 架构重构
- 全局变量消除,迁移至 Config/State 对象
- SMB 插件融合(smb/smb2/smbghost/smbinfo)
- 服务探测重构,实现 Nmap 风格 fallback 机制
- 输出系统重构,TXT 实时刷盘 + 双写机制
- i18n 框架升级至 go-i18n
## 性能优化
- 正则表达式预编译
- 内存优化 map[string]struct{}
- 并发指纹匹配
- SOCKS5 连接复用
- 滑动窗口调度 + 自适应线程池
## 新功能
- Web 管理界面
- 多格式 POC 适配(xray/afrog)
- 增强指纹库(3139条)
- Favicon hash 指纹识别
- 插件选择性编译(Build Tags)
- fscan-lab 靶场环境
- 默认端口扩展(62→133)
## 构建系统
- 添加 no_local tag 支持排除本地插件
- 多版本构建:fscan/fscan-nolocal/fscan-web
- CI 添加 snapshot 模式支持仅测试构建
## Bug 修复
- 修复 120+ 个问题,包括 RDP panic、批量扫描漏报、
JSON 输出格式、Redis 检测、Context 超时等
## 测试增强
- 单元测试覆盖率 74-100%
- 并发安全测试
- 集成测试(Web/端口/服务/SSH/ICMP)
* fix(ci): 移除 PR 对 Project 自动化的触发
* fix: Elasticsearch未授权检测优先于爆破 (#554)
* fix: 修复RDP爆破高误报率问题 (#555)
- 移除 screen.go 中错误的认证结果覆盖逻辑
- 启用 NLA 协议的 ErrorCode 字段检测
- 添加 PubKeyAuth 验证确保认证真正成功
- 修复 io.go 中错误被静默忽略的问题
- 修复 socket.go/io.go 中可能导致 panic 的代码
- 修复 screen.go 中文件句柄泄漏和 log.Panic
* fix: 修复-user/-pwd凭据参数不生效的问题
问题原因:
- Parse()解析凭据后更新globalConfig
- 但BuildConfigFromFlags()创建新Config时使用默认字典
- 导致解析的UserPassPairs等凭据信息被丢弃
修复内容:
1. initialize.go: 将Parse解析的凭据结果应用到新Config
2. credential.go: 单用户密码对时创建UserPassPairs
3. rdp.go: 单凭据测试时跳过指纹识别,减少连接次数
* feat: RDP使用NLA仅验证模式,避免挤掉已登录用户
- 添加ErrNLAAuthSuccess标志用于NLA验证成功信号
- tpkt层支持nlaAuthOnly模式,验证成功后不建立完整会话
- x224层正确传播NLA验证结果
- rdpCrack改用NlaAuth进行凭据验证
* fix: 修复进度条在Windows终端满屏重复输出的问题
- 添加终端宽度检测,动态调整进度条长度
- 使用空格覆盖清除旧内容,避免残留
- 简化进度条格式,确保不超过终端宽度
* feat: 优化日志颜色方案,区分漏洞和普通信息
- 新增 LogVuln 级别(红色),用于漏洞和重要发现
- 密码爆破成功、未授权访问、POC漏洞等改用红色显示
- 普通信息(扫描统计等)改为白色
- Web指纹保持绿色
* refactor: 精简化输出,移除冗余启动信息
- 移除showParseSummary开局配置输出
- 移除LogPluginInfo/LogPluginInfoWithPort插件信息输出
- 移除alive_scanner冗余统计输出
- 移除port_scan_start扫描开始提示
- 移除handleUDPPorts SNMP死代码
- 移除相关i18n条目
* chore: 版本号更新为2.1.1
* fix: 降级依赖版本以保持Go 1.20兼容性
* feat(ldap): 添加NTLM Hash认证支持 (#433)
* chore: 清理无用的 replace 指令
* fix(ping): 修复 TTL expired 导致主机误判为存活的问题
在 ExecCommandPing 中增加错误关键词检测,当 ping 输出包含
TTL expired、Destination unreachable 等错误信息时,不再将
目标主机标记为存活。
Fixes #454
* fix(proxy): 修复透明代理导致输出全端口的问题
在代理初始化时主动探测代理行为,通过连接 RFC 5737 保留的
测试地址来检测是否存在"全回显"问题。如果探测到代理不可靠,
则在端口扫描时跳过所有端口,避免误报。
- 新增 proxyReliable 标志位标记代理可靠性
- 新增 ProbeProxyBehavior 函数探测代理行为
- 端口扫描前检查代理可靠性并输出警告
Fixes #495
* refactor: 移动debug模块到common/debug子包
* fix(web): 修复-u模式下Web插件未执行的问题
* fix: 优化输出格式和颜色显示
- 网段统计格式改为 10.253.0.0/16 网段存活: 26
- WebTitle基础信息改为白色,指纹识别单独绿色输出
- 移除重复的端口数量输出
* fix: URL解析自动补全协议头
-uf 文件中 192.168.1.1:8080 自动转为 http://192.168.1.1:8080
* fix: 修复-u/-uf模式下URLs丢失导致0目标扫描的问题
Parse阶段将URLs设置到全局状态,但Initialize随后创建新状态
并覆盖了全局状态,导致URLs数据丢失。现在在创建新状态前
先保存并迁移Parse阶段设置的URLs和HostPorts数据。
* fix: 智能检测HTTP/HTTPS协议并优化URL显示
- 修复-u/-uf模式URLs丢失导致0目标扫描问题
- detectProtocol改为主动TLS握手检测,不依赖服务名
- WebTitle输出显示完整协议(http/https)
- 隐藏标准端口(80/443)使输出更简洁
* refactor: 精简parsers包,统一配置构建入口
- 删除冗余的中间层(XXXInput、XXXParser类)
- 新增 config_builder.go 统一配置构建
- parsers包从3000+行精简至~540行
- 保留核心函数:ParseIP、ParsePort、文件读取、凭据解析
* test: 扩展parsers单元测试覆盖边缘情况
- 新增内网简写解析测试(192/172/10)
- 新增完整IP范围和无效CIDR测试
- 新增Windows行尾(CRLF)处理测试
- 新增凭据和哈希文件解析测试
- 新增端口解析边缘情况测试
- 测试覆盖率达到94.2%
* refactor: 优化控制台输出格式
- 去掉时间戳,保留[*][+]前缀
- Web输出合并WebTitle和WebFinger为一行
- 有指纹显示绿色[+],无指纹显示白色[*]
- 格式: code:xxx len:xxx title:xxx server:xxx [指纹]
- 服务探测格式: [Product:xxx ||Version:xxx] Banner:(xxx)
- 字段对齐,输出更清爽
* feat: 添加凭据测试未发现弱密码的提示
- credential_tester.go: 失败时设置 Type=ResultTypeCredential
- scanner.go: 根据结果类型在 error 级别输出'未发现弱密码'提示
- 新增 i18n 翻译 brute_no_weak_pass
使用 -log all 或 -log error 可看到此提示
* refactor(logging): 重构日志级别为层级过滤设计
- LogLevel 从 string 改为 int 类型,支持层级比较
- 层级设计:Debug(0) < Base(1) < Info(2) < Success(3) < Vuln(4) < Error(5)
- 设置一个级别后,显示该级别及以上的日志
- Error 级别始终显示,不会被配置过滤掉
- 保留向后兼容别名(LevelAll, LevelInfoSuccess 等)
- 更新测试以匹配新的层级过滤行为
* style(logging): Error级别日志改为黄色显示
* style(findnet): NetInfo输出改为每行一个IP
* refactor(ms17010): 优化错误提示,明确指出SMBv1不支持等情况
* fix(credential): 修复凭据测试结果不一致的问题
问题原因:
1. 未知错误类型不重试,导致服务端限流时跳过正确密码
2. SSH 错误分类不够准确,某些临时错误未被识别
修复内容:
1. 未知错误改为可重试(可能是临时问题)
2. 增加 SSH 特有的网络错误识别(handshake failed, disconnect 等)
* fix(portfinger): 修复SMB2服务指纹识别和NetInfo输出问题
- 添加SMB2ProgNeg探针支持现代Windows的SMB2协议
- 修复Go regexp对高位字节的UTF-8兼容问题,使用Latin-1转换
- 修复探针失败后连接重建逻辑
- 修复vendor_product字段名不匹配问题
- 修复NetInfo多行输出被其他日志打断的问题
* fix(config): 从默认端口移除9100,避免触发打印机打印 (#517)
* feat(proxy): 增强代理端口扫描的深度验证机制
- 新增4阶段深度验证:Banner读取→探测发送→响应等待→最终判定
- 新增SOCKS5错误码和代理错误文本检测
- 优化ProbeProxyBehavior探测逻辑,发送数据验证连接可达性
- 解决透明代理/全回显代理导致的假阳性问题
* fix(proxy): 修复代理深度验证的若干问题
- detector.go: 修复 AutoConfigureProxy 覆盖探测结果的问题
只有未探测过时才设置默认 proxyReliable 值
- port_scan.go: 改进深度验证机制
- 使用带 Host header 的 HTTP GET 请求替代 OPTIONS
- 延长响应等待超时至 2s 以适配慢速服务器
- 正确重置连接 deadline 避免影响后续操作
* refactor: 统一 common 包文件命名风格
Flag.go -> flag.go
* refactor(proxy): 删除自定义 contains() 函数,改用标准库
- 用 strings.Contains() 替代手写的 contains()
- 删除过时的注释
* fix(parsers): 修复带横杠域名被误识别为IP范围的问题
如 111-555.sss.com 这类域名因包含 - 被错误解析为 IP 范围,
添加 looksLikeIPRange() 检查,只有 - 前是有效 IP 才走范围解析
* fix(proxy): 修复代理模式下服务识别错误和端口漏扫问题
- port_scan.go: 验证通过后重建干净连接,避免HTTP GET探测污染服务识别
- port_scan.go: 优化验证策略,用轻量CRLF探测替代HTTP GET,超时从2.2s降至0.6s
- manager.go: 修正ProbeProxyBehavior判断逻辑,超时应视为代理正常转发
* fix(pool): 移除线程池预分配,优化大规模扫描内存占用
WithPreAlloc(true) 会预先创建所有 worker goroutine,
在大规模扫描(如 25域名×65535端口)时可能导致内存问题
* refactor(logging): 统一日志前缀,删除废弃的 LogBase
- 删除 LogBase 函数,所有调用迁移到 LogInfo/LogError
- 新增 PrefixDebug ([.]) 前缀,所有日志级别现在都有前缀
- 修复日志输出缩进不一致的问题
- 删除未使用的 PrefixDefault 常量
* perf(icmp): 实现自适应等待算法优化存活检测性能
- 新增 waitAdaptive 函数,监控响应增量实现智能提前结束
- 算法保守原则:最小等待1s + 连续500ms无新响应才提前结束
- 添加100ms检查间隔避免CPU空转
- 保留原有最大等待时间(3s/6s)作为兜底
- 添加完整单元测试覆盖各种场景
优化效果:
- 全部响应:~100ms (原3s)
- 无响应:~1s (原3s)
- 部分响应后稳定:~1.5s (原3s)
* perf(scan): 实现启发式优化提升扫描体验
1. 端口优先级排序:高价值端口(80,443,22,3389等)优先扫描
- 用户能更快看到有意义的结果
- 不影响端口喷洒策略
2. TCP 补充探测:ICMP 响应率<10%时自动启用
- 对未响应主机用 TCP 80/443/22/445 补充探测
- 解决防火墙过滤 ICMP 导致漏检的问题
* refactor(grdp): 精简RDP库,删除认证检测不需要的代码
- 删除 VNC 协议支持 (protocol/rfb, client/rfb.go)
- 删除完整客户端框架 (client/)
- 删除 RemoteApp 等插件 (plugin/)
- 删除 RLE 图形解压 (core/rle.go)
- 删除绘图指令处理 (pdu/orders.go, pdu/gdi.go)
- 精简 screen.go,移除截图和完整会话功能
- 移除未使用的 RGB 转换函数
grdp 代码从 13,044 行精简至 7,581 行,削减 42%
* refactor(common): 删除死代码,优化代码风格
- 删除未使用的 joinStrings/joinInts 函数
- 删除未使用的 memStats 字段和 getMemoryInfo 方法
- 简化 parsePasswords 中的循环为 append(...) 形式
* refactor(services): 统一数据库插件的DBWrapper
4个数据库插件(MySQL、PostgreSQL、MSSQL、Oracle)都有相同的sql.DB包装代码,
合并为通用的SQLDBWrapper,减少重复。
* refactor(core,grdp): 删除未使用的死代码
- 移除 BaseScanStrategy.LogPluginInfoWithPort 方法(无调用者)
- 移除 mcs.go 中被注释的旧 connect 函数实现
* refactor: 删除 deadcode 检测出的未使用函数
- proxy/detector.go: 删除 IsSOCKS5Standard, IsProxyInitialized
- findnet.go: 删除 NetworkInfo.OneLine, TreeFormat 方法
- port_scan.go: 删除 estimateScanTime 函数
- web_scanner.go: 删除 GetFingerprints 函数
- 清理相关测试代码
* refactor: 删除更多未使用的死代码
- parse.go: 删除 RemoveDuplicate 函数及其测试
- parsers.go: 删除 excludeHosts, removeDuplicates 别名函数
- 更新测试使用真正的函数名
* fix(test): 修复 TestParseIP_InvalidIPRange 测试用例
- 删除不合理的测试用例(无效IP被当作普通主机名处理是设计行为)
- 修复测试逻辑,只在真正通过时输出"正确"
* fix(scan): 移除域名预解析,保留原始域名进行扫描
域名预解析会将域名转换为IP,导致虚拟主机场景下HTTP访问失败
(Host头变成IP而非域名,无法正确路由)
* fix(scan): 修复 -hf 参数无法单独使用的问题
* fix(proxy): 修复透明代理环境下 SOCKS5 代理全端口误报问题
问题:在透明代理(TUN模式)环境下使用 SOCKS5 代理扫描时,
会出现全端口开放的误报,因为代理可靠性检测被透明代理污染。
修复方案(参考 fscanx):
1. 将探针从 CRLF 改为 HTTP GET,更有效检测真实连接状态
2. 删除 "uncertain" 状态,无响应一律判定为端口关闭
3. 调整超时时间以适应代理链路延迟
Fixes #524
* feat(telnet): 新增 telnetd RCE 命令执行验证,修复未授权访问日志级别
* fix: 修复 i18n.Tr vet 报错、Unicode 测试用例,移除过期域名
- 移除 i18n.Tr 中错误的 fmt.Sprintf fallback,消除 go vet 误报
- 修复 match_engine_test Unicode 测试用例与 Latin-1 转换逻辑不匹配
- README 移除过期的 fscan.club 域名
- 添加 .gitattributes 统一换行符为 LF
* refactor: 统一控制台输出风格,使用统一的日志函数
手动合并 PR #558 的改动,适配重构后的代码路径
* fix(ci): 修复版本注入和CI触发配置
- goreleaser ldflags 指向正确的包路径 common.version/commit/date
- version 改为 var 支持 ldflags 注入,banner 显示 commit 和构建日期
- test-build 触发分支增加 dev-* 通配
* fix(ci): 修复 Windows 产物 .exe.exe 双后缀问题
* feat(ci): 扩展构建架构支持 MIPS/ARM/FreeBSD/Solaris
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/panjf2000/ants/v2"
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
// AdaptivePool 自适应线程池
|
||||
// 封装 ants.PoolWithFunc,支持根据资源耗尽率动态调整线程数
|
||||
type AdaptivePool struct {
|
||||
pool *ants.PoolWithFunc
|
||||
state *common.State
|
||||
|
||||
initialSize int
|
||||
minSize int
|
||||
maxSize int
|
||||
currentSize int32 // 原子操作
|
||||
|
||||
// 监控参数
|
||||
checkInterval time.Duration
|
||||
lastCheck time.Time
|
||||
lastExhaustedCount int64
|
||||
lastPacketCount int64
|
||||
|
||||
// 阈值
|
||||
exhaustedThreshold float64 // 资源耗尽率阈值(触发降级)
|
||||
recoveryThreshold float64 // 恢复阈值(允许升级)
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewAdaptivePool 创建自适应线程池
|
||||
func NewAdaptivePool(size int, fn func(interface{}), state *common.State) (*AdaptivePool, error) {
|
||||
// 移除 WithPreAlloc(true),在大规模扫描时预分配可能导致内存问题
|
||||
pool, err := ants.NewPoolWithFunc(size, fn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
minSize := size / 4
|
||||
if minSize < 10 {
|
||||
minSize = 10
|
||||
}
|
||||
|
||||
return &AdaptivePool{
|
||||
pool: pool,
|
||||
state: state,
|
||||
initialSize: size,
|
||||
minSize: minSize,
|
||||
maxSize: size,
|
||||
currentSize: int32(size),
|
||||
checkInterval: time.Second,
|
||||
exhaustedThreshold: 0.10, // 10% 资源耗尽率触发降级
|
||||
recoveryThreshold: 0.02, // 2% 以下允许恢复
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Invoke 提交任务,并在适当时机检查是否需要调整线程数
|
||||
func (ap *AdaptivePool) Invoke(task interface{}) error {
|
||||
ap.maybeAdjust()
|
||||
return ap.pool.Invoke(task)
|
||||
}
|
||||
|
||||
// maybeAdjust 检查并可能调整线程池大小
|
||||
func (ap *AdaptivePool) maybeAdjust() {
|
||||
now := time.Now()
|
||||
|
||||
ap.mu.Lock()
|
||||
if now.Sub(ap.lastCheck) < ap.checkInterval {
|
||||
ap.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ap.lastCheck = now
|
||||
|
||||
// 获取当前计数
|
||||
currentExhausted := ap.state.GetResourceExhaustedCount()
|
||||
currentPackets := ap.state.GetPacketCount()
|
||||
|
||||
// 计算增量(本周期内的耗尽率)
|
||||
deltaExhausted := currentExhausted - ap.lastExhaustedCount
|
||||
deltaPackets := currentPackets - ap.lastPacketCount
|
||||
|
||||
ap.lastExhaustedCount = currentExhausted
|
||||
ap.lastPacketCount = currentPackets
|
||||
ap.mu.Unlock()
|
||||
|
||||
// 需要足够的样本才能判断
|
||||
if deltaPackets < 100 {
|
||||
return
|
||||
}
|
||||
|
||||
rate := float64(deltaExhausted) / float64(deltaPackets)
|
||||
currentSize := int(atomic.LoadInt32(&ap.currentSize))
|
||||
|
||||
if rate > ap.exhaustedThreshold && currentSize > ap.minSize {
|
||||
// 降级:减少 20% 线程
|
||||
newSize := int(float64(currentSize) * 0.8)
|
||||
if newSize < ap.minSize {
|
||||
newSize = ap.minSize
|
||||
}
|
||||
ap.tune(newSize)
|
||||
common.LogInfo(fmt.Sprintf("[AdaptivePool] 资源耗尽率 %.1f%%, 线程数 %d -> %d", rate*100, currentSize, newSize))
|
||||
} else if rate < ap.recoveryThreshold && currentSize < ap.maxSize {
|
||||
// 恢复:增加 10% 线程(保守恢复)
|
||||
newSize := int(float64(currentSize) * 1.1)
|
||||
if newSize > ap.maxSize {
|
||||
newSize = ap.maxSize
|
||||
}
|
||||
if newSize > currentSize {
|
||||
ap.tune(newSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tune 调整线程池大小
|
||||
func (ap *AdaptivePool) tune(newSize int) {
|
||||
ap.pool.Tune(newSize)
|
||||
atomic.StoreInt32(&ap.currentSize, int32(newSize))
|
||||
}
|
||||
|
||||
// Running 返回当前运行中的 goroutine 数量
|
||||
func (ap *AdaptivePool) Running() int {
|
||||
return ap.pool.Running()
|
||||
}
|
||||
|
||||
// Cap 返回当前池容量
|
||||
func (ap *AdaptivePool) Cap() int {
|
||||
return int(atomic.LoadInt32(&ap.currentSize))
|
||||
}
|
||||
|
||||
// Release 释放线程池
|
||||
func (ap *AdaptivePool) Release() {
|
||||
ap.pool.Release()
|
||||
}
|
||||
|
||||
// Wait 等待所有任务完成
|
||||
func (ap *AdaptivePool) Wait() {
|
||||
// ants 没有原生 Wait,通过 Running() == 0 轮询
|
||||
for ap.pool.Running() > 0 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package core
|
||||
|
||||
/*
|
||||
adaptive_pool_test.go - AdaptivePool 高价值测试
|
||||
|
||||
测试重点:
|
||||
1. 并发安全 - 多goroutine同时调整不崩溃
|
||||
2. 降级逻辑 - 资源耗尽率高时正确减少线程
|
||||
3. 恢复逻辑 - 资源耗尽率低时正确增加线程
|
||||
4. 边界条件 - 不超过minSize/maxSize
|
||||
|
||||
不测试:
|
||||
- 简单的getter方法(太简单,不值得)
|
||||
- ants库本身的正确性(库作者负责)
|
||||
*/
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// 场景1:降级逻辑测试(高价值)
|
||||
// =============================================================================
|
||||
|
||||
// TestAdaptivePool_DowngradeOnHighExhaustion 验证资源耗尽率高时降低线程数
|
||||
// 这是个核心业务逻辑:耗尽率 > 10% 时应该减少线程
|
||||
func TestAdaptivePool_DowngradeOnHighExhaustion(t *testing.T) {
|
||||
state := common.NewState()
|
||||
|
||||
pool, err := NewAdaptivePool(100, func(interface{}) {}, state)
|
||||
if err != nil {
|
||||
t.Fatalf("创建线程池失败: %v", err)
|
||||
}
|
||||
defer pool.Release()
|
||||
|
||||
initialCap := pool.Cap()
|
||||
|
||||
// 模拟高资源耗尽率:20% 的包都失败了
|
||||
// 需要至少100个样本才会触发调整
|
||||
for i := 0; i < 200; i++ {
|
||||
state.IncrementPacketCount()
|
||||
if i < 40 { // 前40个失败(20%)
|
||||
state.IncrementResourceExhaustedCount()
|
||||
}
|
||||
}
|
||||
|
||||
// 触发调整:提交足够多的任务让maybeAdjust被调用
|
||||
for i := 0; i < 20; i++ {
|
||||
_ = pool.Invoke(nil)
|
||||
time.Sleep(time.Millisecond * 10) // 等待异步调整
|
||||
}
|
||||
|
||||
// 等待调整完成
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
|
||||
finalCap := pool.Cap()
|
||||
|
||||
// 验证:线程数应该减少
|
||||
if finalCap >= initialCap {
|
||||
t.Errorf("应该降级: 初始 %d, 最终 %d", initialCap, finalCap)
|
||||
}
|
||||
|
||||
// 验证:不应该降到minSize以下
|
||||
minSize := initialCap / 4
|
||||
if minSize < 10 {
|
||||
minSize = 10
|
||||
}
|
||||
if finalCap < minSize {
|
||||
t.Errorf("降到minSize以下: %d < %d", finalCap, minSize)
|
||||
}
|
||||
|
||||
t.Logf("降级成功: %d -> %d (min=%d)", initialCap, finalCap, minSize)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 场景3:恢复逻辑测试(高价值)
|
||||
// =============================================================================
|
||||
|
||||
// TestAdaptivePool_NoRecoveryOnLowExhaustion 验证低耗尽率时不升级
|
||||
// 防止线程数盲目增长
|
||||
func TestAdaptivePool_NoRecoveryOnLowExhaustion(t *testing.T) {
|
||||
state := common.NewState()
|
||||
|
||||
pool, err := NewAdaptivePool(50, func(interface{}) {}, state)
|
||||
if err != nil {
|
||||
t.Fatalf("创建线程池失败: %v", err)
|
||||
}
|
||||
defer pool.Release()
|
||||
|
||||
// 先降到minSize
|
||||
for i := 0; i < 500; i++ {
|
||||
state.IncrementPacketCount()
|
||||
state.IncrementResourceExhaustedCount() // 100% 耗尽
|
||||
}
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
_ = pool.Invoke(nil)
|
||||
}
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
|
||||
reducedCap := pool.Cap()
|
||||
|
||||
// 现在模拟低耗尽率:只有1%失败
|
||||
for i := 0; i < 500; i++ {
|
||||
state.IncrementPacketCount()
|
||||
if i%100 == 0 { // 只有5个失败(1%)
|
||||
state.IncrementResourceExhaustedCount()
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
_ = pool.Invoke(nil)
|
||||
}
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
|
||||
finalCap := pool.Cap()
|
||||
|
||||
// 验证:即使耗尽率低,也不应该立即恢复(保守策略)
|
||||
// 或者即使恢复,也很有限
|
||||
if finalCap > reducedCap+5 {
|
||||
t.Logf("恢复行为: %d -> %d", reducedCap, finalCap)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 场景4:边界条件测试(中价值)
|
||||
// =============================================================================
|
||||
|
||||
// TestAdaptivePool_MinSizeBoundary 验证不会降到minSize以下
|
||||
func TestAdaptivePool_MinSizeBoundary(t *testing.T) {
|
||||
state := common.NewState()
|
||||
|
||||
// 创建小线程池,minSize会是10
|
||||
pool, err := NewAdaptivePool(40, func(interface{}) {}, state)
|
||||
if err != nil {
|
||||
t.Fatalf("创建线程池失败: %v", err)
|
||||
}
|
||||
defer pool.Release()
|
||||
|
||||
// 模拟极端的资源耗尽:100%失败
|
||||
for i := 0; i < 1000; i++ {
|
||||
state.IncrementPacketCount()
|
||||
state.IncrementResourceExhaustedCount()
|
||||
}
|
||||
|
||||
// 触发多次调整
|
||||
for i := 0; i < 50; i++ {
|
||||
_ = pool.Invoke(nil)
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
finalCap := pool.Cap()
|
||||
|
||||
// 验证:不应该低于10
|
||||
if finalCap < 10 {
|
||||
t.Errorf("线程数 < 10: %d", finalCap)
|
||||
}
|
||||
|
||||
t.Logf("最小边界测试通过: cap=%d", finalCap)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 场景5:样本不足测试(低价值但重要)
|
||||
// =============================================================================
|
||||
|
||||
// TestAdaptivePool_NotEnoughSamples 验证样本不足时不调整
|
||||
// 防止基于小样本做错误决策
|
||||
func TestAdaptivePool_NotEnoughSamples(t *testing.T) {
|
||||
state := common.NewState()
|
||||
|
||||
pool, err := NewAdaptivePool(100, func(interface{}) {}, state)
|
||||
if err != nil {
|
||||
t.Fatalf("创建线程池失败: %v", err)
|
||||
}
|
||||
defer pool.Release()
|
||||
|
||||
initialCap := pool.Cap()
|
||||
|
||||
// 只增加少量样本(<100),不足以触发调整
|
||||
for i := 0; i < 50; i++ {
|
||||
state.IncrementPacketCount()
|
||||
state.IncrementResourceExhaustedCount() // 即使100%失败也不调整
|
||||
}
|
||||
|
||||
// 提交任务
|
||||
for i := 0; i < 10; i++ {
|
||||
_ = pool.Invoke(nil)
|
||||
}
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
|
||||
finalCap := pool.Cap()
|
||||
|
||||
// 验证:样本不足时不应该调整
|
||||
if finalCap != initialCap {
|
||||
t.Errorf("样本不足时不应该调整: %d -> %d", initialCap, finalCap)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 辅助函数
|
||||
// =============================================================================
|
||||
|
||||
// TestAdaptivePool_Wait 验证Wait方法正确等待所有任务完成
|
||||
func TestAdaptivePool_Wait(t *testing.T) {
|
||||
state := common.NewState()
|
||||
|
||||
pool, err := NewAdaptivePool(10, func(interface{}) {
|
||||
time.Sleep(time.Millisecond * 50)
|
||||
}, state)
|
||||
if err != nil {
|
||||
t.Fatalf("创建线程池失败: %v", err)
|
||||
}
|
||||
defer pool.Release()
|
||||
|
||||
// 提交任务
|
||||
for i := 0; i < 20; i++ {
|
||||
_ = pool.Invoke(nil)
|
||||
}
|
||||
|
||||
// Wait应该在所有任务完成后返回
|
||||
start := time.Now()
|
||||
pool.Wait()
|
||||
duration := time.Since(start)
|
||||
|
||||
// 20个任务,每个50ms,10个线程,应该约100ms完成
|
||||
if duration < 80*time.Millisecond {
|
||||
t.Logf("Wait提前返回?可能测试有问题: %v", duration)
|
||||
}
|
||||
if duration > 200*time.Millisecond {
|
||||
t.Errorf("Wait耗时过长: %v", duration)
|
||||
}
|
||||
|
||||
t.Logf("Wait测试通过: %v", duration)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/common/i18n"
|
||||
"github.com/shadow1ng/fscan/common/parsers"
|
||||
)
|
||||
|
||||
/*
|
||||
AliveScanner.go - 存活探测扫描器
|
||||
|
||||
专门用于主机存活探测,仅执行ICMP/Ping检测,
|
||||
快速识别网络中的存活主机,不进行端口扫描。
|
||||
*/
|
||||
|
||||
// AliveScanStrategy 存活探测扫描策略
|
||||
type AliveScanStrategy struct {
|
||||
*BaseScanStrategy
|
||||
startTime time.Time
|
||||
stats AliveStats
|
||||
}
|
||||
|
||||
// AliveStats 存活探测统计信息
|
||||
type AliveStats struct {
|
||||
TotalHosts int // 总主机数
|
||||
AliveHosts int // 存活主机数
|
||||
DeadHosts int // 死亡主机数
|
||||
ScanDuration time.Duration // 扫描耗时
|
||||
SuccessRate float64 // 成功率
|
||||
AliveHostList []string // 存活主机列表
|
||||
}
|
||||
|
||||
// NewAliveScanStrategy 创建新的存活探测扫描策略
|
||||
func NewAliveScanStrategy() *AliveScanStrategy {
|
||||
return &AliveScanStrategy{
|
||||
BaseScanStrategy: NewBaseScanStrategy("存活探测", FilterNone),
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// Name 返回策略名称
|
||||
func (s *AliveScanStrategy) Name() string {
|
||||
return i18n.GetText("scan_strategy_alive_name")
|
||||
}
|
||||
|
||||
// Description 返回策略描述
|
||||
func (s *AliveScanStrategy) Description() string {
|
||||
return i18n.GetText("scan_strategy_alive_desc")
|
||||
}
|
||||
|
||||
// Execute 执行存活探测扫描策略
|
||||
func (s *AliveScanStrategy) Execute(config *common.Config, state *common.State, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
|
||||
// 验证扫描目标(需要同时检查 -h 和 -hf 参数)
|
||||
fv := common.GetFlagVars()
|
||||
if info.Host == "" && fv.HostsFile == "" {
|
||||
common.LogError(i18n.GetText("parse_error_target_empty"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// 执行存活探测
|
||||
s.performAliveScan(info, config, state)
|
||||
|
||||
// 输出统计信息
|
||||
s.outputStats()
|
||||
}
|
||||
|
||||
// performAliveScan 执行存活探测
|
||||
func (s *AliveScanStrategy) performAliveScan(info common.HostInfo, config *common.Config, state *common.State) {
|
||||
// 解析目标主机
|
||||
fv := common.GetFlagVars()
|
||||
hosts, err := parsers.ParseIP(info.Host, fv.HostsFile, fv.ExcludeHosts)
|
||||
if err != nil {
|
||||
common.LogError(i18n.Tr("parse_target_failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
if len(hosts) == 0 {
|
||||
common.LogError(i18n.GetText("parse_error_no_hosts"))
|
||||
return
|
||||
}
|
||||
|
||||
// 初始化统计信息
|
||||
s.stats.TotalHosts = len(hosts)
|
||||
s.stats.AliveHosts = 0
|
||||
s.stats.DeadHosts = 0
|
||||
|
||||
|
||||
// 执行存活检测
|
||||
aliveList := CheckLive(hosts, false, config, state) // 使用ICMP探测
|
||||
|
||||
// 更新统计信息
|
||||
s.stats.AliveHosts = len(aliveList)
|
||||
s.stats.DeadHosts = s.stats.TotalHosts - s.stats.AliveHosts
|
||||
s.stats.ScanDuration = time.Since(s.startTime)
|
||||
s.stats.AliveHostList = aliveList // 存储存活主机列表
|
||||
|
||||
if s.stats.TotalHosts > 0 {
|
||||
s.stats.SuccessRate = float64(s.stats.AliveHosts) / float64(s.stats.TotalHosts) * 100
|
||||
}
|
||||
}
|
||||
|
||||
// outputStats 输出统计信息(精简版)
|
||||
func (s *AliveScanStrategy) outputStats() {
|
||||
// 只输出存活主机列表,不输出冗余统计
|
||||
for _, host := range s.stats.AliveHostList {
|
||||
common.LogSuccess(fmt.Sprintf("alive %s", host))
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareTargets 存活探测不需要准备扫描目标
|
||||
func (s *AliveScanStrategy) PrepareTargets(info common.HostInfo) []common.HostInfo {
|
||||
// 存活探测不需要返回目标列表,因为它不进行后续扫描
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPlugins 存活探测不使用插件
|
||||
func (s *AliveScanStrategy) GetPlugins(config *common.Config) ([]string, bool) {
|
||||
return []string{}, false
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
// TestNewAliveScanStrategy 测试构造函数
|
||||
func TestNewAliveScanStrategy(t *testing.T) {
|
||||
strategy := NewAliveScanStrategy()
|
||||
|
||||
if strategy == nil {
|
||||
t.Fatal("NewAliveScanStrategy 返回 nil")
|
||||
}
|
||||
|
||||
if strategy.BaseScanStrategy == nil {
|
||||
t.Error("BaseScanStrategy 未初始化")
|
||||
}
|
||||
|
||||
// 验证起始时间已设置
|
||||
if strategy.startTime.IsZero() {
|
||||
t.Error("startTime 未初始化")
|
||||
}
|
||||
|
||||
// 验证时间在合理范围内(过去1秒内)
|
||||
if time.Since(strategy.startTime) > time.Second {
|
||||
t.Error("startTime 时间戳异常")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAliveScanStrategy_PrepareTargets 测试PrepareTargets
|
||||
func TestAliveScanStrategy_PrepareTargets(t *testing.T) {
|
||||
strategy := NewAliveScanStrategy()
|
||||
|
||||
// 存活探测不需要返回目标列表
|
||||
targets := strategy.PrepareTargets(common.HostInfo{})
|
||||
|
||||
if targets != nil {
|
||||
t.Errorf("PrepareTargets 应返回 nil, 实际: %v", targets)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAliveScanStrategy_GetPlugins 测试GetPlugins
|
||||
func TestAliveScanStrategy_GetPlugins(t *testing.T) {
|
||||
strategy := NewAliveScanStrategy()
|
||||
|
||||
plugins, customMode := strategy.GetPlugins(nil)
|
||||
|
||||
if len(plugins) != 0 {
|
||||
t.Errorf("GetPlugins 应返回空列表, 实际长度: %d", len(plugins))
|
||||
}
|
||||
|
||||
if customMode {
|
||||
t.Error("customMode 应为 false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAliveStats_SuccessRateCalculation 测试成功率计算逻辑
|
||||
func TestAliveStats_SuccessRateCalculation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
totalHosts int
|
||||
aliveHosts int
|
||||
expectedRate float64
|
||||
}{
|
||||
{"全部存活", 10, 10, 100.0},
|
||||
{"一半存活", 10, 5, 50.0},
|
||||
{"无存活", 10, 0, 0.0},
|
||||
{"单主机存活", 1, 1, 100.0},
|
||||
{"单主机死亡", 1, 0, 0.0},
|
||||
{"三分之一存活", 3, 1, 100.0 / 3.0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 模拟统计计算逻辑(来自 alive_scanner.go:108-110)
|
||||
var successRate float64
|
||||
if tt.totalHosts > 0 {
|
||||
successRate = float64(tt.aliveHosts) / float64(tt.totalHosts) * 100
|
||||
}
|
||||
|
||||
// 浮点数比较使用小容忍度
|
||||
const epsilon = 1e-9
|
||||
diff := successRate - tt.expectedRate
|
||||
if diff < -epsilon || diff > epsilon {
|
||||
t.Errorf("成功率计算错误: 期望 %.10f%%, 实际 %.10f%%, 差值 %.10f",
|
||||
tt.expectedRate, successRate, diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAliveStats_DeadHostsCalculation 测试死亡主机数计算
|
||||
func TestAliveStats_DeadHostsCalculation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
totalHosts int
|
||||
aliveHosts int
|
||||
expectedDead int
|
||||
}{
|
||||
{"全部存活", 10, 10, 0},
|
||||
{"一半存活", 10, 5, 5},
|
||||
{"全部死亡", 10, 0, 10},
|
||||
{"单主机", 1, 0, 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 模拟死亡主机计算逻辑(来自 alive_scanner.go:104)
|
||||
deadHosts := tt.totalHosts - tt.aliveHosts
|
||||
|
||||
if deadHosts != tt.expectedDead {
|
||||
t.Errorf("死亡主机数错误: 期望 %d, 实际 %d",
|
||||
tt.expectedDead, deadHosts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/common/i18n"
|
||||
"github.com/shadow1ng/fscan/plugins"
|
||||
)
|
||||
|
||||
// PluginFilterType 插件过滤类型
|
||||
type PluginFilterType int
|
||||
|
||||
const (
|
||||
// FilterNone 不过滤
|
||||
FilterNone PluginFilterType = iota
|
||||
// FilterLocal 仅本地插件
|
||||
FilterLocal
|
||||
// FilterService 仅服务插件(排除本地)
|
||||
FilterService
|
||||
// FilterWeb 仅Web插件
|
||||
FilterWeb
|
||||
)
|
||||
|
||||
// BaseScanStrategy 扫描策略基础类
|
||||
type BaseScanStrategy struct {
|
||||
strategyName string
|
||||
filterType PluginFilterType
|
||||
}
|
||||
|
||||
// NewBaseScanStrategy 创建基础扫描策略
|
||||
func NewBaseScanStrategy(name string, filterType PluginFilterType) *BaseScanStrategy {
|
||||
return &BaseScanStrategy{
|
||||
strategyName: name,
|
||||
filterType: filterType,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPlugins 获取插件列表
|
||||
func (b *BaseScanStrategy) GetPlugins(config *common.Config) ([]string, bool) {
|
||||
scanMode := config.Mode
|
||||
// 如果指定了特定插件且不是"all"
|
||||
if scanMode != "" && scanMode != "all" {
|
||||
requestedPlugins := parsePluginList(scanMode)
|
||||
if len(requestedPlugins) == 0 {
|
||||
requestedPlugins = []string{scanMode}
|
||||
}
|
||||
|
||||
// 验证插件是否存在
|
||||
var validPlugins []string
|
||||
var missingPlugins []string
|
||||
for _, name := range requestedPlugins {
|
||||
if b.pluginExists(name) {
|
||||
validPlugins = append(validPlugins, name)
|
||||
} else {
|
||||
missingPlugins = append(missingPlugins, name)
|
||||
}
|
||||
}
|
||||
|
||||
// 警告用户显式指定的插件不存在
|
||||
// 注意:使用fmt.Fprintf直接输出到stderr,确保错误消息不会被日志级别过滤
|
||||
for _, name := range missingPlugins {
|
||||
errMsg := i18n.Tr("scan_plugin_not_found", name)
|
||||
fmt.Fprintf(os.Stderr, "[ERROR] %s\n", errMsg)
|
||||
}
|
||||
|
||||
return validPlugins, true
|
||||
}
|
||||
|
||||
// 未指定或使用"all":根据策略类型获取对应插件
|
||||
return b.getPluginsByFilterType(), false
|
||||
}
|
||||
|
||||
// IsPluginApplicableByName 根据插件名称判断是否适用
|
||||
func (b *BaseScanStrategy) IsPluginApplicableByName(pluginName string, targetHost string, targetPort int, isCustomMode bool, config *common.Config) bool {
|
||||
// 首先检查插件是否存在
|
||||
if !b.pluginExists(pluginName) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查端口匹配和过滤器类型
|
||||
return b.isPluginApplicableToPortWithHost(pluginName, targetHost, targetPort) && b.isPluginPassesFilterType(pluginName, isCustomMode, config)
|
||||
}
|
||||
|
||||
func (b *BaseScanStrategy) pluginExists(pluginName string) bool {
|
||||
return plugins.Exists(pluginName)
|
||||
}
|
||||
|
||||
func (b *BaseScanStrategy) getPluginPorts(pluginName string) []int {
|
||||
return plugins.GetPluginPorts(pluginName)
|
||||
}
|
||||
|
||||
func (b *BaseScanStrategy) isWebPlugin(pluginName string) bool {
|
||||
return plugins.HasType(pluginName, plugins.PluginTypeWeb)
|
||||
}
|
||||
|
||||
func (b *BaseScanStrategy) isLocalPlugin(pluginName string) bool {
|
||||
return plugins.HasType(pluginName, plugins.PluginTypeLocal)
|
||||
}
|
||||
|
||||
func (b *BaseScanStrategy) isLocalPluginExplicitlySpecified(pluginName string, config *common.Config) bool {
|
||||
return config.LocalPlugin == pluginName
|
||||
}
|
||||
|
||||
// isPluginApplicableToPortWithHost 检查插件是否适用于指定端口
|
||||
func (b *BaseScanStrategy) isPluginApplicableToPortWithHost(pluginName string, targetHost string, targetPort int) bool {
|
||||
if b.isWebPlugin(pluginName) {
|
||||
return IsMarkedWebService(targetHost, targetPort)
|
||||
}
|
||||
|
||||
pluginPorts := b.getPluginPorts(pluginName)
|
||||
|
||||
// 无端口限制的插件适用于所有端口
|
||||
if len(pluginPorts) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// 有端口限制的插件:检查端口匹配
|
||||
if targetPort > 0 {
|
||||
for _, port := range pluginPorts {
|
||||
if port == targetPort {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (b *BaseScanStrategy) isPluginApplicableToPort(pluginName string, targetPort int) bool {
|
||||
return b.isPluginApplicableToPortWithHost(pluginName, "", targetPort)
|
||||
}
|
||||
|
||||
// isPluginPassesFilterType 检查插件是否通过过滤器类型检查
|
||||
func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomMode bool, config *common.Config) bool {
|
||||
// 自定义模式下强制运行所有明确指定的插件
|
||||
if isCustomMode {
|
||||
return true
|
||||
}
|
||||
|
||||
// 应用过滤器类型检查
|
||||
switch b.filterType {
|
||||
case FilterLocal:
|
||||
// 本地扫描策略:只允许本地插件且必须通过-local参数明确指定
|
||||
if b.isLocalPlugin(pluginName) {
|
||||
return b.isLocalPluginExplicitlySpecified(pluginName, config)
|
||||
}
|
||||
return false
|
||||
case FilterService:
|
||||
// 服务扫描策略:排除本地插件
|
||||
return !b.isLocalPlugin(pluginName)
|
||||
case FilterWeb:
|
||||
// Web扫描策略:只允许Web插件
|
||||
return b.isWebPlugin(pluginName)
|
||||
default:
|
||||
// 无过滤器:本地插件需要明确指定,其他插件都允许
|
||||
if b.isLocalPlugin(pluginName) {
|
||||
return b.isLocalPluginExplicitlySpecified(pluginName, config)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// LogPluginInfo 输出插件信息
|
||||
func (b *BaseScanStrategy) LogPluginInfo(config *common.Config) {
|
||||
allPlugins, isCustomMode := b.GetPlugins(config)
|
||||
|
||||
var prefix string
|
||||
switch b.filterType {
|
||||
case FilterLocal:
|
||||
prefix = i18n.GetText("concurrency_local_plugin")
|
||||
case FilterService:
|
||||
prefix = i18n.GetText("concurrency_service_plugin")
|
||||
case FilterWeb:
|
||||
prefix = i18n.GetText("concurrency_web_plugin")
|
||||
default:
|
||||
prefix = i18n.GetText("concurrency_plugin")
|
||||
}
|
||||
|
||||
// 插件信息不再输出,减少干扰
|
||||
_ = allPlugins
|
||||
_ = isCustomMode
|
||||
_ = prefix
|
||||
}
|
||||
|
||||
// formatPluginList 格式化插件列表(超过5个时精简显示)
|
||||
func formatPluginList(plugins []string) string {
|
||||
if len(plugins) <= 5 {
|
||||
return strings.Join(plugins, ", ")
|
||||
}
|
||||
return fmt.Sprintf("%s ... 等%d个", strings.Join(plugins[:5], ", "), len(plugins))
|
||||
}
|
||||
|
||||
// ValidateConfiguration 验证扫描配置
|
||||
func (b *BaseScanStrategy) ValidateConfiguration() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// LogScanStart 输出扫描开始信息(已精简,仅在非服务扫描模式下显示)
|
||||
func (b *BaseScanStrategy) LogScanStart() {
|
||||
// 服务扫描模式下不显示(插件信息已足够说明)
|
||||
// 仅在本地/Web等特殊模式下显示
|
||||
switch b.filterType {
|
||||
case FilterLocal:
|
||||
common.LogInfo(i18n.GetText("start_local_scan"))
|
||||
case FilterWeb:
|
||||
common.LogInfo(i18n.GetText("start_web_scan"))
|
||||
}
|
||||
}
|
||||
|
||||
// getPluginsByFilterType 根据过滤器类型获取插件列表
|
||||
func (b *BaseScanStrategy) getPluginsByFilterType() []string {
|
||||
allPlugins := plugins.All()
|
||||
var filteredPlugins []string
|
||||
|
||||
switch b.filterType {
|
||||
case FilterLocal:
|
||||
// 本地扫描策略:只返回本地插件
|
||||
for _, pluginName := range allPlugins {
|
||||
if b.isLocalPlugin(pluginName) {
|
||||
filteredPlugins = append(filteredPlugins, pluginName)
|
||||
}
|
||||
}
|
||||
case FilterService:
|
||||
// 服务扫描策略:排除本地插件和纯Web插件,保留服务插件
|
||||
for _, pluginName := range allPlugins {
|
||||
if !b.isLocalPlugin(pluginName) {
|
||||
filteredPlugins = append(filteredPlugins, pluginName)
|
||||
}
|
||||
}
|
||||
case FilterWeb:
|
||||
// Web扫描策略:只返回Web插件
|
||||
for _, pluginName := range allPlugins {
|
||||
if b.isWebPlugin(pluginName) {
|
||||
filteredPlugins = append(filteredPlugins, pluginName)
|
||||
}
|
||||
}
|
||||
// 确保 webtitle 在 webpoc 之前执行,避免指纹识别竞态
|
||||
sort.Slice(filteredPlugins, func(i, j int) bool {
|
||||
// webtitle 必须在 webpoc 之前
|
||||
if filteredPlugins[i] == "webtitle" {
|
||||
return true
|
||||
}
|
||||
if filteredPlugins[j] == "webtitle" {
|
||||
return false
|
||||
}
|
||||
if filteredPlugins[i] == "webpoc" {
|
||||
return false
|
||||
}
|
||||
if filteredPlugins[j] == "webpoc" {
|
||||
return true
|
||||
}
|
||||
// 其他插件保持字母顺序
|
||||
return filteredPlugins[i] < filteredPlugins[j]
|
||||
})
|
||||
default:
|
||||
// 无过滤器:返回所有插件
|
||||
filteredPlugins = allPlugins
|
||||
}
|
||||
|
||||
return filteredPlugins
|
||||
}
|
||||
|
||||
// parsePluginList 解析插件列表字符串
|
||||
func parsePluginList(pluginStr string) []string {
|
||||
if pluginStr == "" {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
// 支持逗号分隔的插件列表
|
||||
plugins := strings.Split(pluginStr, ",")
|
||||
result := []string{} // 初始化为空切片而非nil
|
||||
for _, plugin := range plugins {
|
||||
plugin = strings.TrimSpace(plugin)
|
||||
if plugin != "" {
|
||||
result = append(result, plugin)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// 插件列表解析测试
|
||||
// =============================================================================
|
||||
|
||||
/*
|
||||
插件列表解析 - parsePluginList 函数测试
|
||||
|
||||
测试价值:用户输入解析是扫描器的入口,解析错误会导致用户指定的插件无法执行
|
||||
|
||||
"字符串解析看起来简单,但边界情况会咬你一口。空格、空字符串、
|
||||
逗号分隔符——这些是真实的bug来源。必须测试。"
|
||||
*/
|
||||
|
||||
// TestParsePluginList_BasicCases 测试基本的插件列表解析
|
||||
func TestParsePluginList_BasicCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "单个插件",
|
||||
input: "ssh",
|
||||
expected: []string{"ssh"},
|
||||
},
|
||||
{
|
||||
name: "两个插件-逗号分隔",
|
||||
input: "ssh,redis",
|
||||
expected: []string{"ssh", "redis"},
|
||||
},
|
||||
{
|
||||
name: "多个插件-逗号分隔",
|
||||
input: "ssh,redis,mysql,mssql",
|
||||
expected: []string{"ssh", "redis", "mysql", "mssql"},
|
||||
},
|
||||
{
|
||||
name: "空字符串",
|
||||
input: "",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "单个逗号",
|
||||
input: ",",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "多个逗号",
|
||||
input: ",,,",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parsePluginList(tt.input)
|
||||
if !slicesEqual(result, tt.expected) {
|
||||
t.Errorf("parsePluginList(%q) = %v, want %v",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePluginList_Whitespace 测试空格处理
|
||||
func TestParsePluginList_Whitespace(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "插件名前后有空格",
|
||||
input: " ssh ",
|
||||
expected: []string{"ssh"},
|
||||
},
|
||||
{
|
||||
name: "逗号前后有空格",
|
||||
input: "ssh , redis",
|
||||
expected: []string{"ssh", "redis"},
|
||||
},
|
||||
{
|
||||
name: "多个空格",
|
||||
input: " ssh , redis ",
|
||||
expected: []string{"ssh", "redis"},
|
||||
},
|
||||
{
|
||||
name: "Tab字符",
|
||||
input: "ssh\t,\tredis",
|
||||
expected: []string{"ssh", "redis"},
|
||||
},
|
||||
{
|
||||
name: "混合空白字符",
|
||||
input: " \tssh\t , \tredis \t",
|
||||
expected: []string{"ssh", "redis"},
|
||||
},
|
||||
{
|
||||
name: "只有空格",
|
||||
input: " ",
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "空格和逗号混合",
|
||||
input: " , , , ",
|
||||
expected: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parsePluginList(tt.input)
|
||||
if !slicesEqual(result, tt.expected) {
|
||||
t.Errorf("parsePluginList(%q) = %v, want %v",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePluginList_EdgeCases 测试边界情况
|
||||
func TestParsePluginList_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "连续逗号",
|
||||
input: "ssh,,redis",
|
||||
expected: []string{"ssh", "redis"},
|
||||
},
|
||||
{
|
||||
name: "开头有逗号",
|
||||
input: ",ssh,redis",
|
||||
expected: []string{"ssh", "redis"},
|
||||
},
|
||||
{
|
||||
name: "结尾有逗号",
|
||||
input: "ssh,redis,",
|
||||
expected: []string{"ssh", "redis"},
|
||||
},
|
||||
{
|
||||
name: "开头结尾都有逗号",
|
||||
input: ",ssh,redis,",
|
||||
expected: []string{"ssh", "redis"},
|
||||
},
|
||||
{
|
||||
name: "空元素混合",
|
||||
input: "ssh, ,redis, , ,mysql",
|
||||
expected: []string{"ssh", "redis", "mysql"},
|
||||
},
|
||||
{
|
||||
name: "单字符插件名",
|
||||
input: "a,b,c",
|
||||
expected: []string{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "长插件名",
|
||||
input: "verylongpluginname1,verylongpluginname2",
|
||||
expected: []string{"verylongpluginname1", "verylongpluginname2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := parsePluginList(tt.input)
|
||||
if !slicesEqual(result, tt.expected) {
|
||||
t.Errorf("parsePluginList(%q) = %v, want %v",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePluginList_ProductionScenarios 测试生产环境真实场景
|
||||
func TestParsePluginList_ProductionScenarios(t *testing.T) {
|
||||
t.Run("用户复制粘贴带空格", func(t *testing.T) {
|
||||
// 用户从文档复制 "ssh, redis, mysql" 粘贴到命令行
|
||||
input := "ssh, redis, mysql"
|
||||
expected := []string{"ssh", "redis", "mysql"}
|
||||
result := parsePluginList(input)
|
||||
if !slicesEqual(result, expected) {
|
||||
t.Errorf("应该正确处理用户复制粘贴的空格")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("用户手误多打逗号", func(t *testing.T) {
|
||||
// 用户打错了:"ssh,,redis"
|
||||
input := "ssh,,redis"
|
||||
expected := []string{"ssh", "redis"}
|
||||
result := parsePluginList(input)
|
||||
if !slicesEqual(result, expected) {
|
||||
t.Errorf("应该容错处理连续逗号")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("常见的all模式", func(t *testing.T) {
|
||||
// 虽然 "all" 在上层处理,但解析器也要能处理
|
||||
input := "all"
|
||||
expected := []string{"all"}
|
||||
result := parsePluginList(input)
|
||||
if !slicesEqual(result, expected) {
|
||||
t.Errorf("应该正确解析 'all' 关键字")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("混合大小写插件名", func(t *testing.T) {
|
||||
// Go插件名通常小写,但用户可能输入大写
|
||||
input := "SSH,Redis,MySQL"
|
||||
expected := []string{"SSH", "Redis", "MySQL"}
|
||||
result := parsePluginList(input)
|
||||
// 注意:当前实现不做大小写转换,保留原始输入
|
||||
if !slicesEqual(result, expected) {
|
||||
t.Errorf("应该保留原始大小写(交给上层验证)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestParsePluginList_ReturnValue 测试返回值特性
|
||||
func TestParsePluginList_ReturnValue(t *testing.T) {
|
||||
t.Run("返回空切片而非nil", func(t *testing.T) {
|
||||
result := parsePluginList("")
|
||||
if result == nil {
|
||||
t.Error("空输入应该返回空切片,而不是nil")
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("空输入应该返回长度为0的切片,got length %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("返回新切片-不共享内存", func(t *testing.T) {
|
||||
input := "ssh,redis"
|
||||
result1 := parsePluginList(input)
|
||||
result2 := parsePluginList(input)
|
||||
|
||||
// 修改result1不应该影响result2
|
||||
if len(result1) > 0 {
|
||||
result1[0] = "modified"
|
||||
if result2[0] == "modified" {
|
||||
t.Error("每次调用应该返回新的切片,不共享内存")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// slicesEqual 比较两个字符串切片是否相等
|
||||
func slicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestNewBaseScanStrategy 测试构造函数
|
||||
func TestNewBaseScanStrategy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
strategyName string
|
||||
filterType PluginFilterType
|
||||
}{
|
||||
{
|
||||
name: "FilterNone",
|
||||
strategyName: "无过滤",
|
||||
filterType: FilterNone,
|
||||
},
|
||||
{
|
||||
name: "FilterLocal",
|
||||
strategyName: "本地扫描",
|
||||
filterType: FilterLocal,
|
||||
},
|
||||
{
|
||||
name: "FilterService",
|
||||
strategyName: "服务扫描",
|
||||
filterType: FilterService,
|
||||
},
|
||||
{
|
||||
name: "FilterWeb",
|
||||
strategyName: "Web扫描",
|
||||
filterType: FilterWeb,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
strategy := NewBaseScanStrategy(tt.strategyName, tt.filterType)
|
||||
|
||||
if strategy == nil {
|
||||
t.Fatal("NewBaseScanStrategy 返回 nil")
|
||||
}
|
||||
|
||||
if strategy.strategyName != tt.strategyName {
|
||||
t.Errorf("strategyName: 期望 %q, 实际 %q", tt.strategyName, strategy.strategyName)
|
||||
}
|
||||
|
||||
if strategy.filterType != tt.filterType {
|
||||
t.Errorf("filterType: 期望 %d, 实际 %d", tt.filterType, strategy.filterType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginFilterTypeConstants 测试过滤器类型常量
|
||||
func TestPluginFilterTypeConstants(t *testing.T) {
|
||||
// 验证常量值的唯一性和连续性
|
||||
filterTypes := []PluginFilterType{
|
||||
FilterNone,
|
||||
FilterLocal,
|
||||
FilterService,
|
||||
FilterWeb,
|
||||
}
|
||||
|
||||
// 检查值是否唯一
|
||||
seen := make(map[PluginFilterType]bool)
|
||||
for _, ft := range filterTypes {
|
||||
if seen[ft] {
|
||||
t.Errorf("PluginFilterType 值重复: %d", ft)
|
||||
}
|
||||
seen[ft] = true
|
||||
}
|
||||
|
||||
// 验证预期值
|
||||
expectedValues := map[PluginFilterType]int{
|
||||
FilterNone: 0,
|
||||
FilterLocal: 1,
|
||||
FilterService: 2,
|
||||
FilterWeb: 3,
|
||||
}
|
||||
|
||||
for ft, expectedVal := range expectedValues {
|
||||
if int(ft) != expectedVal {
|
||||
t.Errorf("PluginFilterType %d: 期望值 %d, 实际值 %d", ft, expectedVal, int(ft))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBaseScanStrategy_ValidateConfiguration 测试配置验证
|
||||
func TestBaseScanStrategy_ValidateConfiguration(t *testing.T) {
|
||||
strategy := NewBaseScanStrategy("测试", FilterNone)
|
||||
|
||||
err := strategy.ValidateConfiguration()
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfiguration 应返回 nil, 实际: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"hash/fnv"
|
||||
)
|
||||
|
||||
// BloomFilter 布隆过滤器,用于ICMP包去重
|
||||
type BloomFilter struct {
|
||||
bits []bool
|
||||
size uint32
|
||||
k uint32 // hash函数数量
|
||||
}
|
||||
|
||||
// NewBloomFilter 创建布隆过滤器
|
||||
// size: 预期元素数量
|
||||
// falsePositiveRate: 期望的误判率(通常0.01即1%)
|
||||
func NewBloomFilter(size int, falsePositiveRate float64) *BloomFilter {
|
||||
// 计算最优bit数组大小: m = -n*ln(p) / (ln(2)^2)
|
||||
// 简化计算:m ≈ n * 10 for p=0.01
|
||||
m := uint32(size * 10)
|
||||
if m < 1024 {
|
||||
m = 1024 // 最小1KB
|
||||
}
|
||||
|
||||
// 计算最优hash函数数量: k = (m/n) * ln(2)
|
||||
// 简化:k ≈ 7 for p=0.01
|
||||
k := uint32(7)
|
||||
|
||||
return &BloomFilter{
|
||||
bits: make([]bool, m),
|
||||
size: m,
|
||||
k: k,
|
||||
}
|
||||
}
|
||||
|
||||
// Add 添加元素到过滤器
|
||||
func (bf *BloomFilter) Add(data string) {
|
||||
for i := uint32(0); i < bf.k; i++ {
|
||||
pos := bf.hash(data, i)
|
||||
bf.bits[pos] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Contains 检查元素是否可能存在
|
||||
// 返回true:可能存在(有误判可能)
|
||||
// 返回false:一定不存在
|
||||
func (bf *BloomFilter) Contains(data string) bool {
|
||||
for i := uint32(0); i < bf.k; i++ {
|
||||
pos := bf.hash(data, i)
|
||||
if !bf.bits[pos] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// hash 计算hash值
|
||||
func (bf *BloomFilter) hash(data string, seed uint32) uint32 {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(data))
|
||||
// 添加seed实现多个hash函数
|
||||
for i := uint32(0); i < seed; i++ {
|
||||
_, _ = h.Write([]byte{byte(i)})
|
||||
}
|
||||
return h.Sum32() % bf.size
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/*
|
||||
bloom_filter_test.go - BloomFilter 高价值测试
|
||||
|
||||
测试重点:
|
||||
1. 基本正确性 - Add后Contains返回true,未添加的返回false
|
||||
2. 误判率验证 - 实际误判率应接近理论值(1%)
|
||||
3. 大规模数据 - 模拟真实ICMP去重场景
|
||||
|
||||
不测试:
|
||||
- 内部哈希实现细节
|
||||
- 精确的数学公式验证
|
||||
*/
|
||||
|
||||
// TestBloomFilter_BasicCorrectness 基本正确性测试
|
||||
func TestBloomFilter_BasicCorrectness(t *testing.T) {
|
||||
bf := NewBloomFilter(1000, 0.01)
|
||||
|
||||
// 添加元素后应该能找到
|
||||
testData := []string{
|
||||
"192.168.1.1",
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
}
|
||||
|
||||
for _, data := range testData {
|
||||
bf.Add(data)
|
||||
}
|
||||
|
||||
for _, data := range testData {
|
||||
if !bf.Contains(data) {
|
||||
t.Errorf("已添加的元素 %s 应该返回 true", data)
|
||||
}
|
||||
}
|
||||
|
||||
// 未添加的元素(大概率)返回false
|
||||
notAdded := []string{
|
||||
"8.8.8.8",
|
||||
"1.1.1.1",
|
||||
"255.255.255.255",
|
||||
}
|
||||
|
||||
falsePositives := 0
|
||||
for _, data := range notAdded {
|
||||
if bf.Contains(data) {
|
||||
falsePositives++
|
||||
}
|
||||
}
|
||||
|
||||
// 3个未添加元素全部误判的概率极低(<0.0001%)
|
||||
if falsePositives == len(notAdded) {
|
||||
t.Error("所有未添加元素都返回true,布隆过滤器可能有问题")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBloomFilter_FalsePositiveRate 误判率验证
|
||||
//
|
||||
// 对于 n=10000, p=0.01 的布隆过滤器:
|
||||
// 实际误判率应该在 0.5% - 2% 之间(允许统计波动)
|
||||
func TestBloomFilter_FalsePositiveRate(t *testing.T) {
|
||||
n := 10000 // 添加的元素数
|
||||
bf := NewBloomFilter(n, 0.01)
|
||||
|
||||
// 添加n个元素
|
||||
for i := 0; i < n; i++ {
|
||||
bf.Add(fmt.Sprintf("added_%d", i))
|
||||
}
|
||||
|
||||
// 测试n个未添加的元素
|
||||
falsePositives := 0
|
||||
testCount := n
|
||||
for i := 0; i < testCount; i++ {
|
||||
if bf.Contains(fmt.Sprintf("not_added_%d", i)) {
|
||||
falsePositives++
|
||||
}
|
||||
}
|
||||
|
||||
actualRate := float64(falsePositives) / float64(testCount)
|
||||
|
||||
// 允许的误判率范围:0.1% - 3%(考虑统计波动)
|
||||
if actualRate > 0.03 {
|
||||
t.Errorf("误判率过高: %.2f%% (期望 < 3%%)", actualRate*100)
|
||||
}
|
||||
|
||||
t.Logf("实际误判率: %.2f%% (%d/%d)", actualRate*100, falsePositives, testCount)
|
||||
}
|
||||
|
||||
// TestBloomFilter_LargeScale 大规模数据测试
|
||||
//
|
||||
// 模拟真实的ICMP去重场景:100万个IP地址
|
||||
func TestBloomFilter_LargeScale(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("跳过大规模测试")
|
||||
}
|
||||
|
||||
n := 1000000 // 100万
|
||||
bf := NewBloomFilter(n, 0.01)
|
||||
|
||||
// 添加100万个元素
|
||||
for i := 0; i < n; i++ {
|
||||
bf.Add(fmt.Sprintf("192.168.%d.%d", i/256, i%256))
|
||||
}
|
||||
|
||||
// 验证已添加的元素
|
||||
sampleSize := 1000
|
||||
for i := 0; i < sampleSize; i++ {
|
||||
idx := i * (n / sampleSize)
|
||||
data := fmt.Sprintf("192.168.%d.%d", idx/256, idx%256)
|
||||
if !bf.Contains(data) {
|
||||
t.Errorf("已添加的元素 %s 返回 false", data)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试未添加元素的误判率
|
||||
falsePositives := 0
|
||||
for i := 0; i < sampleSize; i++ {
|
||||
if bf.Contains(fmt.Sprintf("10.%d.%d.%d", i/65536, (i/256)%256, i%256)) {
|
||||
falsePositives++
|
||||
}
|
||||
}
|
||||
|
||||
actualRate := float64(falsePositives) / float64(sampleSize)
|
||||
if actualRate > 0.03 {
|
||||
t.Errorf("大规模场景误判率过高: %.2f%%", actualRate*100)
|
||||
}
|
||||
|
||||
t.Logf("100万元素场景误判率: %.2f%%", actualRate*100)
|
||||
}
|
||||
|
||||
// TestBloomFilter_NoFalseNegative 验证无假阴性
|
||||
//
|
||||
// 布隆过滤器的核心保证:已添加的元素必定返回true
|
||||
func TestBloomFilter_NoFalseNegative(t *testing.T) {
|
||||
bf := NewBloomFilter(10000, 0.01)
|
||||
|
||||
// 添加5000个元素
|
||||
added := make([]string, 5000)
|
||||
for i := range added {
|
||||
added[i] = fmt.Sprintf("element_%d", i)
|
||||
bf.Add(added[i])
|
||||
}
|
||||
|
||||
// 全部验证
|
||||
for _, data := range added {
|
||||
if !bf.Contains(data) {
|
||||
t.Fatalf("假阴性!已添加的元素 %s 返回 false", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBloomFilter_EmptyFilter 空过滤器测试
|
||||
func TestBloomFilter_EmptyFilter(t *testing.T) {
|
||||
bf := NewBloomFilter(100, 0.01)
|
||||
|
||||
// 空过滤器应该对任何查询返回false
|
||||
testCases := []string{"anything", "192.168.1.1", ""}
|
||||
for _, tc := range testCases {
|
||||
if bf.Contains(tc) {
|
||||
t.Errorf("空过滤器对 %q 返回 true", tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
+767
@@ -0,0 +1,767 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/common/i18n"
|
||||
"github.com/shadow1ng/fscan/common/output"
|
||||
"golang.org/x/net/icmp"
|
||||
)
|
||||
|
||||
// pingForbiddenChars 命令注入防护 - 禁止的字符
|
||||
var pingForbiddenChars = []string{";", "&", "|", "`", "$", "\\", "'", "%", "\"", "\n"}
|
||||
|
||||
// pingErrorKeywords ping 失败的关键词(跨平台)
|
||||
var pingErrorKeywords = []string{
|
||||
// Windows
|
||||
"TTL expired",
|
||||
"Destination host unreachable",
|
||||
"Destination net unreachable",
|
||||
"Request timed out",
|
||||
"General failure",
|
||||
"transmit failed",
|
||||
// Linux/macOS
|
||||
"Time to live exceeded",
|
||||
"100% packet loss",
|
||||
"Network is unreachable",
|
||||
"No route to host",
|
||||
}
|
||||
|
||||
// CheckLive 检测主机存活状态
|
||||
// 支持 ICMP/Ping 探测,并在响应率过低时自动启用 TCP 补充探测
|
||||
func CheckLive(hostslist []string, Ping bool, config *common.Config, state *common.State) []string {
|
||||
// 创建局部WaitGroup
|
||||
var livewg sync.WaitGroup
|
||||
|
||||
// 创建局部存活主机列表,预分配容量避免频繁扩容
|
||||
aliveHosts := make([]string, 0, len(hostslist))
|
||||
var aliveHostsMu sync.Mutex // 保护aliveHosts并发访问
|
||||
existHosts := make(map[string]struct{}, len(hostslist))
|
||||
|
||||
// 创建主机通道
|
||||
chanHosts := make(chan string, len(hostslist))
|
||||
|
||||
// 处理存活主机
|
||||
go handleAliveHosts(chanHosts, hostslist, Ping, &aliveHosts, &aliveHostsMu, existHosts, config, &livewg)
|
||||
|
||||
// 根据Ping参数选择检测方式
|
||||
if Ping {
|
||||
// 使用ping方式探测
|
||||
RunPing(hostslist, chanHosts, &livewg)
|
||||
} else {
|
||||
probeWithICMP(hostslist, chanHosts, &aliveHosts, &aliveHostsMu, config, state, &livewg)
|
||||
}
|
||||
|
||||
// 等待所有检测完成
|
||||
livewg.Wait()
|
||||
close(chanHosts)
|
||||
|
||||
// TCP 补充探测:当 ICMP/Ping 响应率过低时自动启用
|
||||
// 这对防火墙过滤 ICMP 的环境特别有用
|
||||
aliveHosts = tcpSupplementaryProbe(hostslist, aliveHosts, config)
|
||||
|
||||
// 输出存活统计信息
|
||||
printAliveStats(aliveHosts, hostslist)
|
||||
|
||||
return aliveHosts
|
||||
}
|
||||
|
||||
// tcpSupplementaryProbe TCP 补充探测
|
||||
// 当 ICMP 响应率过低时(<10%),对未响应主机进行 TCP 探测
|
||||
func tcpSupplementaryProbe(allHosts []string, aliveHosts []string, config *common.Config) []string {
|
||||
totalHosts := len(allHosts)
|
||||
if totalHosts == 0 {
|
||||
return aliveHosts
|
||||
}
|
||||
|
||||
// 计算 ICMP 响应率
|
||||
responseRate := float64(len(aliveHosts)) / float64(totalHosts)
|
||||
|
||||
// 响应率高于阈值,无需补充探测
|
||||
if responseRate >= tcpProbeThreshold {
|
||||
return aliveHosts
|
||||
}
|
||||
|
||||
// 获取未响应的主机
|
||||
unrespondedHosts := getUnrespondedHosts(allHosts, aliveHosts)
|
||||
if len(unrespondedHosts) == 0 {
|
||||
return aliveHosts
|
||||
}
|
||||
|
||||
// 提示用户正在进行 TCP 补充探测
|
||||
common.LogInfo(i18n.Tr("tcp_probe_low_icmp_rate", fmt.Sprintf("%.1f%%", responseRate*100), len(unrespondedHosts)))
|
||||
|
||||
// 执行 TCP 补充探测
|
||||
tcpAliveHosts := runTcpProbeForHosts(unrespondedHosts, config)
|
||||
|
||||
// 合并结果
|
||||
if len(tcpAliveHosts) > 0 {
|
||||
aliveHosts = append(aliveHosts, tcpAliveHosts...)
|
||||
common.LogInfo(i18n.Tr("tcp_probe_found", len(tcpAliveHosts)))
|
||||
}
|
||||
|
||||
return aliveHosts
|
||||
}
|
||||
|
||||
// IsContain 检查切片中是否包含指定元素
|
||||
func IsContain(items []string, item string) bool {
|
||||
for _, eachItem := range items {
|
||||
if eachItem == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, aliveHosts *[]string, aliveHostsMu *sync.Mutex, existHosts map[string]struct{}, config *common.Config, livewg *sync.WaitGroup) {
|
||||
for ip := range chanHosts {
|
||||
if _, ok := existHosts[ip]; !ok && IsContain(hostslist, ip) {
|
||||
existHosts[ip] = struct{}{}
|
||||
|
||||
// 加锁保护aliveHosts并发写入
|
||||
aliveHostsMu.Lock()
|
||||
*aliveHosts = append(*aliveHosts, ip)
|
||||
aliveHostsMu.Unlock()
|
||||
|
||||
// 使用Output系统保存存活主机信息
|
||||
protocol := "ICMP"
|
||||
if isPing {
|
||||
protocol = "PING"
|
||||
}
|
||||
|
||||
result := &output.ScanResult{
|
||||
Time: time.Now(),
|
||||
Type: output.TypeHost,
|
||||
Target: ip,
|
||||
Status: "alive",
|
||||
Details: map[string]interface{}{
|
||||
"protocol": protocol,
|
||||
},
|
||||
}
|
||||
_ = common.SaveResult(result)
|
||||
|
||||
// 保留原有的控制台输出
|
||||
if !config.Output.Silent {
|
||||
common.LogInfo(i18n.Tr("host_alive", ip, protocol))
|
||||
}
|
||||
}
|
||||
livewg.Done()
|
||||
}
|
||||
}
|
||||
|
||||
// probeWithICMP 使用ICMP方式探测
|
||||
func probeWithICMP(hostslist []string, chanHosts chan string, aliveHosts *[]string, aliveHostsMu *sync.Mutex, config *common.Config, state *common.State, livewg *sync.WaitGroup) {
|
||||
// 代理模式下自动禁用ICMP,直接降级为Ping
|
||||
// ICMP在代理环境无法正常工作
|
||||
if shouldDisableICMP() {
|
||||
if !config.Output.Silent {
|
||||
common.LogInfo(i18n.GetText("proxy_mode_disable_icmp"))
|
||||
}
|
||||
RunPing(hostslist, chanHosts, livewg)
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试监听本地ICMP
|
||||
conn, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
|
||||
if err == nil {
|
||||
RunIcmp1(hostslist, conn, chanHosts, aliveHosts, aliveHostsMu, config, state, livewg)
|
||||
return
|
||||
}
|
||||
|
||||
common.LogError(i18n.Tr("icmp_listen_failed", err))
|
||||
common.LogInfo(i18n.GetText("trying_no_listen_icmp"))
|
||||
|
||||
// 尝试无监听ICMP探测
|
||||
conn2, err := net.DialTimeout("ip4:icmp", "127.0.0.1", 3*time.Second)
|
||||
if err == nil {
|
||||
defer func() { _ = conn2.Close() }()
|
||||
RunIcmp2(hostslist, chanHosts, config, state, livewg)
|
||||
return
|
||||
}
|
||||
|
||||
common.LogError(i18n.Tr("icmp_connect_failed", err))
|
||||
common.LogError(i18n.GetText("insufficient_privileges"))
|
||||
common.LogInfo(i18n.GetText("switching_to_ping"))
|
||||
|
||||
// 降级使用ping探测
|
||||
RunPing(hostslist, chanHosts, livewg)
|
||||
}
|
||||
|
||||
// shouldDisableICMP 检查是否应该禁用ICMP
|
||||
// 这是一个内部辅助函数,用于检查代理状态
|
||||
func shouldDisableICMP() bool {
|
||||
// 尝试导入proxy包的状态检查(避免循环依赖)
|
||||
// 实际实现中会通过全局配置检查
|
||||
// 这里暂时返回false,实际集成时会正确处理
|
||||
return false
|
||||
}
|
||||
|
||||
// getOptimalTopCount 根据扫描规模智能决定显示数量
|
||||
func getOptimalTopCount(totalHosts int) int {
|
||||
switch {
|
||||
case totalHosts > 50000: // 超大规模扫描
|
||||
return 20
|
||||
case totalHosts > 10000: // 大规模扫描
|
||||
return 15
|
||||
case totalHosts > 1000: // 中等规模扫描
|
||||
return 10
|
||||
case totalHosts > 256: // 小规模扫描
|
||||
return 5
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
// printAliveStats 打印存活统计信息
|
||||
func printAliveStats(aliveHosts []string, hostslist []string) {
|
||||
// 智能计算显示数量
|
||||
topCount := getOptimalTopCount(len(hostslist))
|
||||
|
||||
// 大规模扫描时输出 /16 网段统计
|
||||
if len(hostslist) > 1000 {
|
||||
arrTop, arrLen := ArrayCountValueTop(aliveHosts, topCount, true)
|
||||
for i := 0; i < len(arrTop); i++ {
|
||||
common.LogInfo(i18n.Tr("segment_16_alive", arrTop[i], arrLen[i]))
|
||||
}
|
||||
}
|
||||
|
||||
// 输出 /24 网段统计
|
||||
if len(hostslist) > 256 {
|
||||
arrTop, arrLen := ArrayCountValueTop(aliveHosts, topCount, false)
|
||||
for i := 0; i < len(arrTop); i++ {
|
||||
common.LogInfo(i18n.Tr("segment_24_alive", arrTop[i], arrLen[i]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ICMP 自适应等待参数
|
||||
const (
|
||||
icmpCheckInterval = 100 * time.Millisecond // 检查间隔,避免 CPU 空转
|
||||
icmpMinWaitTime = 1 * time.Second // 最小等待时间,确保基础响应收集
|
||||
icmpStableThreshold = 500 * time.Millisecond // 无新响应稳定阈值,超过此时间无新响应则提前结束
|
||||
)
|
||||
|
||||
// waitAdaptive 自适应等待 ICMP 响应
|
||||
// 算法:监控响应增量,连续一段时间无新响应则提前结束
|
||||
// 保守原则:
|
||||
// - 必须等待最小时间 (1s),确保基础响应收集
|
||||
// - 只有"连续 500ms 无新响应"才提前结束
|
||||
// - 保留原有最大等待时间作为兜底
|
||||
func waitAdaptive(hostslist []string, aliveHosts *[]string, aliveHostsMu *sync.Mutex) {
|
||||
totalHosts := len(hostslist)
|
||||
|
||||
// 根据主机数量设置最大超时时间(保持原有逻辑作为兜底)
|
||||
maxWait := 6 * time.Second
|
||||
if totalHosts <= 256 {
|
||||
maxWait = 3 * time.Second
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
lastAliveCount := 0
|
||||
lastChangeTime := start
|
||||
|
||||
for {
|
||||
time.Sleep(icmpCheckInterval) // 避免 CPU 空转
|
||||
|
||||
// 读取当前存活数
|
||||
aliveHostsMu.Lock()
|
||||
aliveCount := len(*aliveHosts)
|
||||
aliveHostsMu.Unlock()
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 条件1:所有主机都已响应,立即结束
|
||||
if aliveCount >= totalHosts {
|
||||
common.LogDebug(fmt.Sprintf("[ICMP] 全部响应,耗时 %v", elapsed.Round(time.Millisecond)))
|
||||
break
|
||||
}
|
||||
|
||||
// 条件2:超过最大等待时间,兜底结束
|
||||
if elapsed >= maxWait {
|
||||
common.LogDebug(fmt.Sprintf("[ICMP] 达到最大等待时间 %v,存活 %d/%d", maxWait, aliveCount, totalHosts))
|
||||
break
|
||||
}
|
||||
|
||||
// 条件3:自适应提前结束
|
||||
// 必须满足:已过最小等待时间 + 连续一段时间没有新响应
|
||||
if elapsed >= icmpMinWaitTime {
|
||||
if aliveCount > lastAliveCount {
|
||||
// 有新响应,更新状态
|
||||
lastChangeTime = time.Now()
|
||||
lastAliveCount = aliveCount
|
||||
} else if time.Since(lastChangeTime) >= icmpStableThreshold {
|
||||
// 连续 500ms 没有新响应,认为响应已稳定,提前结束
|
||||
common.LogDebug(fmt.Sprintf("[ICMP] 响应稳定,提前结束,耗时 %v,存活 %d/%d",
|
||||
elapsed.Round(time.Millisecond), aliveCount, totalHosts))
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// 最小等待期内,持续更新状态
|
||||
if aliveCount > lastAliveCount {
|
||||
lastChangeTime = time.Now()
|
||||
lastAliveCount = aliveCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RunIcmp1 使用ICMP批量探测主机存活(监听模式)
|
||||
func RunIcmp1(hostslist []string, conn *icmp.PacketConn, chanHosts chan string, aliveHosts *[]string, aliveHostsMu *sync.Mutex, config *common.Config, state *common.State, livewg *sync.WaitGroup) {
|
||||
// 使用atomic.Bool保证并发安全
|
||||
var endflag atomic.Bool
|
||||
var listenerWg sync.WaitGroup
|
||||
|
||||
// 创建布隆过滤器用于去重(自动根据主机数量调整大小)
|
||||
bloomFilter := NewBloomFilter(len(hostslist), 0.01)
|
||||
|
||||
// 启动监听协程
|
||||
listenerWg.Add(1)
|
||||
go func() {
|
||||
defer listenerWg.Done()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
common.LogError(i18n.Tr("icmp_listener_panic", r))
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
if endflag.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
// 设置读取超时避免无限期阻塞
|
||||
_ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
||||
|
||||
// 接收ICMP响应
|
||||
msg := make([]byte, 100)
|
||||
_, sourceIP, err := conn.ReadFrom(msg)
|
||||
|
||||
if err != nil {
|
||||
// 超时错误正常,其他错误则退出
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if sourceIP != nil && !endflag.Load() {
|
||||
ipStr := sourceIP.String()
|
||||
|
||||
// 使用布隆过滤器去重,过滤重复的ICMP响应和杂包
|
||||
if bloomFilter.Contains(ipStr) {
|
||||
continue
|
||||
}
|
||||
bloomFilter.Add(ipStr)
|
||||
|
||||
livewg.Add(1)
|
||||
select {
|
||||
case chanHosts <- ipStr:
|
||||
// 发送成功
|
||||
default:
|
||||
// channel已满,回退计数器
|
||||
livewg.Done()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 发送ICMP请求(应用令牌桶限速)
|
||||
limiter := state.GetICMPLimiter(config.Network.ICMPRate)
|
||||
for _, host := range hostslist {
|
||||
limiter.Wait(1) // 等待令牌,控制发包速率
|
||||
dst, _ := net.ResolveIPAddr("ip", host)
|
||||
IcmpByte := makemsg(host)
|
||||
_, _ = conn.WriteTo(IcmpByte, dst)
|
||||
}
|
||||
|
||||
// 自适应等待响应
|
||||
// 算法:监控响应增量,连续一段时间无新响应则提前结束
|
||||
// 保守原则:保留最大等待时间兜底,确保不漏掉慢响应主机
|
||||
waitAdaptive(hostslist, aliveHosts, aliveHostsMu)
|
||||
|
||||
endflag.Store(true)
|
||||
_ = conn.Close()
|
||||
listenerWg.Wait()
|
||||
}
|
||||
|
||||
// RunIcmp2 使用ICMP并发探测主机存活(无监听模式)
|
||||
func RunIcmp2(hostslist []string, chanHosts chan string, config *common.Config, state *common.State, livewg *sync.WaitGroup) {
|
||||
// 控制并发数
|
||||
num := 1000
|
||||
if len(hostslist) < num {
|
||||
num = len(hostslist)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
limiter := make(chan struct{}, num)
|
||||
rateLimiter := state.GetICMPLimiter(config.Network.ICMPRate) // 获取速率限制器
|
||||
|
||||
// 并发探测
|
||||
for _, host := range hostslist {
|
||||
wg.Add(1)
|
||||
limiter <- struct{}{}
|
||||
|
||||
go func(host string) {
|
||||
defer func() {
|
||||
<-limiter
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
rateLimiter.Wait(1) // 等待令牌,控制发包速率
|
||||
if icmpalive(host) {
|
||||
livewg.Add(1)
|
||||
select {
|
||||
case chanHosts <- host:
|
||||
// 发送成功
|
||||
default:
|
||||
// channel已满,回退计数器
|
||||
livewg.Done()
|
||||
}
|
||||
}
|
||||
}(host)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(limiter)
|
||||
}
|
||||
|
||||
// icmpalive 检测主机ICMP是否存活
|
||||
func icmpalive(host string) bool {
|
||||
startTime := time.Now()
|
||||
|
||||
// 建立ICMP连接
|
||||
conn, err := net.DialTimeout("ip4:icmp", host, 6*time.Second)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// 设置超时时间
|
||||
if err := conn.SetDeadline(startTime.Add(6 * time.Second)); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 构造并发送ICMP请求
|
||||
msg := makemsg(host)
|
||||
if _, err := conn.Write(msg); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 接收ICMP响应
|
||||
receive := make([]byte, 60)
|
||||
if _, err := conn.Read(receive); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// RunPing 使用系统Ping命令并发探测主机存活
|
||||
func RunPing(hostslist []string, chanHosts chan string, livewg *sync.WaitGroup) {
|
||||
var wg sync.WaitGroup
|
||||
// 限制并发数为50
|
||||
limiter := make(chan struct{}, 50)
|
||||
|
||||
// 并发探测
|
||||
for _, host := range hostslist {
|
||||
wg.Add(1)
|
||||
limiter <- struct{}{}
|
||||
|
||||
go func(host string) {
|
||||
defer func() {
|
||||
<-limiter
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
if ExecCommandPing(host) {
|
||||
livewg.Add(1)
|
||||
select {
|
||||
case chanHosts <- host:
|
||||
// 发送成功
|
||||
default:
|
||||
// channel已满,回退计数器
|
||||
livewg.Done()
|
||||
}
|
||||
}
|
||||
}(host)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// containsPingError 检查 ping 输出是否包含错误关键词
|
||||
func containsPingError(output string) bool {
|
||||
outputLower := strings.ToLower(output)
|
||||
for _, keyword := range pingErrorKeywords {
|
||||
if strings.Contains(outputLower, strings.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ExecCommandPing 执行系统Ping命令检测主机存活
|
||||
func ExecCommandPing(ip string) bool {
|
||||
// 过滤黑名单字符(命令注入防护)
|
||||
for _, char := range pingForbiddenChars {
|
||||
if strings.Contains(ip, char) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var command *exec.Cmd
|
||||
// 根据操作系统选择不同的ping命令
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
command = exec.Command("cmd", "/c", "ping -n 1 -w 1 "+ip+" && echo true || echo false")
|
||||
case "darwin":
|
||||
command = exec.Command("/bin/bash", "-c", "ping -c 1 -W 1 "+ip+" && echo true || echo false")
|
||||
default: // linux
|
||||
command = exec.Command("/bin/bash", "-c", "ping -c 1 -w 1 "+ip+" && echo true || echo false")
|
||||
}
|
||||
|
||||
// 捕获命令输出
|
||||
var outinfo bytes.Buffer
|
||||
command.Stdout = &outinfo
|
||||
|
||||
// 执行命令
|
||||
if err := command.Start(); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := command.Wait(); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 分析输出结果
|
||||
output := outinfo.String()
|
||||
return strings.Contains(output, "true") && strings.Count(output, ip) > 2 && !containsPingError(output)
|
||||
}
|
||||
|
||||
// makemsg 构造ICMP echo请求消息
|
||||
func makemsg(host string) []byte {
|
||||
msg := make([]byte, 40)
|
||||
|
||||
// 获取标识符
|
||||
id0, id1 := genIdentifier(host)
|
||||
|
||||
// 设置ICMP头部
|
||||
msg[0] = 8 // Type: Echo Request
|
||||
msg[1] = 0 // Code: 0
|
||||
msg[2] = 0 // Checksum高位(待计算)
|
||||
msg[3] = 0 // Checksum低位(待计算)
|
||||
msg[4], msg[5] = id0, id1 // Identifier
|
||||
msg[6], msg[7] = genSequence(1) // Sequence Number
|
||||
|
||||
// 计算校验和
|
||||
check := checkSum(msg[0:40])
|
||||
msg[2] = byte(check >> 8) // 设置校验和高位
|
||||
msg[3] = byte(check & 255) // 设置校验和低位
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
// checkSum 计算ICMP校验和
|
||||
func checkSum(msg []byte) uint16 {
|
||||
sum := 0
|
||||
length := len(msg)
|
||||
|
||||
// 按16位累加
|
||||
for i := 0; i < length-1; i += 2 {
|
||||
sum += int(msg[i])*256 + int(msg[i+1])
|
||||
}
|
||||
|
||||
// 处理奇数长度情况
|
||||
if length%2 == 1 {
|
||||
sum += int(msg[length-1]) * 256
|
||||
}
|
||||
|
||||
// 将高16位加到低16位
|
||||
sum = (sum >> 16) + (sum & 0xffff)
|
||||
sum = sum + (sum >> 16)
|
||||
|
||||
// 取反得到校验和
|
||||
return uint16(^sum)
|
||||
}
|
||||
|
||||
// genSequence 生成ICMP序列号
|
||||
func genSequence(v int16) (byte, byte) {
|
||||
ret1 := byte(v >> 8) // 高8位
|
||||
ret2 := byte(v & 255) // 低8位
|
||||
return ret1, ret2
|
||||
}
|
||||
|
||||
// genIdentifier 根据主机地址生成标识符
|
||||
func genIdentifier(host string) (byte, byte) {
|
||||
if len(host) < 2 {
|
||||
return 0, 0
|
||||
}
|
||||
return host[0], host[1]
|
||||
}
|
||||
|
||||
// ArrayCountValueTop 统计IP地址段存活数量并返回TOP N结果
|
||||
func ArrayCountValueTop(arrInit []string, length int, flag bool) (arrTop []string, arrLen []int) {
|
||||
if len(arrInit) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 统计各网段出现次数,预分配容量
|
||||
segmentCounts := make(map[string]int, len(arrInit)/4)
|
||||
for _, ip := range arrInit {
|
||||
segments := strings.Split(ip, ".")
|
||||
if len(segments) != 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 根据flag确定统计B段还是C段
|
||||
var segment string
|
||||
if flag {
|
||||
segment = fmt.Sprintf("%s.%s", segments[0], segments[1]) // B段
|
||||
} else {
|
||||
segment = fmt.Sprintf("%s.%s.%s", segments[0], segments[1], segments[2]) // C段
|
||||
}
|
||||
|
||||
segmentCounts[segment]++
|
||||
}
|
||||
|
||||
// 创建副本用于排序
|
||||
sortMap := make(map[string]int)
|
||||
for k, v := range segmentCounts {
|
||||
sortMap[k] = v
|
||||
}
|
||||
|
||||
// 获取TOP N结果
|
||||
for i := 0; i < length && len(sortMap) > 0; i++ {
|
||||
maxSegment := ""
|
||||
maxCount := 0
|
||||
|
||||
// 查找当前最大值
|
||||
for segment, count := range sortMap {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
maxSegment = segment
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到结果集
|
||||
arrTop = append(arrTop, maxSegment)
|
||||
arrLen = append(arrLen, maxCount)
|
||||
|
||||
// 从待处理map中删除已处理项
|
||||
delete(sortMap, maxSegment)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TCP 补充探测 - 当 ICMP 响应率过低时自动启用
|
||||
// =============================================================================
|
||||
|
||||
// tcpProbeCommonPorts TCP 探测使用的常用端口
|
||||
// 这些端口在大多数服务器上至少有一个开放
|
||||
var tcpProbeCommonPorts = []int{80, 443, 22, 445}
|
||||
|
||||
// tcpProbeTimeout TCP 探测超时时间(较短,只做存活判断)
|
||||
const tcpProbeTimeout = 2 * time.Second
|
||||
|
||||
// tcpProbeThreshold TCP 补充探测触发阈值
|
||||
// 当 ICMP 响应率低于此值时,自动启用 TCP 补充探测
|
||||
const tcpProbeThreshold = 0.1 // 10%
|
||||
|
||||
// tcpProbeAlive 使用 TCP 探测主机是否存活
|
||||
// 尝试连接常用端口,任一端口响应即认为存活
|
||||
func tcpProbeAlive(host string) bool {
|
||||
for _, port := range tcpProbeCommonPorts {
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
conn, err := common.WrapperTcpWithTimeout("tcp", addr, tcpProbeTimeout)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runTcpProbeForHosts 对指定主机列表进行 TCP 补充探测
|
||||
// 返回存活的主机列表
|
||||
func runTcpProbeForHosts(hosts []string, config *common.Config) []string {
|
||||
if len(hosts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
aliveHosts := make([]string, 0)
|
||||
|
||||
// 并发控制,避免资源耗尽
|
||||
concurrency := 50
|
||||
if len(hosts) < concurrency {
|
||||
concurrency = len(hosts)
|
||||
}
|
||||
limiter := make(chan struct{}, concurrency)
|
||||
|
||||
for _, host := range hosts {
|
||||
wg.Add(1)
|
||||
limiter <- struct{}{}
|
||||
|
||||
go func(h string) {
|
||||
defer func() {
|
||||
<-limiter
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
if tcpProbeAlive(h) {
|
||||
mu.Lock()
|
||||
aliveHosts = append(aliveHosts, h)
|
||||
mu.Unlock()
|
||||
|
||||
// 保存结果
|
||||
result := &output.ScanResult{
|
||||
Time: time.Now(),
|
||||
Type: output.TypeHost,
|
||||
Target: h,
|
||||
Status: "alive",
|
||||
Details: map[string]interface{}{
|
||||
"protocol": "TCP",
|
||||
},
|
||||
}
|
||||
_ = common.SaveResult(result)
|
||||
|
||||
if !config.Output.Silent {
|
||||
common.LogInfo(i18n.Tr("host_alive", h, "TCP"))
|
||||
}
|
||||
}
|
||||
}(host)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return aliveHosts
|
||||
}
|
||||
|
||||
// getUnrespondedHosts 获取未响应的主机列表
|
||||
func getUnrespondedHosts(allHosts []string, aliveHosts []string) []string {
|
||||
aliveSet := make(map[string]struct{}, len(aliveHosts))
|
||||
for _, h := range aliveHosts {
|
||||
aliveSet[h] = struct{}{}
|
||||
}
|
||||
|
||||
unresponded := make([]string, 0, len(allHosts)-len(aliveHosts))
|
||||
for _, h := range allHosts {
|
||||
if _, alive := aliveSet[h]; !alive {
|
||||
unresponded = append(unresponded, h)
|
||||
}
|
||||
}
|
||||
return unresponded
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCheckSum 测试ICMP校验和计算(RFC 1071算法)
|
||||
func TestCheckSum(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg []byte
|
||||
expected uint16
|
||||
}{
|
||||
{
|
||||
name: "标准ICMP Echo请求",
|
||||
msg: []byte{8, 0, 0, 0, 0, 1, 0, 1},
|
||||
expected: 0xf7fd,
|
||||
},
|
||||
{
|
||||
name: "偶数长度消息",
|
||||
msg: []byte{0x00, 0x01, 0x02, 0x03},
|
||||
expected: 0xfdfb,
|
||||
},
|
||||
{
|
||||
name: "奇数长度消息",
|
||||
msg: []byte{0x00, 0x01, 0x02},
|
||||
expected: 0xfdfe,
|
||||
},
|
||||
{
|
||||
name: "全零消息",
|
||||
msg: make([]byte, 8),
|
||||
expected: 0xffff,
|
||||
},
|
||||
{
|
||||
name: "全0xFF消息",
|
||||
msg: []byte{0xff, 0xff, 0xff, 0xff},
|
||||
expected: 0x0000,
|
||||
},
|
||||
{
|
||||
name: "单字节",
|
||||
msg: []byte{0x12},
|
||||
expected: 0xedff,
|
||||
},
|
||||
{
|
||||
name: "两字节",
|
||||
msg: []byte{0x12, 0x34},
|
||||
expected: 0xedcb,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := checkSum(tt.msg)
|
||||
if result != tt.expected {
|
||||
t.Errorf("checkSum() = 0x%04x, 期望 0x%04x", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckSum_Idempotent 测试校验和幂等性
|
||||
func TestCheckSum_Idempotent(t *testing.T) {
|
||||
testCases := [][]byte{
|
||||
{8, 0, 0, 0, 0, 1, 0, 1},
|
||||
{0x12, 0x34, 0x56, 0x78},
|
||||
make([]byte, 40),
|
||||
}
|
||||
|
||||
for i, msg := range testCases {
|
||||
t.Run(fmt.Sprintf("case_%d", i), func(t *testing.T) {
|
||||
checksum1 := checkSum(msg)
|
||||
checksum2 := checkSum(msg)
|
||||
|
||||
if checksum1 != checksum2 {
|
||||
t.Errorf("幂等性失败: 第一次=0x%04x, 第二次=0x%04x", checksum1, checksum2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckSum_EdgeCases 测试checkSum边界情况
|
||||
func TestCheckSum_EdgeCases(t *testing.T) {
|
||||
t.Run("空切片", func(t *testing.T) {
|
||||
result := checkSum([]byte{})
|
||||
if result != 0xffff {
|
||||
t.Errorf("空切片校验和应为 0xffff, 实际 0x%04x", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("长消息-40字节ICMP包", func(t *testing.T) {
|
||||
msg := make([]byte, 40)
|
||||
msg[0] = 8 // Echo Request
|
||||
result := checkSum(msg)
|
||||
// 应该能正常计算不panic
|
||||
if result == 0 {
|
||||
t.Log("40字节消息校验和计算成功")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGenSequence 测试ICMP序列号生成
|
||||
func TestGenSequence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input int16
|
||||
expectedH byte
|
||||
expectedL byte
|
||||
}{
|
||||
{
|
||||
name: "序列号1",
|
||||
input: 1,
|
||||
expectedH: 0x00,
|
||||
expectedL: 0x01,
|
||||
},
|
||||
{
|
||||
name: "序列号256",
|
||||
input: 256,
|
||||
expectedH: 0x01,
|
||||
expectedL: 0x00,
|
||||
},
|
||||
{
|
||||
name: "序列号0",
|
||||
input: 0,
|
||||
expectedH: 0x00,
|
||||
expectedL: 0x00,
|
||||
},
|
||||
{
|
||||
name: "序列号0x1234",
|
||||
input: 0x1234,
|
||||
expectedH: 0x12,
|
||||
expectedL: 0x34,
|
||||
},
|
||||
{
|
||||
name: "负数序列号",
|
||||
input: -1,
|
||||
expectedH: 0xff,
|
||||
expectedL: 0xff,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h, l := genSequence(tt.input)
|
||||
if h != tt.expectedH || l != tt.expectedL {
|
||||
t.Errorf("genSequence(%d) = (0x%02x, 0x%02x), 期望 (0x%02x, 0x%02x)",
|
||||
tt.input, h, l, tt.expectedH, tt.expectedL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenIdentifier 测试标识符生成
|
||||
func TestGenIdentifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
expectedH byte
|
||||
expectedL byte
|
||||
shouldRun bool
|
||||
}{
|
||||
{
|
||||
name: "正常IP地址",
|
||||
host: "192.168.1.1",
|
||||
expectedH: '1',
|
||||
expectedL: '9',
|
||||
shouldRun: true,
|
||||
},
|
||||
{
|
||||
name: "域名",
|
||||
host: "example.com",
|
||||
expectedH: 'e',
|
||||
expectedL: 'x',
|
||||
shouldRun: true,
|
||||
},
|
||||
{
|
||||
name: "两字符最小长度",
|
||||
host: "ab",
|
||||
expectedH: 'a',
|
||||
expectedL: 'b',
|
||||
shouldRun: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if !tt.shouldRun {
|
||||
t.Skip("跳过可能panic的测试")
|
||||
}
|
||||
|
||||
h, l := genIdentifier(tt.host)
|
||||
if h != tt.expectedH || l != tt.expectedL {
|
||||
t.Errorf("genIdentifier(%q) = (%c, %c), 期望 (%c, %c)",
|
||||
tt.host, h, l, tt.expectedH, tt.expectedL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenIdentifier_EdgeCases 测试genIdentifier边界情况(修复后)
|
||||
func TestGenIdentifier_EdgeCases(t *testing.T) {
|
||||
t.Run("单字符返回默认值", func(t *testing.T) {
|
||||
h, l := genIdentifier("1")
|
||||
if h != 0 || l != 0 {
|
||||
t.Errorf("单字符应返回(0,0), 实际(%d,%d)", h, l)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空字符串返回默认值", func(t *testing.T) {
|
||||
h, l := genIdentifier("")
|
||||
if h != 0 || l != 0 {
|
||||
t.Errorf("空字符串应返回(0,0), 实际(%d,%d)", h, l)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("修复后不再panic", func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("不应panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
// 这些调用在修复前会panic,修复后不应panic
|
||||
_, _ = genIdentifier("")
|
||||
_, _ = genIdentifier("1")
|
||||
_, _ = genIdentifier("ab")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetOptimalTopCount 测试智能显示数量决策
|
||||
func TestGetOptimalTopCount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
totalHosts int
|
||||
expected int
|
||||
}{
|
||||
{"超小规模-10台", 10, 3},
|
||||
{"小规模-100台", 100, 3},
|
||||
{"边界-256台", 256, 3},
|
||||
{"小规模扫描-257台", 257, 5},
|
||||
{"中等规模-1000台", 1000, 5},
|
||||
{"边界-1001台", 1001, 10},
|
||||
{"大规模-10000台", 10000, 10},
|
||||
{"边界-10001台", 10001, 15},
|
||||
{"超大规模-50000台", 50000, 15},
|
||||
{"边界-50001台", 50001, 20},
|
||||
{"极大规模-100000台", 100000, 20},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getOptimalTopCount(tt.totalHosts)
|
||||
if result != tt.expected {
|
||||
t.Errorf("getOptimalTopCount(%d) = %d, 期望 %d",
|
||||
tt.totalHosts, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsContain 测试切片查找
|
||||
func TestIsContain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
items []string
|
||||
item string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "找到元素",
|
||||
items: []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"},
|
||||
item: "192.168.1.2",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "未找到元素",
|
||||
items: []string{"192.168.1.1", "192.168.1.2"},
|
||||
item: "192.168.1.3",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "空切片",
|
||||
items: []string{},
|
||||
item: "192.168.1.1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "查找空字符串",
|
||||
items: []string{"a", "b", ""},
|
||||
item: "",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "单元素切片-匹配",
|
||||
items: []string{"192.168.1.1"},
|
||||
item: "192.168.1.1",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "单元素切片-不匹配",
|
||||
items: []string{"192.168.1.1"},
|
||||
item: "192.168.1.2",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsContain(tt.items, tt.item)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsContain() = %v, 期望 %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecCommandPing_Blacklist 测试Ping命令注入防护
|
||||
func TestExecCommandPing_Blacklist(t *testing.T) {
|
||||
dangerousInputs := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"分号注入", "192.168.1.1; rm -rf /"},
|
||||
{"与符号注入", "192.168.1.1 & whoami"},
|
||||
{"管道注入", "192.168.1.1 | cat /etc/passwd"},
|
||||
{"反引号注入", "192.168.1.1`whoami`"},
|
||||
{"美元符号", "192.168.1.1$USER"},
|
||||
{"反斜杠", "192.168.1.1\\nwhoami"},
|
||||
{"单引号", "192.168.1.1'"},
|
||||
{"百分号", "192.168.1.1%"},
|
||||
{"双引号", "192.168.1.1\""},
|
||||
{"换行符", "192.168.1.1\nwhoami"},
|
||||
}
|
||||
|
||||
for _, tt := range dangerousInputs {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := ExecCommandPing(tt.input)
|
||||
if result {
|
||||
t.Errorf("ExecCommandPing(%q) = true, 应拒绝危险输入", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecCommandPing_ValidInputs 测试合法IP格式
|
||||
func TestExecCommandPing_ValidInputs(t *testing.T) {
|
||||
validInputs := []string{
|
||||
"192.168.1.1",
|
||||
"10.0.0.1",
|
||||
"8.8.8.8",
|
||||
"255.255.255.255",
|
||||
}
|
||||
|
||||
for _, input := range validInputs {
|
||||
t.Run(input, func(t *testing.T) {
|
||||
// 注意:这个测试会实际执行ping命令
|
||||
// 在CI环境可能失败,这里只验证不会因注入而panic
|
||||
_ = ExecCommandPing(input)
|
||||
// 不检查返回值,因为网络可能不可达
|
||||
// 重点是验证黑名单过滤逻辑
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayCountValueTop 测试IP网段统计
|
||||
func TestArrayCountValueTop(t *testing.T) {
|
||||
t.Run("C段统计", func(t *testing.T) {
|
||||
ips := []string{
|
||||
"192.168.1.1",
|
||||
"192.168.1.2",
|
||||
"192.168.1.3",
|
||||
"192.168.2.1",
|
||||
"192.168.2.2",
|
||||
"10.0.0.1",
|
||||
}
|
||||
|
||||
arrTop, arrLen := ArrayCountValueTop(ips, 2, false)
|
||||
|
||||
if len(arrTop) != 2 {
|
||||
t.Errorf("期望返回2个网段, 实际 %d", len(arrTop))
|
||||
}
|
||||
|
||||
// 第一名应该是 192.168.1 (3个IP)
|
||||
if arrTop[0] != "192.168.1" || arrLen[0] != 3 {
|
||||
t.Errorf("第一名应为 192.168.1(3), 实际 %s(%d)", arrTop[0], arrLen[0])
|
||||
}
|
||||
|
||||
// 第二名应该是 192.168.2 (2个IP)
|
||||
if arrTop[1] != "192.168.2" || arrLen[1] != 2 {
|
||||
t.Errorf("第二名应为 192.168.2(2), 实际 %s(%d)", arrTop[1], arrLen[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("B段统计", func(t *testing.T) {
|
||||
ips := []string{
|
||||
"192.168.1.1",
|
||||
"192.168.2.1",
|
||||
"192.168.3.1",
|
||||
"10.0.1.1",
|
||||
"10.0.2.1",
|
||||
}
|
||||
|
||||
arrTop, arrLen := ArrayCountValueTop(ips, 2, true)
|
||||
|
||||
if len(arrTop) != 2 {
|
||||
t.Errorf("期望返回2个B段, 实际 %d", len(arrTop))
|
||||
}
|
||||
|
||||
// 第一名应该是 192.168 (3个IP)
|
||||
if arrTop[0] != "192.168" || arrLen[0] != 3 {
|
||||
t.Errorf("第一名应为 192.168(3), 实际 %s(%d)", arrTop[0], arrLen[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空列表", func(t *testing.T) {
|
||||
arrTop, arrLen := ArrayCountValueTop([]string{}, 5, false)
|
||||
|
||||
if len(arrTop) != 0 || len(arrLen) != 0 {
|
||||
t.Error("空列表应返回空结果")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("请求数量超过实际网段数", func(t *testing.T) {
|
||||
ips := []string{"192.168.1.1", "10.0.0.1"}
|
||||
|
||||
arrTop, _ := ArrayCountValueTop(ips, 10, false)
|
||||
|
||||
if len(arrTop) != 2 {
|
||||
t.Errorf("只有2个网段时请求10个,应返回2个, 实际 %d", len(arrTop))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非法IP格式-跳过", func(t *testing.T) {
|
||||
ips := []string{
|
||||
"192.168.1.1",
|
||||
"invalid",
|
||||
"192.168",
|
||||
"192.168.1.2",
|
||||
}
|
||||
|
||||
arrTop, arrLen := ArrayCountValueTop(ips, 1, false)
|
||||
|
||||
// 只有2个合法IP
|
||||
if len(arrTop) != 1 || arrLen[0] != 2 {
|
||||
t.Errorf("应统计2个合法IP, 实际 %s(%d)", arrTop[0], arrLen[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestMakemsg 测试ICMP消息构造
|
||||
func TestMakemsg(t *testing.T) {
|
||||
t.Run("构造标准ICMP包", func(t *testing.T) {
|
||||
msg := makemsg("192.168.1.1")
|
||||
|
||||
if len(msg) != 40 {
|
||||
t.Errorf("ICMP包长度应为40, 实际 %d", len(msg))
|
||||
}
|
||||
|
||||
// 验证Type字段
|
||||
if msg[0] != 8 {
|
||||
t.Errorf("ICMP Type应为8(Echo Request), 实际 %d", msg[0])
|
||||
}
|
||||
|
||||
// 验证Code字段
|
||||
if msg[1] != 0 {
|
||||
t.Errorf("ICMP Code应为0, 实际 %d", msg[1])
|
||||
}
|
||||
|
||||
// 验证校验和不为零(已计算)
|
||||
checksum := uint16(msg[2])<<8 | uint16(msg[3])
|
||||
if checksum == 0 {
|
||||
t.Error("ICMP校验和不应为0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不同主机产生不同标识符", func(t *testing.T) {
|
||||
msg1 := makemsg("192.168.1.1")
|
||||
msg2 := makemsg("10.0.0.1")
|
||||
|
||||
// 标识符字段在偏移4-5
|
||||
if msg1[4] == msg2[4] && msg1[5] == msg2[5] {
|
||||
t.Log("警告:不同主机可能产生相同标识符(取决于前两字符)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWaitAdaptive 测试自适应等待算法
|
||||
func TestWaitAdaptive(t *testing.T) {
|
||||
t.Run("全部响应-立即结束", func(t *testing.T) {
|
||||
hostslist := []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"}
|
||||
aliveHosts := []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"} // 全部存活
|
||||
var mu sync.Mutex
|
||||
|
||||
start := time.Now()
|
||||
waitAdaptive(hostslist, &aliveHosts, &mu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 全部响应应该在 1 个检查周期内结束 (~100ms)
|
||||
if elapsed > 200*time.Millisecond {
|
||||
t.Errorf("全部响应后应快速结束,实际耗时 %v", elapsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无响应-自适应提前结束", func(t *testing.T) {
|
||||
hostslist := make([]string, 10) // 10 个主机
|
||||
for i := range hostslist {
|
||||
hostslist[i] = fmt.Sprintf("192.168.1.%d", i+1)
|
||||
}
|
||||
aliveHosts := []string{} // 无响应
|
||||
var mu sync.Mutex
|
||||
|
||||
start := time.Now()
|
||||
waitAdaptive(hostslist, &aliveHosts, &mu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 无响应时:lastChangeTime = start
|
||||
// 在 minWait(1s) 后,time.Since(lastChangeTime) >= 1s > stableThreshold(500ms)
|
||||
// 所以会在约 1s 时提前结束(这是自适应优化的效果)
|
||||
// 相比原来的固定 3s,节省了约 2s
|
||||
if elapsed < 900*time.Millisecond || elapsed > 1300*time.Millisecond {
|
||||
t.Errorf("无响应时应在约 1s 提前结束,实际耗时 %v", elapsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("部分响应后稳定-提前结束", func(t *testing.T) {
|
||||
hostslist := make([]string, 100)
|
||||
for i := range hostslist {
|
||||
hostslist[i] = fmt.Sprintf("192.168.1.%d", i+1)
|
||||
}
|
||||
// 模拟 50% 响应
|
||||
aliveHosts := make([]string, 50)
|
||||
for i := range aliveHosts {
|
||||
aliveHosts[i] = fmt.Sprintf("192.168.1.%d", i+1)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
|
||||
start := time.Now()
|
||||
waitAdaptive(hostslist, &aliveHosts, &mu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 响应已稳定(不再变化),应该在 minWait + stableThreshold 后结束
|
||||
// 即约 1.5s,而不是 3s
|
||||
if elapsed > 2*time.Second {
|
||||
t.Errorf("响应稳定后应提前结束,实际耗时 %v", elapsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("持续响应-等待完成", func(t *testing.T) {
|
||||
hostslist := make([]string, 10)
|
||||
for i := range hostslist {
|
||||
hostslist[i] = fmt.Sprintf("192.168.1.%d", i+1)
|
||||
}
|
||||
aliveHosts := []string{}
|
||||
var mu sync.Mutex
|
||||
|
||||
// 模拟持续响应:每 200ms 增加一个存活主机
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 10; i++ {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
mu.Lock()
|
||||
aliveHosts = append(aliveHosts, fmt.Sprintf("192.168.1.%d", i+1))
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
start := time.Now()
|
||||
waitAdaptive(hostslist, &aliveHosts, &mu)
|
||||
elapsed := time.Since(start)
|
||||
<-done // 等待 goroutine 结束
|
||||
|
||||
// 10 个主机 * 200ms = 2s,全部响应后应立即结束
|
||||
// 总耗时应该在 2s 左右
|
||||
if elapsed < 1800*time.Millisecond || elapsed > 2500*time.Millisecond {
|
||||
t.Errorf("持续响应时应等待全部完成,实际耗时 %v", elapsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkWaitAdaptive 基准测试自适应等待性能
|
||||
func BenchmarkWaitAdaptive(b *testing.B) {
|
||||
hostslist := make([]string, 100)
|
||||
for i := range hostslist {
|
||||
hostslist[i] = fmt.Sprintf("192.168.1.%d", i+1)
|
||||
}
|
||||
// 全部响应场景
|
||||
aliveHosts := make([]string, 100)
|
||||
copy(aliveHosts, hostslist)
|
||||
var mu sync.Mutex
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
waitAdaptive(hostslist, &aliveHosts, &mu)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkCheckSum 基准测试校验和性能
|
||||
func BenchmarkCheckSum(b *testing.B) {
|
||||
msg := make([]byte, 40)
|
||||
msg[0] = 8
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
checkSum(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkArrayCountValueTop 基准测试网段统计性能
|
||||
func BenchmarkArrayCountValueTop(b *testing.B) {
|
||||
// 生成1000个IP地址
|
||||
ips := make([]string, 1000)
|
||||
for i := 0; i < 1000; i++ {
|
||||
ips[i] = fmt.Sprintf("192.%d.%d.1", i/256, i%256)
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
ArrayCountValueTop(ips, 10, false)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayCountValueTop_Sorting 测试排序正确性
|
||||
func TestArrayCountValueTop_Sorting(t *testing.T) {
|
||||
ips := []string{
|
||||
"192.168.1.1", // 192.168.1: 1次
|
||||
"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.4", "10.0.0.5", // 10.0.0: 5次
|
||||
"172.16.0.1", "172.16.0.2", "172.16.0.3", // 172.16.0: 3次
|
||||
}
|
||||
|
||||
arrTop, arrLen := ArrayCountValueTop(ips, 3, false)
|
||||
|
||||
// 验证降序排列
|
||||
if arrLen[0] < arrLen[1] || arrLen[1] < arrLen[2] {
|
||||
t.Errorf("结果应按降序排列: %v", arrLen)
|
||||
}
|
||||
|
||||
// 验证第一名
|
||||
if arrTop[0] != "10.0.0" || arrLen[0] != 5 {
|
||||
t.Errorf("第一名错误: %s(%d)", arrTop[0], arrLen[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/common/i18n"
|
||||
"github.com/shadow1ng/fscan/plugins"
|
||||
)
|
||||
|
||||
// LocalScanStrategy 本地扫描策略
|
||||
type LocalScanStrategy struct {
|
||||
*BaseScanStrategy
|
||||
}
|
||||
|
||||
// NewLocalScanStrategy 创建新的本地扫描策略
|
||||
func NewLocalScanStrategy() *LocalScanStrategy {
|
||||
return &LocalScanStrategy{
|
||||
BaseScanStrategy: NewBaseScanStrategy("本地扫描", FilterLocal),
|
||||
}
|
||||
}
|
||||
|
||||
// LogPluginInfo 重写以只显示通过-local指定的插件
|
||||
func (s *LocalScanStrategy) LogPluginInfo(config *common.Config) {
|
||||
localPlugin := config.LocalPlugin
|
||||
if localPlugin != "" {
|
||||
common.LogInfo(i18n.Tr("local_plugin_info", localPlugin))
|
||||
} else {
|
||||
common.LogError(i18n.GetText("local_plugin_not_specified"))
|
||||
}
|
||||
}
|
||||
|
||||
// Name 返回策略名称
|
||||
func (s *LocalScanStrategy) Name() string {
|
||||
return i18n.GetText("scan_strategy_local_name")
|
||||
}
|
||||
|
||||
// Description 返回策略描述
|
||||
func (s *LocalScanStrategy) Description() string {
|
||||
return i18n.GetText("scan_strategy_local_desc")
|
||||
}
|
||||
|
||||
// Execute 执行本地扫描策略
|
||||
func (s *LocalScanStrategy) Execute(config *common.Config, state *common.State, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
|
||||
// 输出扫描开始信息
|
||||
s.LogScanStart()
|
||||
|
||||
// 验证插件配置
|
||||
if err := s.ValidateConfiguration(); err != nil {
|
||||
common.LogError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 验证本地插件是否存在
|
||||
if config.LocalPlugin != "" {
|
||||
if !plugins.Exists(config.LocalPlugin) {
|
||||
common.LogError(i18n.Tr("local_plugin_not_found", config.LocalPlugin))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 输出插件信息
|
||||
s.LogPluginInfo(config)
|
||||
|
||||
// 准备目标(本地扫描通常只有一个目标,即本机)
|
||||
targets := s.PrepareTargets(info)
|
||||
|
||||
// 执行扫描任务
|
||||
ExecuteScanTasks(config, state, targets, s, ch, wg)
|
||||
}
|
||||
|
||||
// PrepareTargets 准备本地扫描目标
|
||||
func (s *LocalScanStrategy) PrepareTargets(info common.HostInfo) []common.HostInfo {
|
||||
// 本地扫描只使用传入的目标信息,不做额外处理
|
||||
return []common.HostInfo{info}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
// TestNewLocalScanStrategy 测试本地扫描策略构造函数
|
||||
func TestNewLocalScanStrategy(t *testing.T) {
|
||||
strategy := NewLocalScanStrategy()
|
||||
|
||||
if strategy == nil {
|
||||
t.Fatal("NewLocalScanStrategy 返回 nil")
|
||||
}
|
||||
|
||||
if strategy.BaseScanStrategy == nil {
|
||||
t.Error("BaseScanStrategy 未初始化")
|
||||
}
|
||||
|
||||
// 验证过滤器类型
|
||||
if strategy.filterType != FilterLocal {
|
||||
t.Errorf("filterType: 期望 FilterLocal(%d), 实际 %d", FilterLocal, strategy.filterType)
|
||||
}
|
||||
|
||||
// 验证策略名称
|
||||
if strategy.strategyName != "本地扫描" {
|
||||
t.Errorf("strategyName: 期望 '本地扫描', 实际 %q", strategy.strategyName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalScanStrategy_PrepareTargets 测试PrepareTargets
|
||||
func TestLocalScanStrategy_PrepareTargets(t *testing.T) {
|
||||
strategy := NewLocalScanStrategy()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input common.HostInfo
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "空HostInfo",
|
||||
input: common.HostInfo{},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "带Host的HostInfo",
|
||||
input: common.HostInfo{
|
||||
Host: "localhost",
|
||||
},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "完整HostInfo",
|
||||
input: common.HostInfo{
|
||||
Host: "127.0.0.1",
|
||||
Port: 80,
|
||||
},
|
||||
expected: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
targets := strategy.PrepareTargets(tt.input)
|
||||
|
||||
// 验证返回列表长度
|
||||
if len(targets) != tt.expected {
|
||||
t.Errorf("PrepareTargets() 返回长度 = %d, 期望 %d", len(targets), tt.expected)
|
||||
}
|
||||
|
||||
// 验证返回的第一个元素与输入相同
|
||||
if len(targets) > 0 {
|
||||
if targets[0].Host != tt.input.Host {
|
||||
t.Errorf("targets[0].Host = %q, 期望 %q", targets[0].Host, tt.input.Host)
|
||||
}
|
||||
if targets[0].Port != tt.input.Port {
|
||||
t.Errorf("targets[0].Port = %q, 期望 %q", targets[0].Port, tt.input.Port)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalScanStrategy_PrepareTargets_ImmutabilityCheck 测试PrepareTargets不修改输入
|
||||
func TestLocalScanStrategy_PrepareTargets_ImmutabilityCheck(t *testing.T) {
|
||||
strategy := NewLocalScanStrategy()
|
||||
|
||||
original := common.HostInfo{
|
||||
Host: "192.168.1.1",
|
||||
Port: 22,
|
||||
}
|
||||
|
||||
// 保存原始值副本
|
||||
originalHost := original.Host
|
||||
originalPort := original.Port
|
||||
|
||||
// 调用PrepareTargets
|
||||
targets := strategy.PrepareTargets(original)
|
||||
|
||||
// 验证原始输入未被修改
|
||||
if original.Host != originalHost {
|
||||
t.Errorf("输入被修改: original.Host = %q, 期望 %q", original.Host, originalHost)
|
||||
}
|
||||
if original.Port != originalPort {
|
||||
t.Errorf("输入被修改: original.Port = %d, 期望 %d", original.Port, originalPort)
|
||||
}
|
||||
|
||||
// 验证返回值与输入相等
|
||||
if len(targets) != 1 {
|
||||
t.Fatalf("targets长度 = %d, 期望 1", len(targets))
|
||||
}
|
||||
|
||||
if targets[0].Host != originalHost {
|
||||
t.Errorf("targets[0].Host = %q, 期望 %q", targets[0].Host, originalHost)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalScanStrategy_TypeAssertion 测试类型继承关系
|
||||
func TestLocalScanStrategy_TypeAssertion(t *testing.T) {
|
||||
strategy := NewLocalScanStrategy()
|
||||
|
||||
// 验证类型继承
|
||||
if strategy.BaseScanStrategy == nil {
|
||||
t.Error("LocalScanStrategy 未嵌入 BaseScanStrategy")
|
||||
}
|
||||
|
||||
// 验证可以访问BaseScanStrategy的方法
|
||||
err := strategy.ValidateConfiguration()
|
||||
if err != nil {
|
||||
t.Errorf("ValidateConfiguration() 应返回 nil, 实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalScanStrategy_FieldAccess 测试字段访问
|
||||
func TestLocalScanStrategy_FieldAccess(t *testing.T) {
|
||||
strategy := NewLocalScanStrategy()
|
||||
|
||||
// 通过BaseScanStrategy访问私有字段
|
||||
if strategy.strategyName == "" {
|
||||
t.Error("strategyName 不应为空")
|
||||
}
|
||||
|
||||
if strategy.filterType != FilterLocal {
|
||||
t.Errorf("filterType 应为 FilterLocal, 实际 %d", strategy.filterType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/common/i18n"
|
||||
"github.com/shadow1ng/fscan/common/output"
|
||||
"github.com/shadow1ng/fscan/common/parsers"
|
||||
)
|
||||
|
||||
// proxyFailurePatterns 代理连接失败的错误模式(小写)
|
||||
var proxyFailurePatterns = []string{
|
||||
"connection reset by peer",
|
||||
"connection refused",
|
||||
"no route to host",
|
||||
"network is unreachable",
|
||||
"host is unreachable",
|
||||
"general socks server failure",
|
||||
"connection not allowed",
|
||||
"host unreachable",
|
||||
"network unreachable",
|
||||
"connection refused by destination host",
|
||||
}
|
||||
|
||||
// resourceExhaustedPatterns 资源耗尽类错误模式
|
||||
var resourceExhaustedPatterns = []string{
|
||||
"too many open files",
|
||||
"no buffer space available",
|
||||
"cannot assign requested address",
|
||||
"connection reset by peer",
|
||||
"发包受限",
|
||||
}
|
||||
|
||||
// resultCollector 结果收集器,用于并发安全地收集扫描结果
|
||||
// 使用 map 实现:O(1) 的添加和删除,无顺序依赖问题
|
||||
type resultCollector struct {
|
||||
mu sync.Mutex
|
||||
addrs map[string]struct{}
|
||||
}
|
||||
|
||||
// newResultCollector 创建结果收集器
|
||||
func newResultCollector() *resultCollector {
|
||||
return &resultCollector{
|
||||
addrs: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Add 添加一个扫描结果
|
||||
func (c *resultCollector) Add(addr string) {
|
||||
c.mu.Lock()
|
||||
c.addrs[addr] = struct{}{}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetAll 获取所有结果
|
||||
func (c *resultCollector) GetAll() []string {
|
||||
c.mu.Lock()
|
||||
result := make([]string, 0, len(c.addrs))
|
||||
for addr := range c.addrs {
|
||||
result = append(result, addr)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
|
||||
// portScanTask 端口扫描任务(轻量级,用于滑动窗口调度)
|
||||
type portScanTask struct {
|
||||
host string
|
||||
port int
|
||||
semaphore chan struct{} // 完成时释放窗口槽位
|
||||
}
|
||||
|
||||
// failedPortInfo 失败端口信息
|
||||
type failedPortInfo struct {
|
||||
Host string
|
||||
Port int
|
||||
Addr string
|
||||
}
|
||||
|
||||
// failedPortCollector 失败端口收集器,用于记录需要重扫的端口
|
||||
type failedPortCollector struct {
|
||||
mu sync.Mutex
|
||||
ports []failedPortInfo
|
||||
}
|
||||
|
||||
// Add 添加失败的端口
|
||||
func (f *failedPortCollector) Add(host string, port int, addr string) {
|
||||
f.mu.Lock()
|
||||
f.ports = append(f.ports, failedPortInfo{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Addr: addr,
|
||||
})
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// Count 获取失败端口数量
|
||||
func (f *failedPortCollector) Count() int {
|
||||
f.mu.Lock()
|
||||
count := len(f.ports)
|
||||
f.mu.Unlock()
|
||||
return count
|
||||
}
|
||||
|
||||
// EnhancedPortScan 高性能端口扫描函数
|
||||
// 使用滑动窗口调度 + 自适应线程池 + 流式迭代器
|
||||
func EnhancedPortScan(hosts []string, ports string, timeout int64, config *common.Config, state *common.State) []string {
|
||||
common.LogDebug(fmt.Sprintf("[PortScan] 开始: %d个主机, 线程数=%d", len(hosts), config.ThreadNum))
|
||||
|
||||
// 解析端口和排除端口
|
||||
portList := parsers.ParsePort(ports)
|
||||
if len(portList) == 0 {
|
||||
common.LogError(i18n.Tr("invalid_port", ports))
|
||||
return nil
|
||||
}
|
||||
common.LogDebug(fmt.Sprintf("[PortScan] 端口解析完成: %d个端口", len(portList)))
|
||||
|
||||
// 使用config中的排除端口配置
|
||||
excludePorts := parsers.ParsePort(config.Target.ExcludePorts)
|
||||
exclude := make(map[int]struct{}, len(excludePorts))
|
||||
for _, p := range excludePorts {
|
||||
exclude[p] = struct{}{}
|
||||
}
|
||||
|
||||
// 检查代理可靠性,如果存在全回显问题则警告
|
||||
if common.IsProxyEnabled() && !common.IsProxyReliable() {
|
||||
common.LogError("检测到代理存在全回显问题,端口扫描结果可能不准确")
|
||||
}
|
||||
|
||||
// 创建流式迭代器(O(1) 内存,端口喷洒策略)
|
||||
iter := NewSocketIterator(hosts, portList, exclude)
|
||||
totalTasks := iter.Total()
|
||||
common.LogDebug(fmt.Sprintf("[PortScan] 总任务数: %d", totalTasks))
|
||||
|
||||
// 使用传入的配置
|
||||
threadNum := config.ThreadNum
|
||||
|
||||
// 大规模扫描警告和线程数自动调整
|
||||
if totalTasks > 100000 {
|
||||
common.LogInfo(fmt.Sprintf("大规模扫描: %d 个目标 (%d主机 × %d端口)", totalTasks, len(hosts), len(portList)))
|
||||
// 如果任务数超过100万且线程数大于300,自动降低线程数
|
||||
if totalTasks > 1000000 && threadNum > 300 {
|
||||
oldThreadNum := threadNum
|
||||
threadNum = 300
|
||||
common.LogInfo(fmt.Sprintf("自动调整线程数: %d -> %d (大规模扫描优化)", oldThreadNum, threadNum))
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化端口扫描进度条
|
||||
if totalTasks > 0 && config.Output.ShowProgress {
|
||||
description := fmt.Sprintf("端口扫描中(%d线程)", threadNum)
|
||||
common.InitProgressBar(int64(totalTasks), description)
|
||||
}
|
||||
common.LogDebug("[PortScan] 进度条初始化完成")
|
||||
|
||||
// 初始化并发控制
|
||||
to := time.Duration(timeout) * time.Second
|
||||
var count int64
|
||||
collector := newResultCollector()
|
||||
failedCollector := &failedPortCollector{}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
common.LogDebug(fmt.Sprintf("[PortScan] 开始创建线程池, size=%d", threadNum))
|
||||
// 创建自适应线程池(支持动态调整)
|
||||
pool, err := NewAdaptivePool(threadNum, func(task interface{}) {
|
||||
taskInfo, ok := task.(portScanTask)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
<-taskInfo.semaphore // 释放窗口槽位
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", taskInfo.host, taskInfo.port)
|
||||
scanSinglePort(taskInfo.host, taskInfo.port, addr, to, &count, collector, failedCollector, config, state)
|
||||
common.UpdateProgressBar(1)
|
||||
}, state)
|
||||
if err != nil {
|
||||
common.LogError(i18n.Tr("thread_pool_create_failed", err))
|
||||
return nil
|
||||
}
|
||||
common.LogDebug("[PortScan] 线程池创建成功")
|
||||
defer pool.Release()
|
||||
|
||||
common.LogDebug("[PortScan] 开始滑动窗口调度")
|
||||
// 滑动窗口调度:维护固定数量的"飞行中"任务
|
||||
slidingWindowSchedule(iter, pool, &wg, threadNum)
|
||||
common.LogDebug("[PortScan] 滑动窗口调度完成")
|
||||
|
||||
// 收集结果
|
||||
aliveAddrs := collector.GetAll()
|
||||
|
||||
// 完成端口扫描进度条
|
||||
if common.IsProgressActive() {
|
||||
common.FinishProgressBar()
|
||||
}
|
||||
|
||||
common.LogInfo(i18n.Tr("port_scan_complete", count))
|
||||
|
||||
// 检查扫描失败率,如果过高则警告用户
|
||||
resourceErrors := state.GetResourceExhaustedCount()
|
||||
failedCount := failedCollector.Count()
|
||||
|
||||
if failedCount > 0 {
|
||||
failureRate := float64(failedCount) / float64(totalTasks) * 100
|
||||
|
||||
if failureRate > 20 {
|
||||
// 失败率超过20%,严重警告
|
||||
common.LogError(i18n.Tr("scan_failure_rate_high", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks))
|
||||
common.LogError(i18n.GetText("scan_failure_reason"))
|
||||
common.LogError(i18n.Tr("scan_reduce_threads_suggestion", threadNum))
|
||||
} else if failureRate > 5 {
|
||||
// 失败率5-20%,一般警告
|
||||
common.LogInfo(i18n.Tr("scan_partial_failure", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks))
|
||||
common.LogInfo(i18n.Tr("scan_reduce_threads_accuracy", threadNum))
|
||||
}
|
||||
}
|
||||
|
||||
if resourceErrors > 0 {
|
||||
common.LogError(i18n.Tr("resource_exhausted_warning", resourceErrors))
|
||||
}
|
||||
|
||||
return aliveAddrs
|
||||
}
|
||||
|
||||
// slidingWindowSchedule 滑动窗口调度器
|
||||
// 核心思想:维护固定数量的"飞行中"任务,一个完成立即补充新的
|
||||
// 优势:避免任务队列堆积,内存使用恒定
|
||||
func slidingWindowSchedule(iter *SocketIterator, pool *AdaptivePool, wg *sync.WaitGroup, windowSize int) {
|
||||
// 使用信号量控制窗口大小
|
||||
semaphore := make(chan struct{}, windowSize)
|
||||
|
||||
for {
|
||||
host, port, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
|
||||
// 获取窗口槽位(阻塞直到有空位)
|
||||
semaphore <- struct{}{}
|
||||
|
||||
wg.Add(1)
|
||||
task := portScanTask{
|
||||
host: host,
|
||||
port: port,
|
||||
semaphore: semaphore,
|
||||
}
|
||||
_ = pool.Invoke(task)
|
||||
}
|
||||
|
||||
// 等待所有任务完成
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// connectWithRetry 带重试的TCP连接 - 只对资源耗尽错误重试
|
||||
func connectWithRetry(addr string, timeout time.Duration, maxRetries int, state *common.State) (net.Conn, error) {
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
conn, err := common.WrapperTcpWithTimeout("tcp", addr, timeout)
|
||||
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
|
||||
// 只对资源耗尽类错误重试,端口关闭直接返回
|
||||
if !isResourceExhaustedError(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 记录资源耗尽错误
|
||||
state.IncrementResourceExhaustedCount()
|
||||
|
||||
// 指数退避:第1次等50ms,第2次等150ms
|
||||
if attempt < maxRetries-1 {
|
||||
waitTime := time.Duration(50*(attempt+1)) * time.Millisecond
|
||||
time.Sleep(waitTime)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
// isResourceExhaustedError 判断是否为资源耗尽类错误
|
||||
func isResourceExhaustedError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
for _, pattern := range resourceExhaustedPatterns {
|
||||
if strings.Contains(errStr, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// buildServiceLogMessage 构建服务识别的日志信息
|
||||
// 格式: addr service [Product:xxx ||Version:xxx] Banner:(xxx)
|
||||
func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) string {
|
||||
var msg strings.Builder
|
||||
msg.WriteString(fmt.Sprintf("%-21s", addr))
|
||||
|
||||
if serviceInfo.Name != "unknown" {
|
||||
msg.WriteString(fmt.Sprintf(" %-8s", serviceInfo.Name))
|
||||
}
|
||||
|
||||
// 构建 [Product:xxx ||Version:xxx] 格式
|
||||
var info []string
|
||||
if product, ok := serviceInfo.Extras["vendor_product"]; ok && product != "" {
|
||||
info = append(info, fmt.Sprintf("Product:%s", product))
|
||||
}
|
||||
if serviceInfo.Version != "" {
|
||||
info = append(info, fmt.Sprintf("Version:%s", serviceInfo.Version))
|
||||
}
|
||||
if len(info) > 0 {
|
||||
msg.WriteString(fmt.Sprintf(" [%s]", strings.Join(info, " ||")))
|
||||
}
|
||||
|
||||
// Banner 信息
|
||||
if len(serviceInfo.Banner) > 0 {
|
||||
banner := strings.TrimSpace(serviceInfo.Banner)
|
||||
if len(banner) > 80 {
|
||||
banner = banner[:80] + "..."
|
||||
}
|
||||
msg.WriteString(fmt.Sprintf(" Banner:(%s)", banner))
|
||||
}
|
||||
|
||||
return msg.String()
|
||||
}
|
||||
|
||||
// scanSinglePort 扫描单个端口并进行服务识别(重构后的简洁版本)
|
||||
func scanSinglePort(host string, port int, addr string, timeout time.Duration, count *int64, collector *resultCollector, failedCollector *failedPortCollector, config *common.Config, state *common.State) {
|
||||
// 步骤1:建立连接
|
||||
conn, err := connectWithRetry(addr, timeout, 3, state)
|
||||
if err != nil {
|
||||
handleConnectionFailure(err, host, port, addr, failedCollector)
|
||||
return
|
||||
}
|
||||
|
||||
// 步骤1.5:代理连接深度验证(防止透明代理/全回显代理的假连接问题)
|
||||
valid, verifyMethod := verifyProxyConnectionDeep(conn, addr)
|
||||
if !valid {
|
||||
common.LogDebug(fmt.Sprintf("代理验证失败 %s: %s", addr, verifyMethod))
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
|
||||
// 步骤1.6:如果使用了代理且进行了数据交互,需要重建连接
|
||||
// 因为验证阶段可能读取了Banner或发送了HTTP GET探测,污染了连接状态
|
||||
if common.IsProxyEnabled() && verifyMethod != "direct" {
|
||||
_ = conn.Close()
|
||||
// 重新建立干净的连接用于服务识别
|
||||
conn, err = connectWithRetry(addr, timeout, 3, state)
|
||||
if err != nil {
|
||||
handleConnectionFailure(err, host, port, addr, failedCollector)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤2:记录开放端口
|
||||
atomic.AddInt64(count, 1)
|
||||
collector.Add(addr)
|
||||
saveOpenPort(host, port)
|
||||
|
||||
// 步骤3:服务识别(Scanner负责关闭连接,包括探测中可能创建的新连接)
|
||||
scanner := NewSmartPortInfoScanner(host, port, conn, timeout, config)
|
||||
defer scanner.Close()
|
||||
serviceInfo, _ := scanner.SmartIdentify()
|
||||
|
||||
// 步骤4:处理结果
|
||||
processServiceResult(host, port, addr, serviceInfo, config)
|
||||
}
|
||||
|
||||
// handleConnectionFailure 处理连接失败
|
||||
func handleConnectionFailure(err error, host string, port int, addr string, failedCollector *failedPortCollector) {
|
||||
if isResourceExhaustedError(err) || isTimeoutError(err) {
|
||||
failedCollector.Add(host, port, addr)
|
||||
}
|
||||
}
|
||||
|
||||
// isTimeoutError 判断是否为超时错误
|
||||
func isTimeoutError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "i/o timeout")
|
||||
}
|
||||
|
||||
// verifyProxyConnectionDeep 深度验证代理连接是否真正可用
|
||||
// 防止透明代理/全回显代理的假连接问题
|
||||
// 返回: (是否有效, 验证方式)
|
||||
//
|
||||
// 优化策略:
|
||||
// 1. 快速 Banner 检测 (100ms) - 大部分服务会主动发送数据
|
||||
// 2. 轻量探测 (发送 \r\n) - 触发某些服务响应,同时不污染协议状态
|
||||
// 3. 短超时等待 (500ms) - 平衡准确性和性能
|
||||
func verifyProxyConnectionDeep(conn net.Conn, addr string) (bool, string) {
|
||||
// 如果没有使用代理,跳过验证
|
||||
if !common.IsProxyEnabled() {
|
||||
return true, "direct"
|
||||
}
|
||||
|
||||
buf := make([]byte, 256)
|
||||
|
||||
// 阶段1: 读取 Banner (500ms)
|
||||
// 大部分服务(SSH、FTP、SMTP、MySQL等)会主动发送欢迎消息
|
||||
// 不能等太久,否则代理可能因空闲而关闭连接
|
||||
_ = conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
n, _ := conn.Read(buf)
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
|
||||
if n > 0 {
|
||||
if isProxyErrorResponse(buf[:n]) {
|
||||
common.LogDebug(fmt.Sprintf("代理返回错误响应 %s", addr))
|
||||
return false, "proxy_error"
|
||||
}
|
||||
return true, "banner"
|
||||
}
|
||||
|
||||
// 阶段2: HTTP 探针探测(参考 fscanx)
|
||||
// 使用 HTTP GET 而非 CRLF,因为:
|
||||
// - 大部分服务会对 HTTP 请求有明确响应(即使是错误响应)
|
||||
// - 在透明代理环境下能更有效地检测真实连接状态
|
||||
// - 即使是非 HTTP 服务也会返回某种响应或关闭连接
|
||||
httpProbe := []byte("GET / HTTP/1.0\r\n\r\n")
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(100 * time.Millisecond))
|
||||
_, writeErr := conn.Write(httpProbe)
|
||||
_ = conn.SetWriteDeadline(time.Time{})
|
||||
|
||||
if writeErr != nil && isConnectionClosed(writeErr) {
|
||||
common.LogDebug(fmt.Sprintf("探测写入失败 %s: %v", addr, writeErr))
|
||||
return false, "write_failed"
|
||||
}
|
||||
|
||||
// 阶段3: 等待探测响应 (2s)
|
||||
// TUN 模式下代理链路延迟较大,需要更长超时
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
n, readErr := conn.Read(buf)
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
|
||||
if n > 0 {
|
||||
if isProxyErrorResponse(buf[:n]) {
|
||||
common.LogDebug(fmt.Sprintf("代理探测返回错误 %s", addr))
|
||||
return false, "proxy_error"
|
||||
}
|
||||
return true, "probe"
|
||||
}
|
||||
|
||||
// 阶段4: 最终判断
|
||||
if readErr != nil {
|
||||
errLower := strings.ToLower(readErr.Error())
|
||||
for _, pattern := range proxyFailurePatterns {
|
||||
if strings.Contains(errLower, pattern) {
|
||||
common.LogDebug(fmt.Sprintf("代理连接被拒绝 %s: %v", addr, readErr))
|
||||
return false, "proxy_reject"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 无响应 = 端口关闭(参考 fscanx 方案)
|
||||
// 在透明代理环境下,ProxyReliable 检测可能被污染,不可信
|
||||
// 因此采用更保守的策略:无响应一律判定为关闭
|
||||
// 这样可以避免透明代理导致的全端口误报问题
|
||||
common.LogDebug(fmt.Sprintf("代理连接无响应,判定为端口关闭 %s", addr))
|
||||
return false, "no_response"
|
||||
}
|
||||
|
||||
// isProxyErrorResponse 检查是否为代理错误响应
|
||||
// 支持 SOCKS5 错误码和常见代理错误模式
|
||||
func isProxyErrorResponse(data []byte) bool {
|
||||
if len(data) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// SOCKS5 错误响应检查
|
||||
// SOCKS5 响应格式: [VER][REP][RSV][ATYP]...
|
||||
// REP 字段: 0x00=成功, 0x01-0x08=各种失败
|
||||
if len(data) >= 2 && data[0] == 0x05 {
|
||||
rep := data[1]
|
||||
if rep >= 0x01 && rep <= 0x08 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查常见的代理错误文本
|
||||
dataStr := strings.ToLower(string(data))
|
||||
proxyErrorTexts := []string{
|
||||
"connection refused",
|
||||
"host unreachable",
|
||||
"network unreachable",
|
||||
"connection timed out",
|
||||
"proxy error",
|
||||
"gateway error",
|
||||
"bad gateway",
|
||||
"502",
|
||||
"503",
|
||||
}
|
||||
|
||||
for _, errText := range proxyErrorTexts {
|
||||
if strings.Contains(dataStr, errText) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isConnectionClosed 检查错误是否表示连接已关闭
|
||||
func isConnectionClosed(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := strings.ToLower(err.Error())
|
||||
closedPatterns := []string{
|
||||
"broken pipe",
|
||||
"connection reset",
|
||||
"connection refused",
|
||||
"use of closed network connection",
|
||||
"connection was forcibly closed",
|
||||
}
|
||||
|
||||
for _, pattern := range closedPatterns {
|
||||
if strings.Contains(errStr, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// saveOpenPort 保存开放端口结果
|
||||
func saveOpenPort(host string, port int) {
|
||||
_ = common.SaveResult(&output.ScanResult{
|
||||
Time: time.Now(),
|
||||
Type: output.TypePort,
|
||||
Target: host,
|
||||
Status: "open",
|
||||
Details: map[string]interface{}{"port": port},
|
||||
})
|
||||
}
|
||||
|
||||
// processServiceResult 处理服务识别结果
|
||||
func processServiceResult(host string, port int, addr string, serviceInfo *ServiceInfo, config *common.Config) {
|
||||
if serviceInfo == nil {
|
||||
// 服务识别失败,尝试 HTTP 回退探测
|
||||
if !tryHTTPFallbackDetection(host, port, addr, config) {
|
||||
common.LogInfo(i18n.Tr("port_open", addr))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 保存并输出服务信息
|
||||
details := buildServiceDetails(port, serviceInfo)
|
||||
isWeb := IsWebServiceByFingerprint(serviceInfo)
|
||||
|
||||
if isWeb {
|
||||
details["is_web"] = true
|
||||
MarkAsWebService(host, port, serviceInfo)
|
||||
}
|
||||
|
||||
_ = common.SaveResult(&output.ScanResult{
|
||||
Time: time.Now(),
|
||||
Type: output.TypeService,
|
||||
Target: fmt.Sprintf("%s:%d", host, port),
|
||||
Status: "identified",
|
||||
Details: details,
|
||||
})
|
||||
|
||||
common.LogInfo(buildServiceLogMessage(addr, serviceInfo, isWeb))
|
||||
}
|
||||
|
||||
// buildServiceDetails 构建服务详情 map
|
||||
func buildServiceDetails(port int, info *ServiceInfo) map[string]interface{} {
|
||||
details := map[string]interface{}{
|
||||
"port": port,
|
||||
"service": info.Name,
|
||||
}
|
||||
|
||||
if info.Version != "" {
|
||||
details["version"] = info.Version
|
||||
}
|
||||
|
||||
extraKeyMap := map[string]string{
|
||||
"vendor_product": "product",
|
||||
"os": "os",
|
||||
"info": "info",
|
||||
}
|
||||
|
||||
for k, v := range info.Extras {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if mappedKey, ok := extraKeyMap[k]; ok {
|
||||
details[mappedKey] = v
|
||||
}
|
||||
}
|
||||
|
||||
if len(info.Banner) > 0 {
|
||||
details["banner"] = strings.TrimSpace(info.Banner)
|
||||
}
|
||||
|
||||
return details
|
||||
}
|
||||
|
||||
// tryHTTPFallbackDetection 尝试HTTP回退探测,返回是否成功识别为HTTP服务
|
||||
func tryHTTPFallbackDetection(host string, port int, addr string, config *common.Config) bool {
|
||||
// 使用WebDetection进行HTTP协议探测
|
||||
webDetector := GetWebPortDetector()
|
||||
if !webDetector.DetectHTTPServiceOnly(host, port, config) {
|
||||
return false
|
||||
}
|
||||
|
||||
// HTTP探测成功,标记为Web服务
|
||||
webServiceInfo := &ServiceInfo{
|
||||
Name: "http",
|
||||
Version: "",
|
||||
Banner: "",
|
||||
Extras: map[string]string{"detected_by": "http_probe"},
|
||||
}
|
||||
MarkAsWebService(host, port, webServiceInfo)
|
||||
|
||||
// 保存HTTP服务结果
|
||||
details := map[string]interface{}{
|
||||
"port": port,
|
||||
"service": "http",
|
||||
"is_web": true,
|
||||
"detected_by": "http_probe",
|
||||
}
|
||||
_ = common.SaveResult(&output.ScanResult{
|
||||
Time: time.Now(),
|
||||
Type: output.TypeService,
|
||||
Target: fmt.Sprintf("%s:%d", host, port),
|
||||
Status: "identified",
|
||||
Details: details,
|
||||
})
|
||||
|
||||
common.LogInfo(i18n.Tr("port_open_http", addr))
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BenchmarkTCPDial 测试原始 TCP 连接性能(本地回环)
|
||||
func BenchmarkTCPDial(b *testing.B) {
|
||||
// 本地监听一个端口
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
b.Skip("无法创建监听器")
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
addr := listener.Addr().String()
|
||||
|
||||
// 后台接受连接
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkResultCollectorAdd 测试结果收集器添加性能
|
||||
func BenchmarkResultCollectorAdd(b *testing.B) {
|
||||
collector := &resultCollector{}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
collector.Add("192.168.1.1:80")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkResultCollectorAddParallel 测试结果收集器并发添加性能
|
||||
func BenchmarkResultCollectorAddParallel(b *testing.B) {
|
||||
collector := &resultCollector{}
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
collector.Add("192.168.1.1:80")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkResultCollectorGetAll 测试结果收集器获取全部性能
|
||||
func BenchmarkResultCollectorGetAll(b *testing.B) {
|
||||
collector := &resultCollector{}
|
||||
// 预填充数据
|
||||
for i := 0; i < 1000; i++ {
|
||||
collector.Add("192.168.1.1:80")
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = collector.GetAll()
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkFailedPortCollectorAdd 测试失败端口收集器添加性能
|
||||
func BenchmarkFailedPortCollectorAdd(b *testing.B) {
|
||||
collector := &failedPortCollector{}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
collector.Add("192.168.1.1", 80, "192.168.1.1:80")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/*
|
||||
port_scan_test.go - EnhancedPortScan 核心逻辑测试
|
||||
|
||||
注意:EnhancedPortScan 是一个228行的"上帝函数",耦合了:
|
||||
- 网络IO (TCP连接)
|
||||
- 并发控制 (errgroup, semaphore)
|
||||
- 全局状态 (common.*全局变量)
|
||||
- 进度条管理
|
||||
- 服务识别
|
||||
- 结果保存
|
||||
|
||||
这种设计无法进行真正的单元测试。本测试文件:
|
||||
1. 验证核心算法逻辑的正确性(通过独立函数模拟)
|
||||
2. 测试关键计算逻辑(任务数计算、排除端口)
|
||||
3. 不测试网络IO和并发控制(需要集成测试)
|
||||
|
||||
"这函数需要重构,不是测试。200行代码做了太多事情。
|
||||
但既然现在无法重构,我们至少验证算法逻辑是对的。"
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// 核心算法逻辑测试(从EnhancedPortScan提取)
|
||||
// =============================================================================
|
||||
|
||||
// calculateTotalTasks 计算总扫描任务数(从EnhancedPortScan:34-42行提取)
|
||||
// 这是纯函数,可以独立测试
|
||||
func calculateTotalTasks(hosts []string, portList []int, exclude map[int]struct{}) int {
|
||||
totalTasks := 0
|
||||
for range hosts {
|
||||
for _, port := range portList {
|
||||
if _, excluded := exclude[port]; !excluded {
|
||||
totalTasks++
|
||||
}
|
||||
}
|
||||
}
|
||||
return totalTasks
|
||||
}
|
||||
|
||||
// TestCalculateTotalTasks 测试总任务数计算逻辑
|
||||
func TestCalculateTotalTasks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hosts []string
|
||||
portList []int
|
||||
exclude map[int]struct{}
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "单主机单端口-无排除",
|
||||
hosts: []string{"192.168.1.1"},
|
||||
portList: []int{80},
|
||||
exclude: map[int]struct{}{},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "单主机多端口-无排除",
|
||||
hosts: []string{"192.168.1.1"},
|
||||
portList: []int{80, 443, 8080},
|
||||
exclude: map[int]struct{}{},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
name: "多主机单端口-无排除",
|
||||
hosts: []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"},
|
||||
portList: []int{80},
|
||||
exclude: map[int]struct{}{},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
name: "多主机多端口-无排除",
|
||||
hosts: []string{"192.168.1.1", "192.168.1.2"},
|
||||
portList: []int{80, 443, 8080},
|
||||
exclude: map[int]struct{}{},
|
||||
expected: 6, // 2 hosts * 3 ports
|
||||
},
|
||||
{
|
||||
name: "单主机多端口-排除一个",
|
||||
hosts: []string{"192.168.1.1"},
|
||||
portList: []int{80, 443, 8080},
|
||||
exclude: map[int]struct{}{443: {}},
|
||||
expected: 2, // 80, 8080
|
||||
},
|
||||
{
|
||||
name: "多主机多端口-排除多个",
|
||||
hosts: []string{"192.168.1.1", "192.168.1.2"},
|
||||
portList: []int{80, 443, 8080, 3306},
|
||||
exclude: map[int]struct{}{443: {}, 3306: {}},
|
||||
expected: 4, // 2 hosts * 2 ports (80, 8080)
|
||||
},
|
||||
{
|
||||
name: "空主机列表",
|
||||
hosts: []string{},
|
||||
portList: []int{80, 443},
|
||||
exclude: map[int]struct{}{},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "空端口列表",
|
||||
hosts: []string{"192.168.1.1"},
|
||||
portList: []int{},
|
||||
exclude: map[int]struct{}{},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "所有端口都被排除",
|
||||
hosts: []string{"192.168.1.1"},
|
||||
portList: []int{80, 443},
|
||||
exclude: map[int]struct{}{80: {}, 443: {}},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "大规模扫描",
|
||||
hosts: []string{"192.168.1.1", "192.168.1.2", "192.168.1.3", "192.168.1.4", "192.168.1.5"},
|
||||
portList: []int{21, 22, 23, 80, 443, 3306, 3389, 8080, 8443, 9090},
|
||||
exclude: map[int]struct{}{},
|
||||
expected: 50, // 5 hosts * 10 ports
|
||||
},
|
||||
{
|
||||
name: "大规模扫描-部分排除",
|
||||
hosts: []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"},
|
||||
portList: []int{80, 443, 8080, 8443, 3000, 3001, 3002, 3003, 3004, 3005},
|
||||
exclude: map[int]struct{}{8080: {}, 8443: {}},
|
||||
expected: 24, // 3 hosts * 8 ports
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := calculateTotalTasks(tt.hosts, tt.portList, tt.exclude)
|
||||
if result != tt.expected {
|
||||
t.Errorf("calculateTotalTasks() = %d, 期望 %d", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 地址格式化逻辑测试(从EnhancedPortScan:67行提取)
|
||||
// =============================================================================
|
||||
|
||||
// formatAddress 格式化主机:端口地址(从EnhancedPortScan提取)
|
||||
func formatAddress(host string, port int) string {
|
||||
return fmt.Sprintf("%s:%d", host, port)
|
||||
}
|
||||
|
||||
// TestFormatAddress 测试地址格式化
|
||||
func TestFormatAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
port int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "标准IPv4地址",
|
||||
host: "192.168.1.1",
|
||||
port: 80,
|
||||
expected: "192.168.1.1:80",
|
||||
},
|
||||
{
|
||||
name: "域名",
|
||||
host: "example.com",
|
||||
port: 443,
|
||||
expected: "example.com:443",
|
||||
},
|
||||
{
|
||||
name: "localhost",
|
||||
host: "localhost",
|
||||
port: 8080,
|
||||
expected: "localhost:8080",
|
||||
},
|
||||
{
|
||||
name: "高端口号",
|
||||
host: "10.0.0.1",
|
||||
port: 65535,
|
||||
expected: "10.0.0.1:65535",
|
||||
},
|
||||
{
|
||||
name: "低端口号",
|
||||
host: "10.0.0.1",
|
||||
port: 1,
|
||||
expected: "10.0.0.1:1",
|
||||
},
|
||||
{
|
||||
name: "常见HTTP端口",
|
||||
host: "192.168.1.100",
|
||||
port: 80,
|
||||
expected: "192.168.1.100:80",
|
||||
},
|
||||
{
|
||||
name: "常见HTTPS端口",
|
||||
host: "192.168.1.100",
|
||||
port: 443,
|
||||
expected: "192.168.1.100:443",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := formatAddress(tt.host, tt.port)
|
||||
if result != tt.expected {
|
||||
t.Errorf("formatAddress() = %q, 期望 %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 排除端口逻辑测试(从EnhancedPortScan:28-32行提取)
|
||||
// =============================================================================
|
||||
|
||||
// buildExcludeMap 构建排除端口映射(从EnhancedPortScan提取)
|
||||
func buildExcludeMap(excludePorts []int) map[int]struct{} {
|
||||
exclude := make(map[int]struct{}, len(excludePorts))
|
||||
for _, p := range excludePorts {
|
||||
exclude[p] = struct{}{}
|
||||
}
|
||||
return exclude
|
||||
}
|
||||
|
||||
// TestBuildExcludeMap 测试排除端口映射构建
|
||||
func TestBuildExcludeMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
excludePorts []int
|
||||
testPort int
|
||||
shouldExclude bool
|
||||
}{
|
||||
{
|
||||
name: "空排除列表",
|
||||
excludePorts: []int{},
|
||||
testPort: 80,
|
||||
shouldExclude: false,
|
||||
},
|
||||
{
|
||||
name: "单个排除端口-匹配",
|
||||
excludePorts: []int{443},
|
||||
testPort: 443,
|
||||
shouldExclude: true,
|
||||
},
|
||||
{
|
||||
name: "单个排除端口-不匹配",
|
||||
excludePorts: []int{443},
|
||||
testPort: 80,
|
||||
shouldExclude: false,
|
||||
},
|
||||
{
|
||||
name: "多个排除端口-匹配第一个",
|
||||
excludePorts: []int{80, 443, 8080},
|
||||
testPort: 80,
|
||||
shouldExclude: true,
|
||||
},
|
||||
{
|
||||
name: "多个排除端口-匹配中间",
|
||||
excludePorts: []int{80, 443, 8080},
|
||||
testPort: 443,
|
||||
shouldExclude: true,
|
||||
},
|
||||
{
|
||||
name: "多个排除端口-匹配最后",
|
||||
excludePorts: []int{80, 443, 8080},
|
||||
testPort: 8080,
|
||||
shouldExclude: true,
|
||||
},
|
||||
{
|
||||
name: "多个排除端口-不匹配",
|
||||
excludePorts: []int{80, 443, 8080},
|
||||
testPort: 3306,
|
||||
shouldExclude: false,
|
||||
},
|
||||
{
|
||||
name: "大量排除端口",
|
||||
excludePorts: []int{21, 22, 23, 25, 53, 110, 143, 445, 3389},
|
||||
testPort: 3389,
|
||||
shouldExclude: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
excludeMap := buildExcludeMap(tt.excludePorts)
|
||||
|
||||
// 验证映射大小
|
||||
if len(excludeMap) != len(tt.excludePorts) {
|
||||
t.Errorf("excludeMap长度 = %d, 期望 %d", len(excludeMap), len(tt.excludePorts))
|
||||
}
|
||||
|
||||
// 验证端口是否被正确排除
|
||||
_, excluded := excludeMap[tt.testPort]
|
||||
if excluded != tt.shouldExclude {
|
||||
t.Errorf("端口 %d 排除状态 = %v, 期望 %v", tt.testPort, excluded, tt.shouldExclude)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildExcludeMap_DuplicatePorts 测试重复端口处理
|
||||
func TestBuildExcludeMap_DuplicatePorts(t *testing.T) {
|
||||
excludePorts := []int{80, 443, 80, 443, 80}
|
||||
excludeMap := buildExcludeMap(excludePorts)
|
||||
|
||||
// 重复端口应该被去重(map自动去重)
|
||||
if len(excludeMap) != 2 {
|
||||
t.Errorf("excludeMap应自动去重, 期望长度2, 实际 %d", len(excludeMap))
|
||||
}
|
||||
|
||||
// 验证两个端口都存在
|
||||
if _, ok := excludeMap[80]; !ok {
|
||||
t.Error("端口80应在排除列表中")
|
||||
}
|
||||
if _, ok := excludeMap[443]; !ok {
|
||||
t.Error("端口443应在排除列表中")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 集成逻辑测试(任务数计算 + 排除端口)
|
||||
// =============================================================================
|
||||
|
||||
// TestIntegratedTaskCalculation 测试任务计算与排除端口的集成
|
||||
func TestIntegratedTaskCalculation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hosts []string
|
||||
portList []int
|
||||
excludePorts []int
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "无排除-小规模",
|
||||
hosts: []string{"192.168.1.1", "192.168.1.2"},
|
||||
portList: []int{80, 443, 8080},
|
||||
excludePorts: []int{},
|
||||
expected: 6, // 2*3
|
||||
},
|
||||
{
|
||||
name: "有排除-小规模",
|
||||
hosts: []string{"192.168.1.1", "192.168.1.2"},
|
||||
portList: []int{80, 443, 8080},
|
||||
excludePorts: []int{443},
|
||||
expected: 4, // 2*2
|
||||
},
|
||||
{
|
||||
name: "大规模C段扫描",
|
||||
hosts: make([]string, 254), // 模拟254个主机
|
||||
portList: []int{80, 443, 22, 3389, 3306},
|
||||
excludePorts: []int{22}, // 排除SSH
|
||||
expected: 1016, // 254 * 4
|
||||
},
|
||||
{
|
||||
name: "端口全排除",
|
||||
hosts: []string{"192.168.1.1"},
|
||||
portList: []int{80, 443},
|
||||
excludePorts: []int{80, 443},
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 填充大规模测试的hosts
|
||||
if len(tt.hosts) == 254 && tt.hosts[0] == "" {
|
||||
for i := range tt.hosts {
|
||||
tt.hosts[i] = fmt.Sprintf("192.168.1.%d", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
excludeMap := buildExcludeMap(tt.excludePorts)
|
||||
result := calculateTotalTasks(tt.hosts, tt.portList, excludeMap)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("集成测试失败: calculateTotalTasks() = %d, 期望 %d", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 边界情况和错误处理测试
|
||||
// =============================================================================
|
||||
|
||||
// TestCalculateTotalTasks_EdgeCases 测试边界情况
|
||||
func TestCalculateTotalTasks_EdgeCases(t *testing.T) {
|
||||
t.Run("nil主机列表", func(t *testing.T) {
|
||||
result := calculateTotalTasks(nil, []int{80}, map[int]struct{}{})
|
||||
if result != 0 {
|
||||
t.Errorf("nil主机列表应返回0, 实际 %d", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil端口列表", func(t *testing.T) {
|
||||
result := calculateTotalTasks([]string{"192.168.1.1"}, nil, map[int]struct{}{})
|
||||
if result != 0 {
|
||||
t.Errorf("nil端口列表应返回0, 实际 %d", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil排除映射", func(t *testing.T) {
|
||||
result := calculateTotalTasks([]string{"192.168.1.1"}, []int{80}, nil)
|
||||
if result != 1 {
|
||||
t.Errorf("nil排除映射应视为无排除, 期望1, 实际 %d", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("极大端口号", func(t *testing.T) {
|
||||
excludeMap := buildExcludeMap([]int{65535})
|
||||
if _, ok := excludeMap[65535]; !ok {
|
||||
t.Error("应支持最大端口号65535")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("端口号0", func(t *testing.T) {
|
||||
excludeMap := buildExcludeMap([]int{0})
|
||||
if _, ok := excludeMap[0]; !ok {
|
||||
t.Error("应支持端口号0")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 性能基准测试
|
||||
// =============================================================================
|
||||
|
||||
// BenchmarkCalculateTotalTasks 基准测试任务计算性能
|
||||
func BenchmarkCalculateTotalTasks(b *testing.B) {
|
||||
// 模拟C段扫描: 254个主机 * 10个端口
|
||||
hosts := make([]string, 254)
|
||||
for i := range hosts {
|
||||
hosts[i] = fmt.Sprintf("192.168.1.%d", i+1)
|
||||
}
|
||||
portList := []int{21, 22, 80, 443, 3306, 3389, 8080, 8443, 9090, 9200}
|
||||
exclude := map[int]struct{}{}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
calculateTotalTasks(hosts, portList, exclude)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBuildExcludeMap 基准测试排除映射构建性能
|
||||
func BenchmarkBuildExcludeMap(b *testing.B) {
|
||||
excludePorts := []int{21, 22, 23, 25, 53, 110, 143, 445, 3389, 1433}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
buildExcludeMap(excludePorts)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 重构后函数的单元测试
|
||||
// =============================================================================
|
||||
|
||||
// TestBuildServiceLogMessage 测试服务日志消息构建
|
||||
// 新格式: "addr service version/banner"
|
||||
func TestBuildServiceLogMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr string
|
||||
serviceInfo *ServiceInfo
|
||||
isWeb bool
|
||||
wantContain []string // 期望包含的字符串片段
|
||||
}{
|
||||
{
|
||||
name: "基础HTTP服务",
|
||||
addr: "192.168.1.1:80",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "http",
|
||||
Version: "1.1",
|
||||
Banner: "",
|
||||
Extras: map[string]string{},
|
||||
},
|
||||
isWeb: true,
|
||||
wantContain: []string{"192.168.1.1:80", "http", "1.1"},
|
||||
},
|
||||
{
|
||||
name: "带Banner的SSH服务",
|
||||
addr: "10.0.0.1:22",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "ssh",
|
||||
Version: "OpenSSH_8.0",
|
||||
Banner: "SSH-2.0-OpenSSH_8.0",
|
||||
Extras: map[string]string{},
|
||||
},
|
||||
isWeb: false,
|
||||
wantContain: []string{"10.0.0.1:22", "ssh", "SSH-2.0-OpenSSH_8.0"}, // Banner优先于Version
|
||||
},
|
||||
{
|
||||
name: "带扩展信息的服务",
|
||||
addr: "172.16.0.1:3306",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "mysql",
|
||||
Version: "5.7.30",
|
||||
Banner: "",
|
||||
Extras: map[string]string{
|
||||
"vendor_product": "MySQL Community Server",
|
||||
"os": "Linux",
|
||||
"info": "utf8_general_ci",
|
||||
},
|
||||
},
|
||||
isWeb: false,
|
||||
wantContain: []string{"172.16.0.1:3306", "mysql", "5.7.30"}, // 简化格式不包含Extras
|
||||
},
|
||||
{
|
||||
name: "未知服务",
|
||||
addr: "192.168.1.1:8888",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "unknown",
|
||||
Version: "",
|
||||
Banner: "",
|
||||
Extras: map[string]string{},
|
||||
},
|
||||
isWeb: false,
|
||||
wantContain: []string{"192.168.1.1:8888"}, // unknown服务不显示名称
|
||||
},
|
||||
{
|
||||
name: "过长Banner使用Version",
|
||||
addr: "10.0.0.1:21",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "ftp",
|
||||
Version: "2.0",
|
||||
Banner: string(make([]byte, 200)), // 超过100字符的banner
|
||||
Extras: map[string]string{},
|
||||
},
|
||||
isWeb: false,
|
||||
wantContain: []string{"10.0.0.1:21", "ftp", "2.0"}, // Banner超长则用Version
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildServiceLogMessage(tt.addr, tt.serviceInfo, tt.isWeb)
|
||||
|
||||
// 验证所有期望的字符串片段都存在
|
||||
for _, want := range tt.wantContain {
|
||||
if !contains(result, want) {
|
||||
t.Errorf("buildServiceLogMessage() 结果缺少期望内容\n期望包含: %q\n实际结果: %q", want, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// contains 检查字符串是否包含子串
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(s) > 0 && len(substr) > 0 && indexOf(s, substr) >= 0))
|
||||
}
|
||||
|
||||
// indexOf 查找子串位置
|
||||
func indexOf(s, substr string) int {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 资源耗尽错误检测测试
|
||||
// =============================================================================
|
||||
|
||||
/*
|
||||
资源耗尽错误检测 - isResourceExhaustedError 函数测试
|
||||
|
||||
测试价值:资源耗尽检测是生产环境的关键逻辑,错误分类影响重试策略
|
||||
|
||||
"这是真正的业务逻辑。错误分类错了,扫描就会失败或死循环。
|
||||
这种函数必须测试,而且要测真实的错误场景。"
|
||||
*/
|
||||
|
||||
// TestIsResourceExhaustedError_ActualErrors 测试真实的资源耗尽错误
|
||||
func TestIsResourceExhaustedError_ActualErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "文件描述符耗尽-Linux",
|
||||
err: fmt.Errorf("socket: too many open files"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "文件描述符耗尽-直接错误",
|
||||
err: fmt.Errorf("too many open files"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "缓冲区耗尽",
|
||||
err: fmt.Errorf("write: no buffer space available"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "本地端口耗尽",
|
||||
err: fmt.Errorf("dial tcp: cannot assign requested address"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "连接重置-高并发",
|
||||
err: fmt.Errorf("read tcp 192.168.1.1:1234->10.0.0.1:80: connection reset by peer"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "自定义发包限制",
|
||||
err: fmt.Errorf("发包受限"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "nil错误",
|
||||
err: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "普通网络错误-超时",
|
||||
err: fmt.Errorf("dial tcp: i/o timeout"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "普通网络错误-拒绝连接",
|
||||
err: fmt.Errorf("connection refused"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "认证错误",
|
||||
err: fmt.Errorf("authentication failed"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "空字符串错误",
|
||||
err: fmt.Errorf(""),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isResourceExhaustedError(tt.err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isResourceExhaustedError() = %v, want %v (error: %v)",
|
||||
result, tt.expected, tt.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsResourceExhaustedError_EdgeCases 测试边界情况
|
||||
func TestIsResourceExhaustedError_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "大小写混合",
|
||||
err: fmt.Errorf("Too Many Open Files"),
|
||||
expected: false, // 当前实现区分大小写
|
||||
},
|
||||
{
|
||||
name: "错误信息包含但不完全匹配",
|
||||
err: fmt.Errorf("some error with no buffer space available suffix"),
|
||||
expected: true, // strings.Contains会匹配完整短语
|
||||
},
|
||||
{
|
||||
name: "多个错误特征-只需匹配一个",
|
||||
err: fmt.Errorf("too many open files and no buffer space available"),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isResourceExhaustedError(tt.err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isResourceExhaustedError() = %v, want %v (error: %v)",
|
||||
result, tt.expected, tt.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsResourceExhaustedError_ProductionScenarios 测试生产环境真实场景
|
||||
func TestIsResourceExhaustedError_ProductionScenarios(t *testing.T) {
|
||||
// 场景1:ulimit设置太低
|
||||
t.Run("ulimit限制触发", func(t *testing.T) {
|
||||
err := fmt.Errorf("dial tcp 10.0.0.1:22: socket: too many open files")
|
||||
if !isResourceExhaustedError(err) {
|
||||
t.Error("应该识别出ulimit限制错误")
|
||||
}
|
||||
})
|
||||
|
||||
// 场景2:Windows端口耗尽
|
||||
t.Run("Windows端口耗尽", func(t *testing.T) {
|
||||
err := fmt.Errorf("dial tcp :0: bind: cannot assign requested address")
|
||||
if !isResourceExhaustedError(err) {
|
||||
t.Error("应该识别出端口耗尽错误")
|
||||
}
|
||||
})
|
||||
|
||||
// 场景3:并发扫描导致的连接重置
|
||||
t.Run("高并发连接重置", func(t *testing.T) {
|
||||
err := fmt.Errorf("read tcp: connection reset by peer")
|
||||
if !isResourceExhaustedError(err) {
|
||||
t.Error("应该识别出高并发导致的连接重置")
|
||||
}
|
||||
})
|
||||
|
||||
// 场景4:正常的认证失败不应被识别为资源耗尽
|
||||
t.Run("认证失败-不是资源问题", func(t *testing.T) {
|
||||
err := fmt.Errorf("ssh: handshake failed: ssh: unable to authenticate")
|
||||
if isResourceExhaustedError(err) {
|
||||
t.Error("认证失败不应被识别为资源耗尽")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// DecodePattern 解码匹配模式
|
||||
func DecodePattern(s string) ([]byte, error) {
|
||||
b := []byte(s)
|
||||
var result []byte
|
||||
|
||||
for i := 0; i < len(b); {
|
||||
if b[i] == '\\' && i+1 < len(b) {
|
||||
// 处理转义序列
|
||||
switch b[i+1] {
|
||||
case 'x':
|
||||
// 十六进制编码 \xNN
|
||||
if i+3 < len(b) {
|
||||
if hexStr := string(b[i+2 : i+4]); isValidHex(hexStr) {
|
||||
if decoded, err := hex.DecodeString(hexStr); err == nil {
|
||||
result = append(result, decoded...)
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
case 'a':
|
||||
result = append(result, '\a')
|
||||
i += 2
|
||||
continue
|
||||
case 'f':
|
||||
result = append(result, '\f')
|
||||
i += 2
|
||||
continue
|
||||
case 't':
|
||||
result = append(result, '\t')
|
||||
i += 2
|
||||
continue
|
||||
case 'n':
|
||||
result = append(result, '\n')
|
||||
i += 2
|
||||
continue
|
||||
case 'r':
|
||||
result = append(result, '\r')
|
||||
i += 2
|
||||
continue
|
||||
case 'v':
|
||||
result = append(result, '\v')
|
||||
i += 2
|
||||
continue
|
||||
case '\\':
|
||||
result = append(result, '\\')
|
||||
i += 2
|
||||
continue
|
||||
default:
|
||||
// 八进制编码 \NNN
|
||||
if i+1 < len(b) && b[i+1] >= '0' && b[i+1] <= '7' {
|
||||
octalStr := ""
|
||||
j := i + 1
|
||||
for j < len(b) && j < i+4 && b[j] >= '0' && b[j] <= '7' {
|
||||
octalStr += string(b[j])
|
||||
j++
|
||||
}
|
||||
// 使用16位解析避免int8溢出(\377=255超出int8范围)
|
||||
if octal, err := strconv.ParseInt(octalStr, 8, 16); err == nil && octal <= 255 {
|
||||
result = append(result, byte(octal))
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 普通字符
|
||||
result = append(result, b[i])
|
||||
i++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecodeData 解码探测数据
|
||||
func DecodeData(s string) ([]byte, error) {
|
||||
// 移除首尾的分隔符
|
||||
if len(s) > 0 && (s[0] == '"' || s[0] == '\'') {
|
||||
s = s[1:]
|
||||
}
|
||||
if len(s) > 0 && (s[len(s)-1] == '"' || s[len(s)-1] == '\'') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
|
||||
return DecodePattern(s)
|
||||
}
|
||||
|
||||
// isValidHex 检查字符串是否为有效的十六进制
|
||||
func isValidHex(s string) bool {
|
||||
for _, c := range s {
|
||||
if (c < '0' || c > '9') && (c < 'A' || c > 'F') && (c < 'a' || c > 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(s) == 2
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDecodePattern 测试nmap探测数据解码
|
||||
func TestDecodePattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []byte
|
||||
}{
|
||||
{
|
||||
name: "十六进制编码-单字节",
|
||||
input: `\x48`,
|
||||
expected: []byte{0x48}, // 'H'
|
||||
},
|
||||
{
|
||||
name: "十六进制编码-多字节",
|
||||
input: `\x48\x65\x6c\x6c\x6f`,
|
||||
expected: []byte{0x48, 0x65, 0x6c, 0x6c, 0x6f}, // "Hello"
|
||||
},
|
||||
{
|
||||
name: "转义字符-换行",
|
||||
input: `\n`,
|
||||
expected: []byte{'\n'},
|
||||
},
|
||||
{
|
||||
name: "转义字符-回车",
|
||||
input: `\r`,
|
||||
expected: []byte{'\r'},
|
||||
},
|
||||
{
|
||||
name: "转义字符-制表符",
|
||||
input: `\t`,
|
||||
expected: []byte{'\t'},
|
||||
},
|
||||
{
|
||||
name: "转义字符-响铃",
|
||||
input: `\a`,
|
||||
expected: []byte{'\a'},
|
||||
},
|
||||
{
|
||||
name: "转义字符-换页",
|
||||
input: `\f`,
|
||||
expected: []byte{'\f'},
|
||||
},
|
||||
{
|
||||
name: "转义字符-垂直制表符",
|
||||
input: `\v`,
|
||||
expected: []byte{'\v'},
|
||||
},
|
||||
{
|
||||
name: "转义字符-反斜杠",
|
||||
input: `\\`,
|
||||
expected: []byte{'\\'},
|
||||
},
|
||||
{
|
||||
name: "八进制编码-单字节",
|
||||
input: `\101`,
|
||||
expected: []byte{0101}, // 'A' (65)
|
||||
},
|
||||
{
|
||||
name: "八进制编码-两位",
|
||||
input: `\72`,
|
||||
expected: []byte{072}, // ':' (58)
|
||||
},
|
||||
{
|
||||
name: "八进制编码-一位",
|
||||
input: `\7`,
|
||||
expected: []byte{7},
|
||||
},
|
||||
{
|
||||
name: "混合编码-nmap GET请求",
|
||||
input: `GET / HTTP/1.0\r\n\r\n`,
|
||||
expected: []byte("GET / HTTP/1.0\r\n\r\n"),
|
||||
},
|
||||
{
|
||||
name: "混合编码-十六进制+文本",
|
||||
input: `\x48ello`,
|
||||
expected: []byte("Hello"),
|
||||
},
|
||||
{
|
||||
name: "普通文本",
|
||||
input: `Hello World`,
|
||||
expected: []byte("Hello World"),
|
||||
},
|
||||
{
|
||||
name: "空字符串",
|
||||
input: ``,
|
||||
expected: []byte{},
|
||||
},
|
||||
{
|
||||
name: "复杂nmap探测数据",
|
||||
input: `\x00\x00\x00\x01\x02\x03`,
|
||||
expected: []byte{0x00, 0x00, 0x00, 0x01, 0x02, 0x03},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := DecodePattern(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePattern() 错误 = %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, tt.expected) {
|
||||
t.Errorf("DecodePattern() = %v (%q), 期望 %v (%q)",
|
||||
result, string(result), tt.expected, string(tt.expected))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePattern_InvalidHex 测试非法十六进制编码
|
||||
func TestDecodePattern_InvalidHex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
name: "不完整的十六进制-只有\\x",
|
||||
input: `\x`,
|
||||
},
|
||||
{
|
||||
name: "不完整的十六进制-只有一位",
|
||||
input: `\xA`,
|
||||
},
|
||||
{
|
||||
name: "非法十六进制字符",
|
||||
input: `\xGH`,
|
||||
},
|
||||
{
|
||||
name: "十六进制后截断",
|
||||
input: `Hello\x`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := DecodePattern(tt.input)
|
||||
// 非法的十六进制应该被忽略,返回原字符
|
||||
if err != nil {
|
||||
t.Errorf("DecodePattern() 不应返回错误: %v", err)
|
||||
}
|
||||
// 验证至少有输出(即使不正确也不应panic)
|
||||
if result == nil {
|
||||
t.Error("DecodePattern() 不应返回 nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePattern_OctalEdgeCases 测试八进制边界情况
|
||||
func TestDecodePattern_OctalEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []byte
|
||||
}{
|
||||
{
|
||||
name: "八进制最大值int8-127",
|
||||
input: `\177`,
|
||||
expected: []byte{0177}, // 127, int8最大值
|
||||
},
|
||||
{
|
||||
name: "八进制零",
|
||||
input: `\0`,
|
||||
expected: []byte{0},
|
||||
},
|
||||
{
|
||||
name: "八进制混合",
|
||||
input: `\101\102\103`,
|
||||
expected: []byte{'A', 'B', 'C'},
|
||||
},
|
||||
{
|
||||
name: "八进制后跟普通数字",
|
||||
input: `\1018`,
|
||||
expected: []byte{0101, '8'}, // 'A' + '8'
|
||||
},
|
||||
{
|
||||
name: "八进制最大值-255",
|
||||
input: `\377`,
|
||||
expected: []byte{0xFF}, // 255, 八进制最大值
|
||||
},
|
||||
{
|
||||
name: "八进制超出255-按原字符",
|
||||
input: `\777`,
|
||||
expected: []byte{'\\', '7', '7', '7'}, // 超出范围,按原字符处理
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := DecodePattern(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodePattern() 错误 = %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, tt.expected) {
|
||||
t.Errorf("DecodePattern() = %v, 期望 %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeData 测试DecodeData包装器
|
||||
func TestDecodeData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []byte
|
||||
}{
|
||||
{
|
||||
name: "双引号包裹",
|
||||
input: `"Hello"`,
|
||||
expected: []byte("Hello"),
|
||||
},
|
||||
{
|
||||
name: "单引号包裹",
|
||||
input: `'World'`,
|
||||
expected: []byte("World"),
|
||||
},
|
||||
{
|
||||
name: "双引号包裹+转义",
|
||||
input: `"\x48\x65\x6c\x6c\x6f"`,
|
||||
expected: []byte("Hello"),
|
||||
},
|
||||
{
|
||||
name: "无引号",
|
||||
input: `Hello`,
|
||||
expected: []byte("Hello"),
|
||||
},
|
||||
{
|
||||
name: "只有开头引号",
|
||||
input: `"Hello`,
|
||||
expected: []byte("Hello"),
|
||||
},
|
||||
{
|
||||
name: "只有结尾引号",
|
||||
input: `Hello"`,
|
||||
expected: []byte("Hello"),
|
||||
},
|
||||
{
|
||||
name: "空字符串-双引号",
|
||||
input: `""`,
|
||||
expected: []byte{},
|
||||
},
|
||||
{
|
||||
name: "nmap探测数据格式",
|
||||
input: `"GET / HTTP/1.0\r\n\r\n"`,
|
||||
expected: []byte("GET / HTTP/1.0\r\n\r\n"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := DecodeData(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeData() 错误 = %v", err)
|
||||
}
|
||||
if !bytes.Equal(result, tt.expected) {
|
||||
t.Errorf("DecodeData() = %v (%q), 期望 %v (%q)",
|
||||
result, string(result), tt.expected, string(tt.expected))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsValidHex 测试十六进制验证
|
||||
func TestIsValidHex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"合法-数字", "12", true},
|
||||
{"合法-小写字母", "ab", true},
|
||||
{"合法-大写字母", "AB", true},
|
||||
{"合法-混合", "3F", true},
|
||||
{"合法-全0", "00", true},
|
||||
{"合法-全F", "FF", true},
|
||||
{"非法-单字符", "A", false},
|
||||
{"非法-三字符", "ABC", false},
|
||||
{"非法-空字符串", "", false},
|
||||
{"非法-包含G", "AG", false},
|
||||
{"非法-包含特殊字符", "A@", false},
|
||||
{"非法-包含空格", "A ", false},
|
||||
{"非法-汉字", "中文", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isValidHex(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isValidHex(%q) = %v, 期望 %v", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodePattern_RealWorldNmapData 测试真实nmap探测数据
|
||||
func TestDecodePattern_RealWorldNmapData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
desc string
|
||||
}{
|
||||
{
|
||||
name: "HTTP GET请求",
|
||||
input: `GET / HTTP/1.0\r\n\r\n`,
|
||||
desc: "nmap HTTP探测",
|
||||
},
|
||||
{
|
||||
name: "SSH握手",
|
||||
input: `SSH-2.0-OpenSSH_8.0\r\n`,
|
||||
desc: "SSH版本探测",
|
||||
},
|
||||
{
|
||||
name: "MySQL握手",
|
||||
input: `\x00\x00\x00\x0a5.7.0`,
|
||||
desc: "MySQL协议",
|
||||
},
|
||||
{
|
||||
name: "二进制协议",
|
||||
input: `\x00\x01\x02\x03\x04\x05`,
|
||||
desc: "纯二进制数据",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := DecodePattern(tt.input)
|
||||
if err != nil {
|
||||
t.Errorf("%s 解码失败: %v", tt.desc, err)
|
||||
}
|
||||
if len(result) == 0 {
|
||||
t.Errorf("%s 解码结果为空", tt.desc)
|
||||
}
|
||||
t.Logf("%s 解码成功: %d 字节", tt.desc, len(result))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkDecodePattern 基准测试DecodePattern
|
||||
func BenchmarkDecodePattern(b *testing.B) {
|
||||
input := `GET / HTTP/1.0\r\n\r\n`
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = DecodePattern(input)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkDecodePattern_Complex 基准测试复杂编码
|
||||
func BenchmarkDecodePattern_Complex(b *testing.B) {
|
||||
input := `\x48\x65\x6c\x6c\x6f\x20\x57\x6f\x72\x6c\x64\r\n`
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = DecodePattern(input)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// BytesToRegexSafeString 将字节切片转换为 Go regexp 安全的正则表达式模式字符串
|
||||
// 非打印字符和高位字节转换为 \x{NN} 形式,用于编译正则表达式
|
||||
func BytesToRegexSafeString(b []byte) string {
|
||||
var result strings.Builder
|
||||
for _, c := range b {
|
||||
if c < 32 || c >= 128 {
|
||||
// 控制字符和高位字节转换为 \x{NN} 格式
|
||||
result.WriteString(fmt.Sprintf("\\x{%02x}", c))
|
||||
} else {
|
||||
result.WriteByte(c)
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// bytesToLatin1String 将字节切片转换为 Latin-1 字符串
|
||||
// 每个字节直接映射到对应的 Unicode 码点 U+0000-U+00FF
|
||||
// 这样可以与使用 \x{NN} 格式的正则表达式正确匹配
|
||||
func bytesToLatin1String(b []byte) string {
|
||||
runes := make([]rune, len(b))
|
||||
for i, c := range b {
|
||||
runes[i] = rune(c)
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
// parseMatchDirective 解析match/softmatch指令的通用实现
|
||||
func (p *Probe) parseMatchDirective(data, prefix string, isSoft bool) (Match, error) {
|
||||
match := Match{IsSoft: isSoft}
|
||||
|
||||
// 提取指令文本并解析语法
|
||||
matchText := data[len(prefix)+1:]
|
||||
directive := p.getDirectiveSyntax(matchText)
|
||||
|
||||
// 分割文本获取pattern和版本信息
|
||||
textSplited := strings.Split(directive.DirectiveStr, directive.Delimiter)
|
||||
if len(textSplited) == 0 {
|
||||
return match, fmt.Errorf("无效的%s指令格式", prefix)
|
||||
}
|
||||
|
||||
pattern := textSplited[0]
|
||||
versionInfo := strings.Join(textSplited[1:], "")
|
||||
|
||||
// versionInfo 格式是 "flags p/product/ v/version/ ..."
|
||||
// flags 是正则表达式修饰符(如 s、i、si),后面跟空格和版本信息字段
|
||||
// 需要跳过 flags 部分,找到第一个空格开始的版本信息
|
||||
if idx := strings.Index(versionInfo, " "); idx != -1 {
|
||||
versionInfo = versionInfo[idx:]
|
||||
}
|
||||
|
||||
// 解码并编译正则表达式
|
||||
patternUnescaped, decodeErr := DecodePattern(pattern)
|
||||
if decodeErr != nil {
|
||||
return match, decodeErr
|
||||
}
|
||||
|
||||
// 将字节模式转换为 Go regexp 安全的字符串(处理高位字节)
|
||||
safePattern := BytesToRegexSafeString(patternUnescaped)
|
||||
patternCompiled, compileErr := regexp.Compile(safePattern)
|
||||
if compileErr != nil {
|
||||
return match, compileErr
|
||||
}
|
||||
|
||||
match.Service = directive.DirectiveName
|
||||
match.Pattern = pattern
|
||||
match.PatternCompiled = patternCompiled
|
||||
match.VersionInfo = versionInfo
|
||||
|
||||
return match, nil
|
||||
}
|
||||
|
||||
// getMatch 解析match指令获取匹配规则
|
||||
func (p *Probe) getMatch(data string) (Match, error) {
|
||||
return p.parseMatchDirective(data, "match", false)
|
||||
}
|
||||
|
||||
// getSoftMatch 解析softmatch指令获取软匹配规则
|
||||
func (p *Probe) getSoftMatch(data string) (Match, error) {
|
||||
return p.parseMatchDirective(data, "softmatch", true)
|
||||
}
|
||||
|
||||
// MatchPattern 检查响应是否与匹配规则匹配
|
||||
func (m *Match) MatchPattern(response []byte) bool {
|
||||
if m.PatternCompiled == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// 将响应字节转换为 Latin-1 字符串,每个字节映射到对应的 Unicode 码点
|
||||
// 这样正则表达式中的 \x{NN} 可以正确匹配对应的字节值
|
||||
latin1Response := bytesToLatin1String(response)
|
||||
matched := m.PatternCompiled.MatchString(latin1Response)
|
||||
if matched {
|
||||
// 提取匹配到的子组
|
||||
submatches := m.PatternCompiled.FindStringSubmatch(latin1Response)
|
||||
if len(submatches) > 1 {
|
||||
m.FoundItems = submatches[1:] // 排除完整匹配,只保留分组
|
||||
}
|
||||
}
|
||||
|
||||
return matched
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/*
|
||||
match_engine_test.go - 服务指纹匹配引擎测试
|
||||
|
||||
测试重点:
|
||||
1. MatchPattern - 核心匹配逻辑,错误会导致服务识别失败
|
||||
2. 正则表达式子组提取 - 版本信息依赖此功能
|
||||
3. 边界情况 - nil编译器、空响应
|
||||
|
||||
不测试:
|
||||
- getMatch/getSoftMatch - 依赖复杂的probe解析上下文
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// MatchPattern 核心测试
|
||||
// =============================================================================
|
||||
|
||||
// TestMatchPattern_BasicMatching 测试基本匹配功能
|
||||
func TestMatchPattern_BasicMatching(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
response []byte
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "SSH版本匹配",
|
||||
pattern: `SSH-[\d.]+-(.*)`,
|
||||
response: []byte("SSH-2.0-OpenSSH_8.0"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "HTTP协议匹配",
|
||||
pattern: `HTTP/1\.[01] (\d{3})`,
|
||||
response: []byte("HTTP/1.1 200 OK"),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "不匹配",
|
||||
pattern: `SSH-`,
|
||||
response: []byte("HTTP/1.1 200 OK"),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "空响应",
|
||||
pattern: `.*`,
|
||||
response: []byte{},
|
||||
expected: true, // .* 匹配空字符串
|
||||
},
|
||||
{
|
||||
name: "二进制数据匹配",
|
||||
pattern: `^\x00\x01`,
|
||||
response: []byte{0x00, 0x01, 0x02, 0x03},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "MySQL握手匹配",
|
||||
pattern: `^\x00\x00\x00\x0a([\d.]+)`,
|
||||
response: []byte("\x00\x00\x00\x0a5.7.33\x00"),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
compiled, err := regexp.Compile(tt.pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("正则编译失败: %v", err)
|
||||
}
|
||||
|
||||
m := &Match{
|
||||
PatternCompiled: compiled,
|
||||
}
|
||||
|
||||
result := m.MatchPattern(tt.response)
|
||||
if result != tt.expected {
|
||||
t.Errorf("MatchPattern() = %v, 期望 %v", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchPattern_SubgroupExtraction 测试子组提取
|
||||
//
|
||||
// 这是关键功能:版本信息从正则表达式的分组中提取
|
||||
func TestMatchPattern_SubgroupExtraction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
response []byte
|
||||
expectedItems []string
|
||||
}{
|
||||
{
|
||||
name: "提取SSH版本",
|
||||
pattern: `SSH-[\d.]+-(.*)`,
|
||||
response: []byte("SSH-2.0-OpenSSH_8.0"),
|
||||
expectedItems: []string{"OpenSSH_8.0"},
|
||||
},
|
||||
{
|
||||
name: "提取HTTP状态码",
|
||||
pattern: `HTTP/1\.[01] (\d{3}) (.*)`,
|
||||
response: []byte("HTTP/1.1 200 OK"),
|
||||
expectedItems: []string{"200", "OK"},
|
||||
},
|
||||
{
|
||||
name: "提取多个分组",
|
||||
pattern: `(\w+)://([^:/]+):?(\d*)`,
|
||||
response: []byte("https://example.com:443"),
|
||||
expectedItems: []string{"https", "example.com", "443"},
|
||||
},
|
||||
{
|
||||
name: "无分组",
|
||||
pattern: `SSH-2\.0`,
|
||||
response: []byte("SSH-2.0-OpenSSH"),
|
||||
expectedItems: nil, // 无分组时为nil
|
||||
},
|
||||
{
|
||||
name: "可选分组为空",
|
||||
pattern: `HTTP/(\d+)\.(\d+)`,
|
||||
response: []byte("HTTP/1.1 200 OK"),
|
||||
expectedItems: []string{"1", "1"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
compiled, err := regexp.Compile(tt.pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("正则编译失败: %v", err)
|
||||
}
|
||||
|
||||
m := &Match{
|
||||
PatternCompiled: compiled,
|
||||
}
|
||||
|
||||
matched := m.MatchPattern(tt.response)
|
||||
if !matched {
|
||||
t.Fatal("应该匹配成功")
|
||||
}
|
||||
|
||||
// 验证提取的子组
|
||||
if tt.expectedItems == nil {
|
||||
if len(m.FoundItems) != 0 {
|
||||
t.Errorf("FoundItems 应为空,实际 %v", m.FoundItems)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(m.FoundItems) != len(tt.expectedItems) {
|
||||
t.Fatalf("FoundItems 长度 = %d, 期望 %d",
|
||||
len(m.FoundItems), len(tt.expectedItems))
|
||||
}
|
||||
|
||||
for i, expected := range tt.expectedItems {
|
||||
if m.FoundItems[i] != expected {
|
||||
t.Errorf("FoundItems[%d] = %q, 期望 %q",
|
||||
i, m.FoundItems[i], expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchPattern_NilCompiler 测试nil编译器
|
||||
//
|
||||
// 边界情况:如果正则编译失败,PatternCompiled为nil
|
||||
func TestMatchPattern_NilCompiler(t *testing.T) {
|
||||
m := &Match{
|
||||
PatternCompiled: nil,
|
||||
}
|
||||
|
||||
result := m.MatchPattern([]byte("any data"))
|
||||
if result {
|
||||
t.Error("nil编译器应返回false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchPattern_RealWorldServices 测试真实服务指纹
|
||||
func TestMatchPattern_RealWorldServices(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
response []byte
|
||||
expectedService string
|
||||
expectMatch bool
|
||||
}{
|
||||
{
|
||||
name: "OpenSSH",
|
||||
pattern: `SSH-2\.0-OpenSSH[_\d\.p]+`,
|
||||
response: []byte("SSH-2.0-OpenSSH_8.0p1 Ubuntu-6ubuntu0.1"),
|
||||
expectedService: "ssh",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "nginx",
|
||||
pattern: `Server: nginx/?([\d.]+)?`,
|
||||
response: []byte("HTTP/1.1 200 OK\r\nServer: nginx/1.18.0\r\n"),
|
||||
expectedService: "http",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "Redis",
|
||||
pattern: `-ERR wrong number of arguments`,
|
||||
response: []byte("-ERR wrong number of arguments for 'get' command\r\n"),
|
||||
expectedService: "redis",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "MySQL",
|
||||
pattern: `mysql_native_password`,
|
||||
response: []byte("\x00\x00\x00\x0a5.7.33\x00...mysql_native_password\x00"),
|
||||
expectedService: "mysql",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "FTP-220",
|
||||
pattern: `^220[\s-]`,
|
||||
response: []byte("220 (vsFTPd 3.0.3)\r\n"),
|
||||
expectedService: "ftp",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "SMTP-220",
|
||||
pattern: `^220.*SMTP`,
|
||||
response: []byte("220 mail.example.com ESMTP Postfix\r\n"),
|
||||
expectedService: "smtp",
|
||||
expectMatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
compiled, err := regexp.Compile(tt.pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("正则编译失败: %v", err)
|
||||
}
|
||||
|
||||
m := &Match{
|
||||
Service: tt.expectedService,
|
||||
PatternCompiled: compiled,
|
||||
}
|
||||
|
||||
result := m.MatchPattern(tt.response)
|
||||
if result != tt.expectMatch {
|
||||
t.Errorf("服务 %s 匹配失败: 期望 %v, 实际 %v",
|
||||
tt.expectedService, tt.expectMatch, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchPattern_FoundItemsReset 测试FoundItems在多次匹配时的重置
|
||||
func TestMatchPattern_FoundItemsReset(t *testing.T) {
|
||||
compiled, _ := regexp.Compile(`SSH-(\d+)\.(\d+)-(.*)`)
|
||||
|
||||
m := &Match{
|
||||
PatternCompiled: compiled,
|
||||
}
|
||||
|
||||
// 第一次匹配
|
||||
m.MatchPattern([]byte("SSH-2.0-OpenSSH_8.0"))
|
||||
firstItems := make([]string, len(m.FoundItems))
|
||||
copy(firstItems, m.FoundItems)
|
||||
|
||||
// 第二次匹配不同内容
|
||||
m.MatchPattern([]byte("SSH-1.99-Dropbear"))
|
||||
|
||||
// 验证FoundItems被更新
|
||||
if len(m.FoundItems) < 1 {
|
||||
t.Fatal("第二次匹配后FoundItems应有内容")
|
||||
}
|
||||
|
||||
if m.FoundItems[2] == "OpenSSH_8.0" {
|
||||
t.Error("FoundItems 未被更新为新的匹配结果")
|
||||
}
|
||||
|
||||
if m.FoundItems[2] != "Dropbear" {
|
||||
t.Errorf("FoundItems[2] = %q, 期望 Dropbear", m.FoundItems[2])
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Match 结构体属性测试
|
||||
// =============================================================================
|
||||
|
||||
// TestMatch_IsSoftFlag 测试软匹配标志
|
||||
func TestMatch_IsSoftFlag(t *testing.T) {
|
||||
hardMatch := Match{IsSoft: false}
|
||||
softMatch := Match{IsSoft: true}
|
||||
|
||||
if hardMatch.IsSoft {
|
||||
t.Error("硬匹配的IsSoft应为false")
|
||||
}
|
||||
|
||||
if !softMatch.IsSoft {
|
||||
t.Error("软匹配的IsSoft应为true")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 边界情况测试
|
||||
// =============================================================================
|
||||
|
||||
// TestMatchPattern_LargeResponse 测试大响应数据
|
||||
func TestMatchPattern_LargeResponse(t *testing.T) {
|
||||
compiled, _ := regexp.Compile(`needle`)
|
||||
|
||||
m := &Match{
|
||||
PatternCompiled: compiled,
|
||||
}
|
||||
|
||||
// 构造包含关键字的大响应(100KB)
|
||||
largeData := make([]byte, 100*1024)
|
||||
for i := range largeData {
|
||||
largeData[i] = 'x'
|
||||
}
|
||||
copy(largeData[50*1024:], []byte("needle"))
|
||||
|
||||
result := m.MatchPattern(largeData)
|
||||
if !result {
|
||||
t.Error("大响应中的关键字应被匹配")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchPattern_BinaryData 测试二进制数据匹配
|
||||
func TestMatchPattern_BinaryData(t *testing.T) {
|
||||
// 测试二进制数据中的固定字符串匹配
|
||||
compiled, _ := regexp.Compile(`SMB`)
|
||||
|
||||
m := &Match{
|
||||
PatternCompiled: compiled,
|
||||
}
|
||||
|
||||
// SMB协议头包含固定字符串 "SMB"
|
||||
smbResponse := []byte{0x00, 0x00, 0x00, 0x45, 0xff, 'S', 'M', 'B', 0x00}
|
||||
|
||||
result := m.MatchPattern(smbResponse)
|
||||
if !result {
|
||||
t.Error("二进制数据中的SMB字符串应被匹配")
|
||||
}
|
||||
|
||||
// 验证能提取SMB协议版本
|
||||
compiled2, _ := regexp.Compile(`SMBr`)
|
||||
m2 := &Match{PatternCompiled: compiled2}
|
||||
smb2Response := []byte("SMBr\x00\x00\x00\x00")
|
||||
result2 := m2.MatchPattern(smb2Response)
|
||||
if !result2 {
|
||||
t.Error("SMBr应被匹配")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchPattern_UnicodeResponse 测试Latin-1转换后的字节级匹配
|
||||
func TestMatchPattern_UnicodeResponse(t *testing.T) {
|
||||
// bytesToLatin1String 按字节逐个映射到 Latin-1 码点
|
||||
// UTF-8 多字节字符会被拆开,所以用字节级正则匹配
|
||||
raw := []byte("HTTP/1.1 200 OK\r\nServer: test-srv\r\n")
|
||||
compiled, _ := regexp.Compile(`test-srv`)
|
||||
|
||||
m := &Match{
|
||||
PatternCompiled: compiled,
|
||||
}
|
||||
|
||||
result := m.MatchPattern(raw)
|
||||
if !result {
|
||||
t.Error("Latin-1字节级匹配应成功")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 解析指令语法,返回指令结构
|
||||
func (p *Probe) getDirectiveSyntax(data string) (directive Directive) {
|
||||
directive = Directive{}
|
||||
// 查找第一个空格的位置
|
||||
blankIndex := strings.Index(data, " ")
|
||||
if blankIndex == -1 {
|
||||
return directive
|
||||
}
|
||||
|
||||
// 解析各个字段
|
||||
directiveName := data[:blankIndex]
|
||||
Flag := data[blankIndex+1 : blankIndex+2]
|
||||
delimiter := data[blankIndex+2 : blankIndex+3]
|
||||
directiveStr := data[blankIndex+3:]
|
||||
|
||||
directive.DirectiveName = directiveName
|
||||
directive.Flag = Flag
|
||||
directive.Delimiter = delimiter
|
||||
directive.DirectiveStr = directiveStr
|
||||
|
||||
return directive
|
||||
}
|
||||
|
||||
// 解析探测器信息
|
||||
func (p *Probe) parseProbeInfo(probeStr string) {
|
||||
// 提取协议和其他信息
|
||||
proto := probeStr[:4]
|
||||
other := probeStr[4:]
|
||||
|
||||
// 验证协议类型
|
||||
if proto != "TCP " && proto != "UDP " {
|
||||
errMsg := "探测器协议必须是 TCP 或 UDP"
|
||||
panic(errMsg)
|
||||
}
|
||||
|
||||
// 验证其他信息不为空
|
||||
if len(other) == 0 {
|
||||
errMsg := "nmap-service-probes - 探测器名称无效"
|
||||
panic(errMsg)
|
||||
}
|
||||
|
||||
// 解析指令
|
||||
directive := p.getDirectiveSyntax(other)
|
||||
|
||||
// 设置探测器属性
|
||||
p.Name = directive.DirectiveName
|
||||
p.Data = strings.Split(directive.DirectiveStr, directive.Delimiter)[0]
|
||||
p.Protocol = strings.ToLower(strings.TrimSpace(proto))
|
||||
|
||||
}
|
||||
|
||||
// 从字符串解析探测器信息
|
||||
func (p *Probe) fromString(data string) error {
|
||||
var err error
|
||||
|
||||
// 预处理数据
|
||||
data = strings.TrimSpace(data)
|
||||
lines := strings.Split(data, "\n")
|
||||
if len(lines) == 0 {
|
||||
return fmt.Errorf("输入数据为空")
|
||||
}
|
||||
|
||||
probeStr := lines[0]
|
||||
p.parseProbeInfo(probeStr)
|
||||
|
||||
// 解析匹配规则和其他配置
|
||||
var matchs []Match
|
||||
for _, line := range lines {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "match "):
|
||||
match, matchErr := p.getMatch(line)
|
||||
if matchErr != nil {
|
||||
continue
|
||||
}
|
||||
matchs = append(matchs, match)
|
||||
|
||||
case strings.HasPrefix(line, "softmatch "):
|
||||
softMatch, matchErr := p.getSoftMatch(line)
|
||||
if matchErr != nil {
|
||||
continue
|
||||
}
|
||||
matchs = append(matchs, softMatch)
|
||||
|
||||
case strings.HasPrefix(line, "ports "):
|
||||
p.parsePorts(line)
|
||||
|
||||
case strings.HasPrefix(line, "sslports "):
|
||||
p.parseSSLPorts(line)
|
||||
|
||||
case strings.HasPrefix(line, "totalwaitms "):
|
||||
p.parseTotalWaitMS(line)
|
||||
|
||||
case strings.HasPrefix(line, "tcpwrappedms "):
|
||||
p.parseTCPWrappedMS(line)
|
||||
|
||||
case strings.HasPrefix(line, "rarity "):
|
||||
p.parseRarity(line)
|
||||
|
||||
case strings.HasPrefix(line, "fallback "):
|
||||
p.parseFallback(line)
|
||||
}
|
||||
}
|
||||
p.Matchs = &matchs
|
||||
return err
|
||||
}
|
||||
|
||||
// 解析端口配置
|
||||
func (p *Probe) parsePorts(data string) {
|
||||
p.Ports = data[len("ports")+1:]
|
||||
}
|
||||
|
||||
// 解析SSL端口配置
|
||||
func (p *Probe) parseSSLPorts(data string) {
|
||||
p.SSLPorts = data[len("sslports")+1:]
|
||||
}
|
||||
|
||||
// 解析总等待时间
|
||||
func (p *Probe) parseTotalWaitMS(data string) {
|
||||
waitMS, err := strconv.Atoi(strings.TrimSpace(data[len("totalwaitms")+1:]))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p.TotalWaitMS = waitMS
|
||||
}
|
||||
|
||||
// 解析TCP包装等待时间
|
||||
func (p *Probe) parseTCPWrappedMS(data string) {
|
||||
wrappedMS, err := strconv.Atoi(strings.TrimSpace(data[len("tcpwrappedms")+1:]))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p.TCPWrappedMS = wrappedMS
|
||||
}
|
||||
|
||||
// 解析稀有度
|
||||
func (p *Probe) parseRarity(data string) {
|
||||
rarity, err := strconv.Atoi(strings.TrimSpace(data[len("rarity")+1:]))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p.Rarity = rarity
|
||||
}
|
||||
|
||||
// 解析回退配置
|
||||
func (p *Probe) parseFallback(data string) {
|
||||
p.Fallback = data[len("fallback")+1:]
|
||||
}
|
||||
|
||||
// 从内容解析探测器规则
|
||||
func (v *VScan) parseProbesFromContent(content string) {
|
||||
var probes []Probe
|
||||
var lines []string
|
||||
|
||||
// 过滤注释和空行
|
||||
linesTemp := strings.Split(content, "\n")
|
||||
for _, lineTemp := range linesTemp {
|
||||
lineTemp = strings.TrimSpace(lineTemp)
|
||||
if lineTemp == "" || strings.HasPrefix(lineTemp, "#") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, lineTemp)
|
||||
}
|
||||
|
||||
// 验证文件内容
|
||||
if len(lines) == 0 {
|
||||
errMsg := "读取nmap-service-probes文件失败: 内容为空"
|
||||
panic(errMsg)
|
||||
}
|
||||
|
||||
// 检查Exclude指令
|
||||
excludeCount := 0
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "Exclude ") {
|
||||
excludeCount++
|
||||
}
|
||||
if excludeCount > 1 {
|
||||
errMsg := "nmap-service-probes文件中只允许有一个Exclude指令"
|
||||
panic(errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证第一行格式
|
||||
firstLine := lines[0]
|
||||
if !strings.HasPrefix(firstLine, "Exclude ") && !strings.HasPrefix(firstLine, "Probe ") {
|
||||
errMsg := "解析错误: 首行必须以\"Probe \"或\"Exclude \"开头"
|
||||
panic(errMsg)
|
||||
}
|
||||
|
||||
// 处理Exclude指令
|
||||
if excludeCount == 1 {
|
||||
v.Exclude = firstLine[len("Exclude")+1:]
|
||||
lines = lines[1:]
|
||||
}
|
||||
|
||||
// 合并内容并分割探测器
|
||||
content = "\n" + strings.Join(lines, "\n")
|
||||
probeParts := strings.Split(content, "\nProbe")[1:]
|
||||
|
||||
// 解析每个探测器
|
||||
for _, probePart := range probeParts {
|
||||
probe := Probe{}
|
||||
if err := probe.fromString(probePart); err != nil {
|
||||
continue
|
||||
}
|
||||
probes = append(probes, probe)
|
||||
}
|
||||
|
||||
v.AllProbes = probes
|
||||
}
|
||||
|
||||
// 将探测器转换为名称映射
|
||||
func (v *VScan) parseProbesToMapKName() {
|
||||
v.ProbesMapKName = map[string]Probe{}
|
||||
for _, probe := range v.AllProbes {
|
||||
v.ProbesMapKName[probe.Name] = probe
|
||||
}
|
||||
}
|
||||
|
||||
// SetusedProbes 设置使用的探测器
|
||||
func (v *VScan) SetusedProbes() {
|
||||
for _, probe := range v.AllProbes {
|
||||
if strings.ToLower(probe.Protocol) == "tcp" {
|
||||
if probe.Name == "SSLSessionReq" {
|
||||
continue
|
||||
}
|
||||
|
||||
v.Probes = append(v.Probes, probe)
|
||||
// 特殊处理TLS会话请求
|
||||
if probe.Name == "TLSSessionReq" {
|
||||
sslProbe := v.ProbesMapKName["SSLSessionReq"]
|
||||
v.Probes = append(v.Probes, sslProbe)
|
||||
}
|
||||
} else {
|
||||
v.UDPProbes = append(v.UDPProbes, probe)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PortInRange 检查端口是否在指定的端口范围字符串内
|
||||
// 端口范围格式: "21,22,80,1000-2000,8080"
|
||||
func PortInRange(port int, portsStr string) bool {
|
||||
if portsStr == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(portsStr, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否是范围 (如 "1000-2000")
|
||||
if strings.Contains(part, "-") {
|
||||
rangeParts := strings.Split(part, "-")
|
||||
if len(rangeParts) == 2 {
|
||||
start, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))
|
||||
end, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))
|
||||
if err1 == nil && err2 == nil && port >= start && port <= end {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 单个端口
|
||||
p, err := strconv.Atoi(part)
|
||||
if err == nil && p == port {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetProbesForPort 获取适用于指定端口的所有探测器
|
||||
// 根据 Probe.Ports 字段筛选,并按 Rarity 从低到高排序
|
||||
func (v *VScan) GetProbesForPort(port int) []*Probe {
|
||||
var result []*Probe
|
||||
|
||||
for i := range v.Probes {
|
||||
probe := &v.Probes[i]
|
||||
// 跳过 UDP 探测器
|
||||
if probe.Protocol == "udp" {
|
||||
continue
|
||||
}
|
||||
// 检查端口是否在探测器的 ports 范围内
|
||||
if PortInRange(port, probe.Ports) {
|
||||
result = append(result, probe)
|
||||
}
|
||||
}
|
||||
|
||||
// 按 Rarity 从低到高排序 (rarity 越低越优先)
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
// rarity 为 0 表示未设置,视为最低优先级 (放最后)
|
||||
ri, rj := result[i].Rarity, result[j].Rarity
|
||||
if ri == 0 {
|
||||
ri = 10
|
||||
}
|
||||
if rj == 0 {
|
||||
rj = 10
|
||||
}
|
||||
return ri < rj
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetSSLProbesForPort 获取适用于指定端口的 SSL 探测器
|
||||
func (v *VScan) GetSSLProbesForPort(port int) []*Probe {
|
||||
var result []*Probe
|
||||
|
||||
for i := range v.Probes {
|
||||
probe := &v.Probes[i]
|
||||
// 检查端口是否在探测器的 sslports 范围内
|
||||
if PortInRange(port, probe.SSLPorts) {
|
||||
result = append(result, probe)
|
||||
}
|
||||
}
|
||||
|
||||
// 按 Rarity 排序
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
ri, rj := result[i].Rarity, result[j].Rarity
|
||||
if ri == 0 {
|
||||
ri = 10
|
||||
}
|
||||
if rj == 0 {
|
||||
rj = 10
|
||||
}
|
||||
return ri < rj
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAllProbesSortedByRarity 获取所有 TCP 探测器,按 Rarity 排序
|
||||
func (v *VScan) GetAllProbesSortedByRarity() []*Probe {
|
||||
result := make([]*Probe, 0, len(v.Probes))
|
||||
|
||||
for i := range v.Probes {
|
||||
probe := &v.Probes[i]
|
||||
if probe.Protocol != "udp" {
|
||||
result = append(result, probe)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
ri, rj := result[i].Rarity, result[j].Rarity
|
||||
if ri == 0 {
|
||||
ri = 10
|
||||
}
|
||||
if rj == 0 {
|
||||
rj = 10
|
||||
}
|
||||
return ri < rj
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// FilterProbesByIntensity 根据 intensity 过滤探测器
|
||||
// intensity 范围 1-9,默认 7
|
||||
func FilterProbesByIntensity(probes []*Probe, intensity int) []*Probe {
|
||||
if intensity <= 0 {
|
||||
intensity = 7
|
||||
}
|
||||
if intensity > 9 {
|
||||
intensity = 9
|
||||
}
|
||||
|
||||
var result []*Probe
|
||||
for _, probe := range probes {
|
||||
// rarity 为 0 表示未设置,视为 1 (最常用)
|
||||
rarity := probe.Rarity
|
||||
if rarity == 0 {
|
||||
rarity = 1
|
||||
}
|
||||
if rarity <= intensity {
|
||||
result = append(result, probe)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPortInRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
port int
|
||||
portsStr string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "单个端口匹配",
|
||||
port: 80,
|
||||
portsStr: "80",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "单个端口不匹配",
|
||||
port: 81,
|
||||
portsStr: "80",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "端口列表匹配",
|
||||
port: 443,
|
||||
portsStr: "80,443,8080",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "端口列表不匹配",
|
||||
port: 8443,
|
||||
portsStr: "80,443,8080",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "端口范围匹配-起点",
|
||||
port: 1000,
|
||||
portsStr: "1000-2000",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "端口范围匹配-终点",
|
||||
port: 2000,
|
||||
portsStr: "1000-2000",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "端口范围匹配-中间",
|
||||
port: 1500,
|
||||
portsStr: "1000-2000",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "端口范围不匹配-小于起点",
|
||||
port: 999,
|
||||
portsStr: "1000-2000",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "端口范围不匹配-大于终点",
|
||||
port: 2001,
|
||||
portsStr: "1000-2000",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "混合格式匹配-单端口",
|
||||
port: 22,
|
||||
portsStr: "22,80,443,1000-2000,8080",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "混合格式匹配-范围内",
|
||||
port: 1234,
|
||||
portsStr: "22,80,443,1000-2000,8080",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "混合格式不匹配",
|
||||
port: 3000,
|
||||
portsStr: "22,80,443,1000-2000,8080",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "空字符串",
|
||||
port: 80,
|
||||
portsStr: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "带空格的端口列表",
|
||||
port: 443,
|
||||
portsStr: "80, 443, 8080",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Nmap格式-GetRequest探测器端口",
|
||||
port: 8080,
|
||||
portsStr: "80,81,82,83,84,85,86,87,88,89,90,280,443,591,593,623,664,777,808,832,888,901,981,1010,1080,1100,1241,1311,1352,1434,1944,2301,2381,2574,3000,3128,3268,4000,4001,4002,4100,4444,5000,5050,5432,5555,5800,5801,5802,5803,6080,7000,7001,7002,7103,7201,7777,7778,8000,8001,8002,8003,8006,8008,8009,8014,8042,8080,8081,8082,8083,8084,8085,8087,8088,8089,8090,8091,8100,8118,8123,8172,8180,8181,8200,8222,8243,8280,8281,8333,8383,8400,8443,8500,8509,8787,8800,8888,8899,8983,9000,9001,9002,9080,9090,9091,9100,9200,9443,9990,9999,10000,10443,12443,16080,18091,18092,20720,28017",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := PortInRange(tt.port, tt.portsStr)
|
||||
if result != tt.expected {
|
||||
t.Errorf("PortInRange(%d, %q) = %v, want %v", tt.port, tt.portsStr, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProbesForPort(t *testing.T) {
|
||||
// 确保全局 VScan 已初始化
|
||||
InitializeGlobalVScan()
|
||||
v := GetGlobalVScan()
|
||||
|
||||
// 测试常见端口
|
||||
// 注意:SSH(22) 和 MySQL(3306) 等服务在 nmap 规则中不使用 ports 字段
|
||||
// 它们依赖 NULL 探测器(等待服务主动发送 banner)
|
||||
tests := []struct {
|
||||
port int
|
||||
expectFound bool
|
||||
description string
|
||||
}{
|
||||
{port: 80, expectFound: true, description: "HTTP端口应该有探测器"},
|
||||
{port: 22, expectFound: false, description: "SSH端口使用NULL探测(无ports字段)"},
|
||||
{port: 443, expectFound: true, description: "HTTPS端口应该有探测器"},
|
||||
{port: 3306, expectFound: false, description: "MySQL端口使用NULL探测(无ports字段)"},
|
||||
{port: 1, expectFound: true, description: "端口1有GetRequest和Help探测器"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
probes := v.GetProbesForPort(tt.port)
|
||||
if tt.expectFound && len(probes) == 0 {
|
||||
t.Errorf("端口 %d: 期望找到探测器,但找到 %d 个", tt.port, len(probes))
|
||||
}
|
||||
if len(probes) > 0 {
|
||||
t.Logf("端口 %d: 找到 %d 个探测器", tt.port, len(probes))
|
||||
for i, p := range probes {
|
||||
t.Logf(" [%d] %s (rarity=%d)", i+1, p.Name, p.Rarity)
|
||||
}
|
||||
} else {
|
||||
t.Logf("端口 %d: 无特定探测器(使用NULL探测)", tt.port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProbesForPort_RaritySorting(t *testing.T) {
|
||||
InitializeGlobalVScan()
|
||||
v := GetGlobalVScan()
|
||||
|
||||
// 获取端口80的探测器(应该有多个)
|
||||
probes := v.GetProbesForPort(80)
|
||||
if len(probes) < 2 {
|
||||
t.Skip("端口80的探测器数量不足,跳过排序测试")
|
||||
}
|
||||
|
||||
// 验证按 rarity 排序(从低到高)
|
||||
for i := 1; i < len(probes); i++ {
|
||||
prev := probes[i-1].Rarity
|
||||
curr := probes[i].Rarity
|
||||
// 将0视为10(最低优先级)
|
||||
if prev == 0 {
|
||||
prev = 10
|
||||
}
|
||||
if curr == 0 {
|
||||
curr = 10
|
||||
}
|
||||
if prev > curr {
|
||||
t.Errorf("探测器未按rarity排序: probes[%d].Rarity=%d > probes[%d].Rarity=%d",
|
||||
i-1, probes[i-1].Rarity, i, probes[i].Rarity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterProbesByIntensity(t *testing.T) {
|
||||
// 创建模拟探测器
|
||||
probes := []*Probe{
|
||||
{Name: "p1", Rarity: 1},
|
||||
{Name: "p2", Rarity: 3},
|
||||
{Name: "p3", Rarity: 5},
|
||||
{Name: "p4", Rarity: 7},
|
||||
{Name: "p5", Rarity: 9},
|
||||
{Name: "p6", Rarity: 0}, // 0 视为 1
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
intensity int
|
||||
expectedCount int
|
||||
}{
|
||||
{intensity: 1, expectedCount: 2}, // p1, p6
|
||||
{intensity: 3, expectedCount: 3}, // p1, p2, p6
|
||||
{intensity: 5, expectedCount: 4}, // p1, p2, p3, p6
|
||||
{intensity: 7, expectedCount: 5}, // p1, p2, p3, p4, p6
|
||||
{intensity: 9, expectedCount: 6}, // all
|
||||
{intensity: 0, expectedCount: 5}, // 默认7,所以 p1, p2, p3, p4, p6
|
||||
{intensity: -1, expectedCount: 5}, // 默认7
|
||||
{intensity: 10, expectedCount: 6}, // 截断到9
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run("", func(t *testing.T) {
|
||||
result := FilterProbesByIntensity(probes, tt.intensity)
|
||||
if len(result) != tt.expectedCount {
|
||||
t.Errorf("FilterProbesByIntensity(intensity=%d): got %d probes, want %d",
|
||||
tt.intensity, len(result), tt.expectedCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSSLProbesForPort(t *testing.T) {
|
||||
InitializeGlobalVScan()
|
||||
v := GetGlobalVScan()
|
||||
|
||||
// 测试SSL端口
|
||||
sslPorts := []int{443, 465, 636, 993, 995}
|
||||
for _, port := range sslPorts {
|
||||
probes := v.GetSSLProbesForPort(port)
|
||||
t.Logf("SSL端口 %d: 找到 %d 个SSL探测器", port, len(probes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllProbesSortedByRarity(t *testing.T) {
|
||||
InitializeGlobalVScan()
|
||||
v := GetGlobalVScan()
|
||||
|
||||
probes := v.GetAllProbesSortedByRarity()
|
||||
if len(probes) == 0 {
|
||||
t.Fatal("GetAllProbesSortedByRarity 返回空列表")
|
||||
}
|
||||
|
||||
t.Logf("总共 %d 个TCP探测器", len(probes))
|
||||
|
||||
// 验证排序
|
||||
for i := 1; i < len(probes); i++ {
|
||||
prev := probes[i-1].Rarity
|
||||
curr := probes[i].Rarity
|
||||
if prev == 0 {
|
||||
prev = 10
|
||||
}
|
||||
if curr == 0 {
|
||||
curr = 10
|
||||
}
|
||||
if prev > curr {
|
||||
t.Errorf("探测器未按rarity排序: probes[%d].Rarity=%d > probes[%d].Rarity=%d",
|
||||
i-1, probes[i-1].Rarity, i, probes[i].Rarity)
|
||||
}
|
||||
}
|
||||
|
||||
// 打印前10个探测器
|
||||
t.Log("前10个探测器(按rarity排序):")
|
||||
for i := 0; i < 10 && i < len(probes); i++ {
|
||||
t.Logf(" [%d] %s (rarity=%d)", i+1, probes[i].Name, probes[i].Rarity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFallbacksCompilation 验证 fallback 数组编译
|
||||
func TestFallbacksCompilation(t *testing.T) {
|
||||
InitializeGlobalVScan()
|
||||
v := GetGlobalVScan()
|
||||
|
||||
// 获取 NULL 探测器
|
||||
nullProbe, hasNull := v.ProbesMapKName["NULL"]
|
||||
if !hasNull {
|
||||
t.Fatal("NULL 探测器不存在")
|
||||
}
|
||||
|
||||
// 验证 NULL 探测器的 fallback 只包含自身
|
||||
if nullProbe.Fallbacks[0] == nil {
|
||||
t.Error("NULL 探测器的 Fallbacks[0] 为 nil")
|
||||
} else if nullProbe.Fallbacks[0].Name != "NULL" {
|
||||
t.Errorf("NULL 探测器的 Fallbacks[0] 应该是自身,实际是 %s", nullProbe.Fallbacks[0].Name)
|
||||
}
|
||||
t.Log("✓ NULL 探测器的 fallback 只包含自身")
|
||||
|
||||
// 验证 GetRequest 探测器(TCP,无 fallback 指令)
|
||||
getReq, hasGetReq := v.ProbesMapKName["GetRequest"]
|
||||
if hasGetReq {
|
||||
// fallbacks[0] 应该是自身
|
||||
if getReq.Fallbacks[0] == nil || getReq.Fallbacks[0].Name != "GetRequest" {
|
||||
t.Error("GetRequest 的 Fallbacks[0] 应该是自身")
|
||||
}
|
||||
// fallbacks[1] 应该是 NULL(TCP 探测器)
|
||||
if getReq.Protocol == "tcp" && getReq.Fallbacks[1] != nil {
|
||||
t.Logf("✓ GetRequest (TCP) 的 Fallbacks[1] = %s", getReq.Fallbacks[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// 统计有 fallback 数组的探测器数量
|
||||
countWithFallbacks := 0
|
||||
countWithNullFallback := 0
|
||||
for _, probe := range v.Probes {
|
||||
if probe.Fallbacks[0] != nil {
|
||||
countWithFallbacks++
|
||||
}
|
||||
// 检查 TCP 探测器是否有 NULL fallback
|
||||
if probe.Protocol == "tcp" {
|
||||
for i := 0; i < MaxFallbacks+1; i++ {
|
||||
if probe.Fallbacks[i] == nil {
|
||||
break
|
||||
}
|
||||
if probe.Fallbacks[i].Name == "NULL" {
|
||||
countWithNullFallback++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("✓ %d 个探测器有 fallback 数组", countWithFallbacks)
|
||||
t.Logf("✓ %d 个 TCP 探测器有 NULL fallback", countWithNullFallback)
|
||||
}
|
||||
|
||||
// TestFallbacksWithDirective 验证有 fallback 指令的探测器
|
||||
func TestFallbacksWithDirective(t *testing.T) {
|
||||
InitializeGlobalVScan()
|
||||
v := GetGlobalVScan()
|
||||
|
||||
// 查找有 fallback 指令的探测器
|
||||
for _, probe := range v.Probes {
|
||||
if probe.Fallback != "" {
|
||||
t.Logf("探测器 %s 有 fallback 指令: %s", probe.Name, probe.Fallback)
|
||||
|
||||
// 验证 fallback 数组
|
||||
t.Logf(" Fallbacks 数组:")
|
||||
for i := 0; i < MaxFallbacks+1; i++ {
|
||||
if probe.Fallbacks[i] == nil {
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] %s", i, probe.Fallbacks[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ProbeString 嵌入的nmap服务探测数据
|
||||
//
|
||||
//go:embed nmap-service-probes.txt
|
||||
var ProbeString string
|
||||
|
||||
// 全局VScan实例(使用sync.Once确保只初始化一次)
|
||||
var (
|
||||
globalVScan VScan
|
||||
globalNull *Probe
|
||||
globalCommon *Probe
|
||||
vscanOnce sync.Once
|
||||
)
|
||||
|
||||
// Init 初始化VScan对象
|
||||
func (vs *VScan) Init() {
|
||||
vs.parseProbesFromContent(ProbeString)
|
||||
vs.parseProbesToMapKName()
|
||||
vs.SetusedProbes()
|
||||
vs.compileFallbacks() // 编译 fallback 数组
|
||||
}
|
||||
|
||||
// compileFallbacks 编译所有探测器的 fallback 数组
|
||||
// 参考 Nmap 的 AllProbes::compileFallbacks() 实现
|
||||
func (vs *VScan) compileFallbacks() {
|
||||
// 获取 NULL 探测器指针
|
||||
var nullProbe *Probe
|
||||
if np, ok := vs.ProbesMapKName["NULL"]; ok {
|
||||
nullProbe = &np
|
||||
// NULL 探测器的 fallback 只包含自身
|
||||
nullProbe.Fallbacks[0] = nullProbe
|
||||
vs.ProbesMapKName["NULL"] = *nullProbe
|
||||
}
|
||||
|
||||
// 遍历所有探测器,编译 fallback 数组
|
||||
for i := range vs.Probes {
|
||||
probe := &vs.Probes[i]
|
||||
idx := 0
|
||||
|
||||
// fallbacks[0] = 自身
|
||||
probe.Fallbacks[idx] = probe
|
||||
idx++
|
||||
|
||||
if probe.Fallback == "" {
|
||||
// 无 fallback 指令:TCP 使用 [自身, NULL],UDP 使用 [自身]
|
||||
if probe.Protocol == "tcp" && nullProbe != nil {
|
||||
probe.Fallbacks[idx] = nullProbe
|
||||
}
|
||||
} else {
|
||||
// 有 fallback 指令:解析逗号分隔的探测器名称
|
||||
fallbackNames := strings.Split(probe.Fallback, ",")
|
||||
for _, name := range fallbackNames {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if idx >= MaxFallbacks {
|
||||
break
|
||||
}
|
||||
if fbProbe, ok := vs.ProbesMapKName[name]; ok {
|
||||
probe.Fallbacks[idx] = &fbProbe
|
||||
idx++
|
||||
}
|
||||
}
|
||||
// TCP 探测器在末尾添加 NULL 探测器
|
||||
if probe.Protocol == "tcp" && nullProbe != nil && idx < MaxFallbacks {
|
||||
probe.Fallbacks[idx] = nullProbe
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 ProbesMapKName 中的探测器(因为我们修改了 Fallbacks)
|
||||
for i := range vs.Probes {
|
||||
vs.ProbesMapKName[vs.Probes[i].Name] = vs.Probes[i]
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeGlobalVScan 初始化全局VScan实例(线程安全,只执行一次)
|
||||
func InitializeGlobalVScan() {
|
||||
vscanOnce.Do(func() {
|
||||
globalVScan = VScan{}
|
||||
globalVScan.Init()
|
||||
|
||||
// 获取并检查 NULL 探测器
|
||||
if nullProbe, ok := globalVScan.ProbesMapKName["NULL"]; ok {
|
||||
globalNull = &nullProbe
|
||||
}
|
||||
|
||||
// 获取并检查 GenericLines 探测器
|
||||
if genericProbe, ok := globalVScan.ProbesMapKName["GenericLines"]; ok {
|
||||
globalCommon = &genericProbe
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// GetGlobalVScan 获取全局VScan实例
|
||||
func GetGlobalVScan() *VScan {
|
||||
InitializeGlobalVScan() // 确保已初始化
|
||||
return &globalVScan
|
||||
}
|
||||
|
||||
// GetNullProbe 获取NULL探测器
|
||||
func GetNullProbe() *Probe {
|
||||
InitializeGlobalVScan() // 确保已初始化
|
||||
return globalNull
|
||||
}
|
||||
|
||||
// GetCommonProbe 获取通用探测器
|
||||
func GetCommonProbe() *Probe {
|
||||
InitializeGlobalVScan() // 确保已初始化
|
||||
return globalCommon
|
||||
}
|
||||
|
||||
func init() {
|
||||
InitializeGlobalVScan()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// VScan 主扫描器结构体
|
||||
type VScan struct {
|
||||
Exclude string
|
||||
AllProbes []Probe
|
||||
UDPProbes []Probe
|
||||
Probes []Probe
|
||||
ProbesMapKName map[string]Probe
|
||||
}
|
||||
|
||||
// MaxFallbacks 最大 fallback 数量(与 Nmap 一致)
|
||||
const MaxFallbacks = 20
|
||||
|
||||
// Probe 探测器结构体
|
||||
type Probe struct {
|
||||
Name string // 探测器名称
|
||||
Data string // 探测数据
|
||||
Protocol string // 协议
|
||||
Ports string // 端口范围
|
||||
SSLPorts string // SSL端口范围
|
||||
|
||||
TotalWaitMS int // 总等待时间
|
||||
TCPWrappedMS int // TCP包装等待时间
|
||||
Rarity int // 稀有度
|
||||
Fallback string // 回退探测器名称(原始字符串)
|
||||
|
||||
// Fallbacks 编译后的 fallback 探测器数组
|
||||
// 顺序: [自身, fallback指令中的探测器..., NULL探测器(TCP)]
|
||||
Fallbacks [MaxFallbacks + 1]*Probe
|
||||
|
||||
Matchs *[]Match // 匹配规则列表
|
||||
}
|
||||
|
||||
// Match 匹配规则结构体
|
||||
type Match struct {
|
||||
IsSoft bool // 是否为软匹配
|
||||
Service string // 服务名称
|
||||
Pattern string // 匹配模式
|
||||
VersionInfo string // 版本信息格式
|
||||
FoundItems []string // 找到的项目
|
||||
PatternCompiled *regexp.Regexp // 编译后的正则表达式
|
||||
}
|
||||
|
||||
// Directive 指令结构体
|
||||
type Directive struct {
|
||||
DirectiveName string
|
||||
Flag string
|
||||
Delimiter string
|
||||
DirectiveStr string
|
||||
}
|
||||
|
||||
// Extras 额外信息结构体
|
||||
type Extras struct {
|
||||
VendorProduct string
|
||||
Version string
|
||||
Info string
|
||||
Hostname string
|
||||
OperatingSystem string
|
||||
DeviceType string
|
||||
CPE string
|
||||
}
|
||||
|
||||
// Target 目标结构体
|
||||
type Target struct {
|
||||
Host string
|
||||
Port int
|
||||
Timeout int
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 预编译正则表达式
|
||||
var (
|
||||
whitespaceRegex = regexp.MustCompile(`\s+`)
|
||||
|
||||
// 版本信息字段解析正则 - 支持斜线和竖线两种分隔符
|
||||
fieldRegexes = map[string][]*regexp.Regexp{
|
||||
" p": {regexp.MustCompile(` p/([^/]*)/`), regexp.MustCompile(` p\|([^|]*)\|`)},
|
||||
" v": {regexp.MustCompile(` v/([^/]*)/`), regexp.MustCompile(` v\|([^|]*)\|`)},
|
||||
" i": {regexp.MustCompile(` i/([^/]*)/`), regexp.MustCompile(` i\|([^|]*)\|`)},
|
||||
" h": {regexp.MustCompile(` h/([^/]*)/`), regexp.MustCompile(` h\|([^|]*)\|`)},
|
||||
" o": {regexp.MustCompile(` o/([^/]*)/`), regexp.MustCompile(` o\|([^|]*)\|`)},
|
||||
" d": {regexp.MustCompile(` d/([^/]*)/`), regexp.MustCompile(` d\|([^|]*)\|`)},
|
||||
}
|
||||
|
||||
// CPE解析正则
|
||||
cpeRegexSlash = regexp.MustCompile(`cpe:/([^/]*)`)
|
||||
cpeRegexPipe = regexp.MustCompile(`cpe:\|([^|]*)`)
|
||||
)
|
||||
|
||||
// ParseVersionInfo 解析版本信息并返回额外信息结构
|
||||
func (m *Match) ParseVersionInfo(response []byte) Extras {
|
||||
var extras = Extras{}
|
||||
|
||||
// 替换版本信息中的占位符(如 $1, $2 等)
|
||||
versionInfo := m.VersionInfo
|
||||
if len(m.FoundItems) > 0 {
|
||||
replacements := make([]string, 0, len(m.FoundItems)*2)
|
||||
for i, value := range m.FoundItems {
|
||||
replacements = append(replacements, "$"+strconv.Itoa(i+1), value)
|
||||
}
|
||||
versionInfo = strings.NewReplacer(replacements...).Replace(versionInfo)
|
||||
}
|
||||
|
||||
// 定义解析函数 - 使用预编译正则
|
||||
parseField := func(field string) string {
|
||||
regexes, ok := fieldRegexes[field]
|
||||
if !ok || !strings.Contains(versionInfo, field) {
|
||||
return ""
|
||||
}
|
||||
for _, regex := range regexes {
|
||||
if matches := regex.FindStringSubmatch(versionInfo); len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 解析各个字段
|
||||
extras.VendorProduct = parseField(" p")
|
||||
extras.Version = parseField(" v")
|
||||
extras.Info = parseField(" i")
|
||||
extras.Hostname = parseField(" h")
|
||||
extras.OperatingSystem = parseField(" o")
|
||||
extras.DeviceType = parseField(" d")
|
||||
|
||||
// 特殊处理CPE - 使用预编译正则
|
||||
if strings.Contains(versionInfo, " cpe:/") || strings.Contains(versionInfo, " cpe:|") {
|
||||
for _, regex := range []*regexp.Regexp{cpeRegexSlash, cpeRegexPipe} {
|
||||
if matches := regex.FindStringSubmatch(versionInfo); len(matches) > 1 {
|
||||
extras.CPE = matches[1]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return extras
|
||||
}
|
||||
|
||||
// ToMap 将 Extras 转换为 map[string]string
|
||||
func (e *Extras) ToMap() map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
// 定义字段映射
|
||||
fields := map[string]string{
|
||||
"vendor_product": e.VendorProduct,
|
||||
"version": e.Version,
|
||||
"info": e.Info,
|
||||
"hostname": e.Hostname,
|
||||
"os": e.OperatingSystem,
|
||||
"device_type": e.DeviceType,
|
||||
"cpe": e.CPE,
|
||||
}
|
||||
|
||||
// 添加非空字段到结果map
|
||||
for key, value := range fields {
|
||||
if value != "" {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// TrimBanner 清理横幅数据,移除不可打印字符
|
||||
func TrimBanner(banner string) string {
|
||||
// 移除开头和结尾的空白字符
|
||||
banner = strings.TrimSpace(banner)
|
||||
|
||||
// 移除控制字符,但保留换行符和制表符
|
||||
var result strings.Builder
|
||||
for _, r := range banner {
|
||||
if r >= 32 && r <= 126 { // 可打印ASCII字符
|
||||
result.WriteRune(r)
|
||||
} else if r == '\n' || r == '\t' { // 保留换行符和制表符
|
||||
result.WriteRune(r)
|
||||
} else {
|
||||
result.WriteRune(' ') // 其他控制字符替换为空格
|
||||
}
|
||||
}
|
||||
|
||||
// 压缩多个连续空格为单个空格
|
||||
resultStr := result.String()
|
||||
resultStr = whitespaceRegex.ReplaceAllString(resultStr, " ")
|
||||
|
||||
return strings.TrimSpace(resultStr)
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
package portfinger
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/*
|
||||
version_parser_test.go - Banner清理与版本解析测试
|
||||
|
||||
测试目标:TrimBanner 函数
|
||||
价值:Banner清理是服务识别的预处理步骤,错误会导致:
|
||||
- 误识别服务类型
|
||||
- 正则匹配失败
|
||||
- 日志输出混乱(控制字符污染)
|
||||
|
||||
"Banner清理看起来简单,但涉及ASCII控制字符、Unicode、空格压缩。
|
||||
这是真实的网络数据处理,必须测试边界情况。"
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// TrimBanner - Banner清理测试
|
||||
// =============================================================================
|
||||
|
||||
// TestTrimBanner_BasicCases 测试基本的清理功能
|
||||
func TestTrimBanner_BasicCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "普通字符串-无需清理",
|
||||
input: "SSH-2.0-OpenSSH_8.0",
|
||||
expected: "SSH-2.0-OpenSSH_8.0",
|
||||
},
|
||||
{
|
||||
name: "前后有空格",
|
||||
input: " SSH-2.0-OpenSSH_8.0 ",
|
||||
expected: "SSH-2.0-OpenSSH_8.0",
|
||||
},
|
||||
{
|
||||
name: "多个连续空格",
|
||||
input: "SSH 2.0 OpenSSH",
|
||||
expected: "SSH 2.0 OpenSSH",
|
||||
},
|
||||
{
|
||||
name: "空字符串",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "只有空格",
|
||||
input: " ",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "只有制表符",
|
||||
input: "\t\t\t",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := TrimBanner(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("TrimBanner(%q) = %q, want %q",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrimBanner_ControlCharacters 测试控制字符处理
|
||||
func TestTrimBanner_ControlCharacters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "NULL字符-移除",
|
||||
input: "SSH\x00-2.0",
|
||||
expected: "SSH -2.0",
|
||||
},
|
||||
{
|
||||
name: "BEL响铃-移除",
|
||||
input: "SSH\x07-2.0",
|
||||
expected: "SSH -2.0",
|
||||
},
|
||||
{
|
||||
name: "退格符-移除",
|
||||
input: "SSH\x08-2.0",
|
||||
expected: "SSH -2.0",
|
||||
},
|
||||
{
|
||||
name: "ESC转义符-移除控制字符部分",
|
||||
input: "SSH\x1b[31m-2.0",
|
||||
expected: "SSH [31m-2.0", // ESC被移除,但[31m是可打印字符
|
||||
},
|
||||
{
|
||||
name: "DEL删除符-移除",
|
||||
input: "SSH\x7f-2.0",
|
||||
expected: "SSH -2.0",
|
||||
},
|
||||
{
|
||||
name: "多个控制字符",
|
||||
input: "\x01\x02SSH\x03\x04-2.0\x05\x06",
|
||||
expected: "SSH -2.0",
|
||||
},
|
||||
{
|
||||
name: "只有控制字符",
|
||||
input: "\x00\x01\x02\x03\x04\x05",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := TrimBanner(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("TrimBanner(%q) = %q, want %q",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrimBanner_PreservedCharacters 测试保留的特殊字符
|
||||
func TestTrimBanner_PreservedCharacters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "保留换行符",
|
||||
input: "SSH-2.0\nOpenSSH_8.0",
|
||||
expected: "SSH-2.0 OpenSSH_8.0", // 连续空白被压缩
|
||||
},
|
||||
{
|
||||
name: "保留制表符",
|
||||
input: "SSH-2.0\tOpenSSH_8.0",
|
||||
expected: "SSH-2.0 OpenSSH_8.0", // 制表符被压缩为空格
|
||||
},
|
||||
{
|
||||
name: "混合换行符和制表符",
|
||||
input: "SSH\n\t2.0\n\tOpenSSH",
|
||||
expected: "SSH 2.0 OpenSSH",
|
||||
},
|
||||
{
|
||||
name: "多个连续换行符",
|
||||
input: "SSH\n\n\n2.0",
|
||||
expected: "SSH 2.0",
|
||||
},
|
||||
{
|
||||
name: "Windows换行符CRLF",
|
||||
input: "SSH-2.0\r\nOpenSSH_8.0",
|
||||
expected: "SSH-2.0 OpenSSH_8.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := TrimBanner(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("TrimBanner(%q) = %q, want %q",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrimBanner_SpaceCompression 测试空格压缩
|
||||
func TestTrimBanner_SpaceCompression(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "两个空格",
|
||||
input: "SSH 2.0",
|
||||
expected: "SSH 2.0",
|
||||
},
|
||||
{
|
||||
name: "多个空格",
|
||||
input: "SSH 2.0 OpenSSH",
|
||||
expected: "SSH 2.0 OpenSSH",
|
||||
},
|
||||
{
|
||||
name: "混合空白字符",
|
||||
input: "SSH \t \n 2.0",
|
||||
expected: "SSH 2.0",
|
||||
},
|
||||
{
|
||||
name: "开头多个空格",
|
||||
input: " SSH-2.0",
|
||||
expected: "SSH-2.0",
|
||||
},
|
||||
{
|
||||
name: "结尾多个空格",
|
||||
input: "SSH-2.0 ",
|
||||
expected: "SSH-2.0",
|
||||
},
|
||||
{
|
||||
name: "前后和中间都有多余空格",
|
||||
input: " SSH 2.0 OpenSSH ",
|
||||
expected: "SSH 2.0 OpenSSH",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := TrimBanner(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("TrimBanner(%q) = %q, want %q",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrimBanner_ProductionScenarios 测试生产环境真实场景
|
||||
func TestTrimBanner_ProductionScenarios(t *testing.T) {
|
||||
t.Run("SSH服务Banner", func(t *testing.T) {
|
||||
// 真实的SSH banner,可能包含控制字符
|
||||
input := "\x00\x00SSH-2.0-OpenSSH_8.0 Ubuntu\x00\x00"
|
||||
expected := "SSH-2.0-OpenSSH_8.0 Ubuntu"
|
||||
result := TrimBanner(input)
|
||||
if result != expected {
|
||||
t.Errorf("SSH banner清理失败: got %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP服务Banner", func(t *testing.T) {
|
||||
// HTTP响应可能包含多余空白
|
||||
input := " HTTP/1.1 200 OK\r\nServer: nginx/1.18.0 "
|
||||
expected := "HTTP/1.1 200 OK Server: nginx/1.18.0"
|
||||
result := TrimBanner(input)
|
||||
if result != expected {
|
||||
t.Errorf("HTTP banner清理失败: got %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FTP服务Banner", func(t *testing.T) {
|
||||
// FTP欢迎消息,可能包含换行符
|
||||
input := "220\tProFTPD Server\n(Welcome)\n"
|
||||
expected := "220 ProFTPD Server (Welcome)"
|
||||
result := TrimBanner(input)
|
||||
if result != expected {
|
||||
t.Errorf("FTP banner清理失败: got %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MySQL服务Banner", func(t *testing.T) {
|
||||
// MySQL握手包可能包含二进制数据
|
||||
input := "\x00\x00\x005.7.30-log\x00"
|
||||
expected := "5.7.30-log"
|
||||
result := TrimBanner(input)
|
||||
if result != expected {
|
||||
t.Errorf("MySQL banner清理失败: got %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Telnet服务Banner", func(t *testing.T) {
|
||||
// Telnet可能包含ANSI转义序列
|
||||
// 注意:当前实现只移除控制字符,ANSI序列的参数部分(可打印字符)会保留
|
||||
input := "\x1b[2J\x1b[HWelcome to Linux\x1b[0m"
|
||||
expected := "[2J [HWelcome to Linux [0m" // ESC被移除,参数保留
|
||||
result := TrimBanner(input)
|
||||
if result != expected {
|
||||
t.Errorf("Telnet banner清理失败: got %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTrimBanner_EdgeCases 测试边界情况
|
||||
func TestTrimBanner_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "单个字符",
|
||||
input: "S",
|
||||
expected: "S",
|
||||
},
|
||||
{
|
||||
name: "单个空格",
|
||||
input: " ",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "单个控制字符",
|
||||
input: "\x00",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "所有可打印ASCII字符",
|
||||
input: " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
|
||||
expected: "!\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~",
|
||||
},
|
||||
{
|
||||
name: "混合可打印和不可打印字符",
|
||||
input: "A\x00B\x01C\x1fD E",
|
||||
expected: "A B C D E",
|
||||
},
|
||||
{
|
||||
name: "长Banner-1000字符",
|
||||
input: strings.Repeat("SSH-2.0 ", 125), // 1000字符
|
||||
expected: strings.TrimSpace(strings.Repeat("SSH-2.0 ", 125)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := TrimBanner(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("TrimBanner(%q) = %q, want %q",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTrimBanner_ASCIIRanges 测试ASCII范围边界
|
||||
func TestTrimBanner_ASCIIRanges(t *testing.T) {
|
||||
t.Run("ASCII-31-控制字符边界", func(t *testing.T) {
|
||||
// ASCII 0-31 是控制字符(除了\n和\t)
|
||||
input := string([]byte{31, 32, 33}) // US控制符, 空格, !
|
||||
expected := "!" // 31被移除,32变空格被trim,33保留
|
||||
result := TrimBanner(input)
|
||||
if result != expected {
|
||||
t.Errorf("ASCII 31边界测试失败: got %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ASCII-32-空格-最小可打印字符", func(t *testing.T) {
|
||||
input := string([]byte{32}) // 空格
|
||||
expected := "" // trim掉
|
||||
result := TrimBanner(input)
|
||||
if result != expected {
|
||||
t.Errorf("ASCII 32测试失败: got %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ASCII-126-波浪号-最大可打印字符", func(t *testing.T) {
|
||||
input := string([]byte{126}) // ~
|
||||
expected := "~"
|
||||
result := TrimBanner(input)
|
||||
if result != expected {
|
||||
t.Errorf("ASCII 126测试失败: got %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ASCII-127-DEL-控制字符", func(t *testing.T) {
|
||||
input := string([]byte{127}) // DEL
|
||||
expected := "" // 被移除
|
||||
result := TrimBanner(input)
|
||||
if result != expected {
|
||||
t.Errorf("ASCII 127测试失败: got %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTrimBanner_SpecialCases 测试特殊场景
|
||||
func TestTrimBanner_SpecialCases(t *testing.T) {
|
||||
t.Run("换行符保留-但被压缩", func(t *testing.T) {
|
||||
input := "Line1\nLine2"
|
||||
result := TrimBanner(input)
|
||||
// 换行符应该被保留,但被压缩为空格
|
||||
if !strings.Contains(result, "Line1") || !strings.Contains(result, "Line2") {
|
||||
t.Errorf("换行符处理错误: got %q", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("制表符保留-但被压缩", func(t *testing.T) {
|
||||
input := "Col1\tCol2"
|
||||
result := TrimBanner(input)
|
||||
// 制表符应该被保留,但被压缩为空格
|
||||
if !strings.Contains(result, "Col1") || !strings.Contains(result, "Col2") {
|
||||
t.Errorf("制表符处理错误: got %q", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("连续控制字符-被替换为单个空格", func(t *testing.T) {
|
||||
input := "SSH\x00\x01\x02-2.0"
|
||||
result := TrimBanner(input)
|
||||
// 多个控制字符应该被压缩
|
||||
expected := "SSH -2.0"
|
||||
if result != expected {
|
||||
t.Errorf("控制字符压缩错误: got %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空字符串不panic", func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("空字符串导致panic: %v", r)
|
||||
}
|
||||
}()
|
||||
result := TrimBanner("")
|
||||
if result != "" {
|
||||
t.Errorf("空字符串处理错误: got %q", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTrimBanner_PerformanceBaseline 性能基准测试
|
||||
func TestTrimBanner_PerformanceBaseline(t *testing.T) {
|
||||
// 测试大字符串不会超时
|
||||
largeInput := strings.Repeat("SSH-2.0-OpenSSH_8.0 ", 10000) // ~200KB
|
||||
result := TrimBanner(largeInput)
|
||||
if len(result) == 0 {
|
||||
t.Error("大字符串处理失败")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToMap - 结构体转Map测试
|
||||
// =============================================================================
|
||||
|
||||
// TestExtras_ToMap_BasicCases 测试基本的ToMap功能
|
||||
func TestExtras_ToMap_BasicCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
extras Extras
|
||||
expected map[string]string
|
||||
}{
|
||||
{
|
||||
name: "所有字段都有值",
|
||||
extras: Extras{
|
||||
VendorProduct: "Apache httpd",
|
||||
Version: "2.4.41",
|
||||
Info: "Ubuntu",
|
||||
Hostname: "web-server",
|
||||
OperatingSystem: "Linux",
|
||||
DeviceType: "general purpose",
|
||||
CPE: "cpe:/a:apache:http_server:2.4.41",
|
||||
},
|
||||
expected: map[string]string{
|
||||
"vendor_product": "Apache httpd",
|
||||
"version": "2.4.41",
|
||||
"info": "Ubuntu",
|
||||
"hostname": "web-server",
|
||||
"os": "Linux",
|
||||
"device_type": "general purpose",
|
||||
"cpe": "cpe:/a:apache:http_server:2.4.41",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "所有字段都为空",
|
||||
extras: Extras{},
|
||||
expected: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "只有部分字段有值",
|
||||
extras: Extras{
|
||||
VendorProduct: "OpenSSH",
|
||||
Version: "8.0",
|
||||
},
|
||||
expected: map[string]string{
|
||||
"vendor_product": "OpenSSH",
|
||||
"version": "8.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.extras.ToMap()
|
||||
|
||||
// 验证长度
|
||||
if len(result) != len(tt.expected) {
|
||||
t.Errorf("ToMap() 返回map长度 = %d, want %d",
|
||||
len(result), len(tt.expected))
|
||||
}
|
||||
|
||||
// 验证每个字段
|
||||
for key, expectedValue := range tt.expected {
|
||||
if actualValue, ok := result[key]; !ok {
|
||||
t.Errorf("ToMap() 缺少字段 %q", key)
|
||||
} else if actualValue != expectedValue {
|
||||
t.Errorf("ToMap()[%q] = %q, want %q",
|
||||
key, actualValue, expectedValue)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证没有多余字段
|
||||
for key := range result {
|
||||
if _, ok := tt.expected[key]; !ok {
|
||||
t.Errorf("ToMap() 包含意外字段 %q = %q",
|
||||
key, result[key])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtras_ToMap_EmptyStringFiltering 测试空字符串过滤
|
||||
func TestExtras_ToMap_EmptyStringFiltering(t *testing.T) {
|
||||
t.Run("空字符串不应出现在map中", func(t *testing.T) {
|
||||
extras := Extras{
|
||||
VendorProduct: "Apache",
|
||||
Version: "", // 空
|
||||
Info: "Ubuntu",
|
||||
Hostname: "", // 空
|
||||
OperatingSystem: "",
|
||||
DeviceType: "",
|
||||
CPE: "",
|
||||
}
|
||||
|
||||
result := extras.ToMap()
|
||||
|
||||
// 应该只有两个非空字段
|
||||
if len(result) != 2 {
|
||||
t.Errorf("ToMap() 应该过滤空字符串, got length %d, want 2", len(result))
|
||||
}
|
||||
|
||||
// 验证空字段不存在
|
||||
emptyFields := []string{"version", "hostname", "os", "device_type", "cpe"}
|
||||
for _, field := range emptyFields {
|
||||
if _, exists := result[field]; exists {
|
||||
t.Errorf("ToMap() 不应包含空字段 %q", field)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/common/i18n"
|
||||
"github.com/shadow1ng/fscan/common/output"
|
||||
"github.com/shadow1ng/fscan/plugins"
|
||||
"github.com/shadow1ng/fscan/webscan/lib"
|
||||
)
|
||||
|
||||
// ScanStrategy 定义扫描策略接口
|
||||
type ScanStrategy interface {
|
||||
Execute(config *common.Config, state *common.State, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup)
|
||||
GetPlugins(config *common.Config) ([]string, bool)
|
||||
IsPluginApplicableByName(pluginName string, targetHost string, targetPort int, isCustomMode bool, config *common.Config) bool
|
||||
}
|
||||
|
||||
// ScanMode 扫描模式类型
|
||||
type ScanMode int
|
||||
|
||||
const (
|
||||
ScanModeService ScanMode = iota // 默认:服务扫描
|
||||
ScanModeAlive // 仅存活检测
|
||||
ScanModeLocal // 本地插件
|
||||
ScanModeWeb // Web扫描
|
||||
)
|
||||
|
||||
// strategyInfo 策略信息
|
||||
type strategyInfo struct {
|
||||
factory func() ScanStrategy
|
||||
logKey string
|
||||
}
|
||||
|
||||
var strategyRegistry = map[ScanMode]strategyInfo{
|
||||
ScanModeAlive: {func() ScanStrategy { return NewAliveScanStrategy() }, "scan_mode_alive_selected"},
|
||||
ScanModeLocal: {func() ScanStrategy { return NewLocalScanStrategy() }, "scan_mode_local_selected"},
|
||||
ScanModeWeb: {func() ScanStrategy { return NewWebScanStrategy() }, "scan_mode_web_selected"},
|
||||
ScanModeService: {func() ScanStrategy { return NewServiceScanStrategy() }, "scan_mode_service_selected"},
|
||||
}
|
||||
|
||||
// determineScanMode 根据配置和状态确定扫描模式
|
||||
func determineScanMode(config *common.Config, state *common.State) ScanMode {
|
||||
switch {
|
||||
case config.AliveOnly || config.Mode == "icmp":
|
||||
return ScanModeAlive
|
||||
case config.LocalMode:
|
||||
return ScanModeLocal
|
||||
case len(state.GetURLs()) > 0:
|
||||
return ScanModeWeb
|
||||
default:
|
||||
return ScanModeService
|
||||
}
|
||||
}
|
||||
|
||||
// selectStrategy 根据扫描模式选择策略
|
||||
func selectStrategy(config *common.Config, state *common.State, info common.HostInfo) ScanStrategy {
|
||||
mode := determineScanMode(config, state)
|
||||
|
||||
if info, ok := strategyRegistry[mode]; ok {
|
||||
return info.factory()
|
||||
}
|
||||
|
||||
// 后备:默认服务扫描(理论上不会执行到这里)
|
||||
return NewServiceScanStrategy()
|
||||
}
|
||||
|
||||
// RunScan 执行整体扫描流程
|
||||
func RunScan(info common.HostInfo, config *common.Config, state *common.State) {
|
||||
// 初始化HTTP客户端(静默,无需日志)
|
||||
if err := lib.Inithttp(config); err != nil {
|
||||
common.LogError(i18n.Tr("http_client_init_failed", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 选择策略
|
||||
strategy := selectStrategy(config, state, info)
|
||||
|
||||
// 并发控制初始化
|
||||
ch := make(chan struct{}, config.ThreadNum)
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
// 执行策略
|
||||
strategy.Execute(config, state, info, ch, &wg)
|
||||
|
||||
// 等待所有扫描完成
|
||||
wg.Wait()
|
||||
|
||||
// 检查是否有活跃的连接需要维持
|
||||
if state.IsReverseShellActive() || state.IsSocks5ProxyActive() || state.IsForwardShellActive() {
|
||||
if state.IsReverseShellActive() {
|
||||
common.LogInfo(i18n.GetText("active_reverse_shell"))
|
||||
}
|
||||
if state.IsSocks5ProxyActive() {
|
||||
common.LogInfo(i18n.GetText("active_socks5_proxy"))
|
||||
}
|
||||
if state.IsForwardShellActive() {
|
||||
common.LogInfo(i18n.GetText("active_forward_shell"))
|
||||
}
|
||||
common.LogInfo(i18n.GetText("press_ctrl_c_exit"))
|
||||
|
||||
// 优雅等待信号
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
<-sigChan
|
||||
common.LogInfo(i18n.GetText("received_exit_signal"))
|
||||
}
|
||||
|
||||
// 完成扫描
|
||||
finishScan(config, state)
|
||||
}
|
||||
|
||||
// finishScan 完成扫描并输出结果
|
||||
func finishScan(config *common.Config, state *common.State) {
|
||||
// 确保进度条正确完成
|
||||
if common.IsProgressActive() {
|
||||
common.FinishProgressBar()
|
||||
}
|
||||
|
||||
// 输出扫描完成信息
|
||||
common.LogInfo(i18n.Tr("scan_task_complete", time.Since(state.GetStartTime()).Round(time.Millisecond), state.GetNum()))
|
||||
|
||||
// 输出性能统计 JSON(如果启用)
|
||||
if config.Output.PerfStats {
|
||||
fmt.Printf("\n[PERF_STATS_JSON]%s[/PERF_STATS_JSON]\n", state.GetPerfStatsJSON())
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteScanTasks 任务执行通用框架
|
||||
func ExecuteScanTasks(config *common.Config, state *common.State, targets []common.HostInfo, strategy ScanStrategy, ch chan struct{}, wg *sync.WaitGroup) {
|
||||
// 获取要执行的插件
|
||||
pluginsToRun, isCustomMode := strategy.GetPlugins(config)
|
||||
|
||||
// 预计算任务数量用于进度条
|
||||
taskCount := countApplicableTasks(targets, pluginsToRun, isCustomMode, strategy, config)
|
||||
|
||||
// 初始化进度条
|
||||
if taskCount > 0 && config.Output.ShowProgress {
|
||||
description := i18n.GetText("progress_scanning_description")
|
||||
common.InitProgressBar(int64(taskCount), description)
|
||||
}
|
||||
|
||||
// 流式执行任务,避免预构建大量任务对象
|
||||
for _, target := range targets {
|
||||
targetPort := target.Port
|
||||
|
||||
for _, pluginName := range pluginsToRun {
|
||||
// 使用Exists检查避免不必要的插件实例创建
|
||||
if !plugins.Exists(pluginName) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查插件是否适用于当前目标
|
||||
if strategy.IsPluginApplicableByName(pluginName, target.Host, targetPort, isCustomMode, config) {
|
||||
executeScanTask(config, state, pluginName, target, ch, wg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// countApplicableTasks 计算适用的任务数量
|
||||
func countApplicableTasks(targets []common.HostInfo, pluginsToRun []string, isCustomMode bool, strategy ScanStrategy, config *common.Config) int {
|
||||
count := 0
|
||||
for _, target := range targets {
|
||||
targetPort := target.Port
|
||||
|
||||
for _, pluginName := range pluginsToRun {
|
||||
// 使用Exists检查避免不必要的插件实例创建
|
||||
if plugins.Exists(pluginName) &&
|
||||
strategy.IsPluginApplicableByName(pluginName, target.Host, targetPort, isCustomMode, config) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// executeScanTask 执行单个扫描任务
|
||||
func executeScanTask(config *common.Config, state *common.State, pluginName string, target common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
|
||||
wg.Add(1)
|
||||
ch <- struct{}{} // 获取并发槽位
|
||||
|
||||
go func() {
|
||||
// 开始监控插件任务
|
||||
monitor := common.GetConcurrencyMonitor()
|
||||
monitor.StartPluginTask()
|
||||
|
||||
defer func() {
|
||||
// 捕获并记录任何可能的panic
|
||||
if r := recover(); r != nil {
|
||||
common.LogError(i18n.Tr("plugin_panic", pluginName, target.Host, target.Port, r))
|
||||
}
|
||||
|
||||
// 更新统计和进度(任务真正完成时才更新)
|
||||
state.IncrementNum()
|
||||
common.UpdateProgressBar(1)
|
||||
|
||||
// 完成任务,释放资源
|
||||
monitor.FinishPluginTask()
|
||||
wg.Done()
|
||||
<-ch // 释放并发槽位
|
||||
}()
|
||||
|
||||
plugin := plugins.Get(pluginName)
|
||||
if plugin != nil {
|
||||
result := plugin.Scan(context.Background(), &target, config, state)
|
||||
if result != nil {
|
||||
if result.Success {
|
||||
// 保存成功的扫描结果到文件
|
||||
savePluginResult(&target, pluginName, result)
|
||||
} else if result.Type == plugins.ResultTypeCredential {
|
||||
// 凭据测试完成但未发现弱密码,在error级别输出提示
|
||||
common.LogError(i18n.Tr("brute_no_weak_pass", target.Host, target.Port, pluginName))
|
||||
} else if result.Error != nil {
|
||||
// 其他类型的错误
|
||||
common.LogError(i18n.Tr("plugin_scan_error", target.Host, target.Port, result.Error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// resultSerializer 结果序列化信息
|
||||
type resultSerializer struct {
|
||||
outputType output.ResultType
|
||||
getStatus func(*plugins.Result, *common.HostInfo) string
|
||||
fillDetail func(*plugins.Result, *common.HostInfo, map[string]interface{})
|
||||
}
|
||||
|
||||
var resultSerializers = map[plugins.ResultType]resultSerializer{
|
||||
plugins.ResultTypeCredential: {
|
||||
outputType: output.TypeVuln,
|
||||
getStatus: func(r *plugins.Result, _ *common.HostInfo) string {
|
||||
return fmt.Sprintf("weak_credential: %s:%s", r.Username, r.Password)
|
||||
},
|
||||
fillDetail: func(r *plugins.Result, _ *common.HostInfo, d map[string]interface{}) {
|
||||
d["service"] = r.Service
|
||||
d["username"] = r.Username
|
||||
d["password"] = r.Password
|
||||
d["type"] = "weak_credential"
|
||||
},
|
||||
},
|
||||
plugins.ResultTypeService: {
|
||||
outputType: output.TypeService,
|
||||
getStatus: func(r *plugins.Result, _ *common.HostInfo) string {
|
||||
if r.Banner != "" {
|
||||
return r.Banner
|
||||
}
|
||||
return r.Service
|
||||
},
|
||||
fillDetail: func(r *plugins.Result, _ *common.HostInfo, d map[string]interface{}) {
|
||||
if r.Banner != "" {
|
||||
d["banner"] = r.Banner
|
||||
}
|
||||
if r.Service != "" {
|
||||
d["service"] = r.Service
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins.ResultTypeVuln: {
|
||||
outputType: output.TypeVuln,
|
||||
getStatus: func(r *plugins.Result, _ *common.HostInfo) string {
|
||||
// 优先使用VulInfo,为空则回退到Banner
|
||||
if r.VulInfo != "" {
|
||||
return r.VulInfo
|
||||
}
|
||||
return r.Banner
|
||||
},
|
||||
fillDetail: func(r *plugins.Result, _ *common.HostInfo, d map[string]interface{}) {
|
||||
// 优先使用VulInfo,为空则回退到Banner
|
||||
vuln := r.VulInfo
|
||||
if vuln == "" {
|
||||
vuln = r.Banner
|
||||
}
|
||||
d["vulnerability"] = vuln
|
||||
d["service"] = r.Service
|
||||
},
|
||||
},
|
||||
plugins.ResultTypeWeb: {
|
||||
outputType: output.TypeService,
|
||||
getStatus: func(_ *plugins.Result, _ *common.HostInfo) string { return "web" },
|
||||
fillDetail: func(_ *plugins.Result, info *common.HostInfo, d map[string]interface{}) {
|
||||
d["is_web"] = true
|
||||
d["port"] = info.Port
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var defaultSerializer = resultSerializer{
|
||||
outputType: output.TypeService,
|
||||
getStatus: func(r *plugins.Result, _ *common.HostInfo) string {
|
||||
if r.Banner != "" {
|
||||
return r.Banner
|
||||
}
|
||||
if r.Service != "" {
|
||||
return r.Service
|
||||
}
|
||||
return "detected"
|
||||
},
|
||||
fillDetail: func(_ *plugins.Result, _ *common.HostInfo, _ map[string]interface{}) {},
|
||||
}
|
||||
|
||||
// savePluginResult 保存插件扫描结果
|
||||
func savePluginResult(info *common.HostInfo, pluginName string, result *plugins.Result) {
|
||||
if result == nil || !result.Success || result.Skipped {
|
||||
return
|
||||
}
|
||||
|
||||
// 获取序列化器
|
||||
serializer, ok := resultSerializers[result.Type]
|
||||
if !ok {
|
||||
serializer = defaultSerializer
|
||||
}
|
||||
|
||||
// 构建详情
|
||||
details := map[string]interface{}{"plugin": pluginName}
|
||||
serializer.fillDetail(result, info, details)
|
||||
|
||||
// 添加通用字段
|
||||
addCommonDetails(result, details)
|
||||
|
||||
// 保存结果
|
||||
target := info.Target()
|
||||
_ = common.SaveResult(&output.ScanResult{
|
||||
Time: time.Now(),
|
||||
Type: serializer.outputType,
|
||||
Target: target,
|
||||
Status: serializer.getStatus(result, info),
|
||||
Details: details,
|
||||
})
|
||||
}
|
||||
|
||||
// addCommonDetails 添加通用详情字段
|
||||
func addCommonDetails(result *plugins.Result, details map[string]interface{}) {
|
||||
if len(result.Fingerprints) > 0 {
|
||||
details["fingerprints"] = result.Fingerprints
|
||||
}
|
||||
if result.Title != "" {
|
||||
details["title"] = result.Title
|
||||
}
|
||||
if result.Status != 0 {
|
||||
details["status"] = result.Status
|
||||
}
|
||||
if result.Server != "" {
|
||||
details["server"] = result.Server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
/*
|
||||
scanner_test.go - Scanner核心逻辑测试
|
||||
|
||||
注意:scanner.go 包含大量副作用(HTTP初始化、信号处理、并发控制)。
|
||||
本测试文件专注于可测试的纯逻辑和算法正确性:
|
||||
1. 策略选择逻辑(selectStrategy) - 测试4种扫描模式的优先级
|
||||
2. 端口解析逻辑(parsePort) - 测试端口范围验证(1-65535)
|
||||
3. 任务计数逻辑验证(countApplicableTasks) - 使用mock策略测试
|
||||
|
||||
测试发现并修复的Bug:
|
||||
- Bug #1: strconv.Atoi接受负数端口(如 "-80" 被解析为 -80)✅ 已修复
|
||||
- Bug #2: strconv.Atoi不验证端口范围(如 "99999" 被解析为 99999,超出65535)✅ 已修复
|
||||
|
||||
修复方案:
|
||||
在scanner.go中添加了 parsePort() 辅助函数,验证端口范围 (1-65535)。
|
||||
非法端口会被记录到日志并返回0,避免传递给插件系统导致未定义行为。
|
||||
|
||||
"这代码需要依赖注入,不是测试。但既然现在无法重构,
|
||||
我们至少验证策略选择和任务计数的逻辑是对的。
|
||||
更重要的是,测试发现了两个真实的bug,并且都修复了。"
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// 核心逻辑测试:策略选择
|
||||
// =============================================================================
|
||||
|
||||
// TestSelectStrategy 测试策略选择逻辑
|
||||
func TestSelectStrategy(t *testing.T) {
|
||||
// 保存原始配置
|
||||
cfg := common.GetGlobalConfig()
|
||||
state := common.GetGlobalState()
|
||||
origAliveOnly := cfg.AliveOnly
|
||||
origMode := cfg.Mode
|
||||
origLocalMode := cfg.LocalMode
|
||||
origURLs := state.GetURLs()
|
||||
defer func() {
|
||||
cfg.AliveOnly = origAliveOnly
|
||||
cfg.Mode = origMode
|
||||
cfg.LocalMode = origLocalMode
|
||||
state.SetURLs(origURLs)
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupConfig func()
|
||||
expectedType string
|
||||
info common.HostInfo
|
||||
}{
|
||||
{
|
||||
name: "存活检测模式-AliveOnly优先级最高",
|
||||
setupConfig: func() {
|
||||
cfg.AliveOnly = true
|
||||
cfg.Mode = ""
|
||||
cfg.LocalMode = false
|
||||
state.SetURLs(nil)
|
||||
},
|
||||
expectedType: "*core.AliveScanStrategy",
|
||||
info: common.HostInfo{Host: "192.168.1.1"},
|
||||
},
|
||||
{
|
||||
name: "存活检测模式-ScanMode=icmp",
|
||||
setupConfig: func() {
|
||||
cfg.AliveOnly = false
|
||||
cfg.Mode = "icmp"
|
||||
cfg.LocalMode = false
|
||||
state.SetURLs(nil)
|
||||
},
|
||||
expectedType: "*core.AliveScanStrategy",
|
||||
info: common.HostInfo{Host: "192.168.1.1"},
|
||||
},
|
||||
{
|
||||
name: "本地模式-LocalMode",
|
||||
setupConfig: func() {
|
||||
cfg.AliveOnly = false
|
||||
cfg.Mode = ""
|
||||
cfg.LocalMode = true
|
||||
state.SetURLs(nil)
|
||||
},
|
||||
expectedType: "*core.LocalScanStrategy",
|
||||
info: common.HostInfo{Host: "localhost"},
|
||||
},
|
||||
{
|
||||
name: "Web扫描模式-URLs非空",
|
||||
setupConfig: func() {
|
||||
cfg.AliveOnly = false
|
||||
cfg.Mode = ""
|
||||
cfg.LocalMode = false
|
||||
state.SetURLs([]string{"http://example.com"})
|
||||
},
|
||||
expectedType: "*core.WebScanStrategy",
|
||||
info: common.HostInfo{Host: "example.com"},
|
||||
},
|
||||
{
|
||||
name: "服务扫描模式-默认",
|
||||
setupConfig: func() {
|
||||
cfg.AliveOnly = false
|
||||
cfg.Mode = ""
|
||||
cfg.LocalMode = false
|
||||
state.SetURLs(nil)
|
||||
},
|
||||
expectedType: "*core.ServiceScanStrategy",
|
||||
info: common.HostInfo{Host: "192.168.1.1", Port: 22},
|
||||
},
|
||||
{
|
||||
name: "优先级测试-AliveOnly覆盖LocalMode",
|
||||
setupConfig: func() {
|
||||
cfg.AliveOnly = true
|
||||
cfg.Mode = ""
|
||||
cfg.LocalMode = true // 被AliveOnly覆盖
|
||||
state.SetURLs(nil)
|
||||
},
|
||||
expectedType: "*core.AliveScanStrategy",
|
||||
info: common.HostInfo{Host: "localhost"},
|
||||
},
|
||||
{
|
||||
name: "优先级测试-LocalMode覆盖URLs",
|
||||
setupConfig: func() {
|
||||
cfg.AliveOnly = false
|
||||
cfg.Mode = ""
|
||||
cfg.LocalMode = true
|
||||
state.SetURLs([]string{"http://example.com"}) // 被LocalMode覆盖
|
||||
},
|
||||
expectedType: "*core.LocalScanStrategy",
|
||||
info: common.HostInfo{Host: "localhost"},
|
||||
},
|
||||
{
|
||||
name: "优先级测试-URLs覆盖默认服务扫描",
|
||||
setupConfig: func() {
|
||||
cfg.AliveOnly = false
|
||||
cfg.Mode = ""
|
||||
cfg.LocalMode = false
|
||||
state.SetURLs([]string{"http://example.com"})
|
||||
},
|
||||
expectedType: "*core.WebScanStrategy",
|
||||
info: common.HostInfo{Host: "192.168.1.1", Port: 80},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 设置配置
|
||||
tt.setupConfig()
|
||||
|
||||
// 执行策略选择
|
||||
strategy := selectStrategy(cfg, state, tt.info)
|
||||
|
||||
// 验证策略类型
|
||||
strategyType := fmt.Sprintf("%T", strategy)
|
||||
if strategyType != tt.expectedType {
|
||||
t.Errorf("selectStrategy() 类型 = %s, 期望 %s", strategyType, tt.expectedType)
|
||||
}
|
||||
|
||||
// 验证策略不为nil
|
||||
if strategy == nil {
|
||||
t.Error("selectStrategy() 返回 nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectStrategy_AllModesDisabled 测试所有模式禁用时的默认行为
|
||||
func TestSelectStrategy_AllModesDisabled(t *testing.T) {
|
||||
// 保存原始配置
|
||||
cfg := common.GetGlobalConfig()
|
||||
state := common.GetGlobalState()
|
||||
origAliveOnly := cfg.AliveOnly
|
||||
origMode := cfg.Mode
|
||||
origLocalMode := cfg.LocalMode
|
||||
origURLs := state.GetURLs()
|
||||
defer func() {
|
||||
cfg.AliveOnly = origAliveOnly
|
||||
cfg.Mode = origMode
|
||||
cfg.LocalMode = origLocalMode
|
||||
state.SetURLs(origURLs)
|
||||
}()
|
||||
|
||||
// 设置所有模式为禁用状态
|
||||
cfg.AliveOnly = false
|
||||
cfg.Mode = ""
|
||||
cfg.LocalMode = false
|
||||
state.SetURLs(nil)
|
||||
|
||||
info := common.HostInfo{Host: "192.168.1.1"}
|
||||
strategy := selectStrategy(cfg, state, info)
|
||||
|
||||
// 应该返回默认的ServiceScanStrategy
|
||||
expectedType := "*core.ServiceScanStrategy"
|
||||
strategyType := fmt.Sprintf("%T", strategy)
|
||||
if strategyType != expectedType {
|
||||
t.Errorf("默认策略类型 = %s, 期望 %s", strategyType, expectedType)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// 任务计数逻辑测试(需要mock策略)
|
||||
// =============================================================================
|
||||
|
||||
// mockStrategy 用于测试的mock策略
|
||||
type mockStrategy struct {
|
||||
plugins []string
|
||||
isCustomMode bool
|
||||
applicablePlugins map[string]bool // pluginName -> isApplicable
|
||||
}
|
||||
|
||||
func (m *mockStrategy) Execute(config *common.Config, state *common.State, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
|
||||
}
|
||||
|
||||
func (m *mockStrategy) GetPlugins() ([]string, bool) {
|
||||
return m.plugins, m.isCustomMode
|
||||
}
|
||||
|
||||
func (m *mockStrategy) IsPluginApplicableByName(pluginName string, targetHost string, targetPort int, isCustomMode bool) bool {
|
||||
if m.applicablePlugins == nil {
|
||||
return true // 默认都适用
|
||||
}
|
||||
return m.applicablePlugins[pluginName]
|
||||
}
|
||||
|
||||
// TestCountApplicableTasks 测试任务计数逻辑
|
||||
func TestCountApplicableTasks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
targets []common.HostInfo
|
||||
strategy *mockStrategy
|
||||
setupPlugins func()
|
||||
expected int
|
||||
}{
|
||||
{
|
||||
name: "空目标列表",
|
||||
targets: []common.HostInfo{},
|
||||
strategy: &mockStrategy{
|
||||
plugins: []string{"ssh", "mysql"},
|
||||
isCustomMode: false,
|
||||
},
|
||||
setupPlugins: func() {},
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "单目标单插件",
|
||||
targets: []common.HostInfo{
|
||||
{Host: "192.168.1.1", Port: 22},
|
||||
},
|
||||
strategy: &mockStrategy{
|
||||
plugins: []string{"ssh"},
|
||||
isCustomMode: false,
|
||||
},
|
||||
setupPlugins: func() {},
|
||||
expected: 1, // 取决于插件是否存在
|
||||
},
|
||||
{
|
||||
name: "单目标多插件",
|
||||
targets: []common.HostInfo{
|
||||
{Host: "192.168.1.1", Port: 22},
|
||||
},
|
||||
strategy: &mockStrategy{
|
||||
plugins: []string{"ssh", "mysql", "redis"},
|
||||
isCustomMode: false,
|
||||
},
|
||||
setupPlugins: func() {},
|
||||
expected: 3, // 假设所有插件都存在且适用
|
||||
},
|
||||
{
|
||||
name: "多目标单插件",
|
||||
targets: []common.HostInfo{
|
||||
{Host: "192.168.1.1", Port: 22},
|
||||
{Host: "192.168.1.2", Port: 22},
|
||||
{Host: "192.168.1.3", Port: 22},
|
||||
},
|
||||
strategy: &mockStrategy{
|
||||
plugins: []string{"ssh"},
|
||||
isCustomMode: false,
|
||||
},
|
||||
setupPlugins: func() {},
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
name: "多目标多插件",
|
||||
targets: []common.HostInfo{
|
||||
{Host: "192.168.1.1", Port: 22},
|
||||
{Host: "192.168.1.2", Port: 80},
|
||||
},
|
||||
strategy: &mockStrategy{
|
||||
plugins: []string{"ssh", "http"},
|
||||
isCustomMode: false,
|
||||
},
|
||||
setupPlugins: func() {},
|
||||
expected: 4, // 2 targets * 2 plugins
|
||||
},
|
||||
{
|
||||
name: "部分插件不适用",
|
||||
targets: []common.HostInfo{
|
||||
{Host: "192.168.1.1", Port: 22},
|
||||
{Host: "192.168.1.2", Port: 80},
|
||||
},
|
||||
strategy: &mockStrategy{
|
||||
plugins: []string{"ssh", "http", "mysql"},
|
||||
isCustomMode: false,
|
||||
applicablePlugins: map[string]bool{
|
||||
"ssh": true,
|
||||
"http": true,
|
||||
"mysql": false, // mysql不适用
|
||||
},
|
||||
},
|
||||
setupPlugins: func() {},
|
||||
expected: 4, // 2 targets * 2 applicable plugins
|
||||
},
|
||||
{
|
||||
name: "空端口-端口为0",
|
||||
targets: []common.HostInfo{
|
||||
{Host: "192.168.1.1", Port: 0},
|
||||
},
|
||||
strategy: &mockStrategy{
|
||||
plugins: []string{"ssh"},
|
||||
isCustomMode: false,
|
||||
},
|
||||
setupPlugins: func() {},
|
||||
expected: 1,
|
||||
},
|
||||
{
|
||||
name: "非法端口-解析为0",
|
||||
targets: []common.HostInfo{
|
||||
{Host: "192.168.1.1", Port: 0},
|
||||
},
|
||||
strategy: &mockStrategy{
|
||||
plugins: []string{"ssh"},
|
||||
isCustomMode: false,
|
||||
},
|
||||
setupPlugins: func() {},
|
||||
expected: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.setupPlugins()
|
||||
|
||||
// 注意:实际的countApplicableTasks依赖plugins.Exists()
|
||||
// 这里我们只能测试逻辑结构,无法验证实际插件系统
|
||||
// 这是"上帝函数"的典型问题:无法mock依赖
|
||||
|
||||
// 提取纯逻辑测试
|
||||
count := 0
|
||||
for _, target := range tt.targets {
|
||||
targetPort := target.Port
|
||||
pluginsToRun, isCustomMode := tt.strategy.GetPlugins()
|
||||
|
||||
for _, pluginName := range pluginsToRun {
|
||||
// 跳过plugins.Exists检查(无法mock)
|
||||
if tt.strategy.IsPluginApplicableByName(pluginName, target.Host, targetPort, isCustomMode) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count != tt.expected {
|
||||
t.Errorf("任务计数 = %d, 期望 %d", count, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 边界情况测试
|
||||
// =============================================================================
|
||||
|
||||
// TestSelectStrategy_EmptyHostInfo 测试空HostInfo的策略选择
|
||||
func TestSelectStrategy_EmptyHostInfo(t *testing.T) {
|
||||
// 保存原始配置
|
||||
cfg := common.GetGlobalConfig()
|
||||
state := common.GetGlobalState()
|
||||
origAliveOnly := cfg.AliveOnly
|
||||
origMode := cfg.Mode
|
||||
origLocalMode := cfg.LocalMode
|
||||
origURLs := state.GetURLs()
|
||||
defer func() {
|
||||
cfg.AliveOnly = origAliveOnly
|
||||
cfg.Mode = origMode
|
||||
cfg.LocalMode = origLocalMode
|
||||
state.SetURLs(origURLs)
|
||||
}()
|
||||
|
||||
cfg.AliveOnly = false
|
||||
cfg.Mode = ""
|
||||
cfg.LocalMode = false
|
||||
state.SetURLs(nil)
|
||||
|
||||
emptyInfo := common.HostInfo{}
|
||||
strategy := selectStrategy(cfg, state, emptyInfo)
|
||||
|
||||
if strategy == nil {
|
||||
t.Error("selectStrategy() 不应对空HostInfo返回nil")
|
||||
}
|
||||
|
||||
// 应该返回默认策略
|
||||
expectedType := "*core.ServiceScanStrategy"
|
||||
strategyType := fmt.Sprintf("%T", strategy)
|
||||
if strategyType != expectedType {
|
||||
t.Errorf("空HostInfo策略类型 = %s, 期望 %s", strategyType, expectedType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountApplicableTasks_EmptyPlugins 测试空插件列表
|
||||
func TestCountApplicableTasks_EmptyPlugins(t *testing.T) {
|
||||
targets := []common.HostInfo{
|
||||
{Host: "192.168.1.1", Port: 22},
|
||||
}
|
||||
|
||||
strategy := &mockStrategy{
|
||||
plugins: []string{},
|
||||
isCustomMode: false,
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, target := range targets {
|
||||
targetPort := target.Port
|
||||
pluginsToRun, isCustomMode := strategy.GetPlugins()
|
||||
|
||||
for _, pluginName := range pluginsToRun {
|
||||
if strategy.IsPluginApplicableByName(pluginName, target.Host, targetPort, isCustomMode) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if count != 0 {
|
||||
t.Errorf("空插件列表应返回0任务, 实际 %d", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/core/portfinger"
|
||||
)
|
||||
|
||||
// 默认超时时间常量
|
||||
const (
|
||||
defaultTotalWaitMS = 6000 // Nmap 默认等待时间
|
||||
defaultIntensity = 7 // 默认探测强度 (1-9)
|
||||
)
|
||||
|
||||
// sslSecondProbes SSL服务二次探测的探针名称
|
||||
var sslSecondProbes = []string{"TerminalServerCookie", "TerminalServer"}
|
||||
|
||||
// Probe PortFinger探测器类型别名 - 简化引用
|
||||
type (
|
||||
Probe = portfinger.Probe
|
||||
// Match PortFinger匹配规则类型别名
|
||||
Match = portfinger.Match
|
||||
)
|
||||
|
||||
// PortFinger全局访问 - 简化探测器访问
|
||||
var (
|
||||
v = portfinger.GetGlobalVScan()
|
||||
null = portfinger.GetNullProbe()
|
||||
commonProbe = portfinger.GetCommonProbe()
|
||||
DecodeData = portfinger.DecodeData
|
||||
)
|
||||
|
||||
// readBufPool 读取缓冲区对象池,复用 2KB 缓冲区减少 GC 压力
|
||||
var readBufPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
buf := make([]byte, 2*1024)
|
||||
return &buf
|
||||
},
|
||||
}
|
||||
|
||||
// ServiceInfo 定义服务识别的结果信息
|
||||
type ServiceInfo struct {
|
||||
Name string // 服务名称,如 http、ssh 等
|
||||
Banner string // 服务返回的横幅信息
|
||||
Version string // 服务版本号
|
||||
Extras map[string]string // 其他额外信息,如操作系统、产品名等
|
||||
}
|
||||
|
||||
// Result 定义单次探测的结果
|
||||
type Result struct {
|
||||
Service Service // 识别出的服务信息
|
||||
Banner string // 服务横幅
|
||||
Extras map[string]string // 额外信息
|
||||
Send []byte // 发送的探测数据
|
||||
Recv []byte // 接收到的响应数据
|
||||
}
|
||||
|
||||
// Service 定义服务的基本信息
|
||||
type Service struct {
|
||||
Name string // 服务名称
|
||||
Extras map[string]string // 服务的额外属性
|
||||
}
|
||||
|
||||
// Info 定义单个端口探测的上下文信息
|
||||
type Info struct {
|
||||
Address string // 目标IP地址
|
||||
Port int // 目标端口
|
||||
Conn net.Conn // 网络连接
|
||||
Result Result // 探测结果
|
||||
Found bool // 是否成功识别服务
|
||||
config *common.Config // 配置引用
|
||||
readTimeoutMS int // 当前读取超时时间(毫秒)
|
||||
}
|
||||
|
||||
// SmartPortInfoScanner 智能服务识别器:保持nmap准确性,优化网络交互
|
||||
type SmartPortInfoScanner struct {
|
||||
Address string
|
||||
Port int
|
||||
Conn net.Conn
|
||||
Timeout time.Duration
|
||||
info *Info
|
||||
config *common.Config // 配置引用
|
||||
}
|
||||
|
||||
// 预定义的基础探测器已在PortFinger.go中定义,这里不再重复定义
|
||||
|
||||
// NewSmartPortInfoScanner 创建智能服务识别器
|
||||
func NewSmartPortInfoScanner(addr string, port int, conn net.Conn, timeout time.Duration, config *common.Config) *SmartPortInfoScanner {
|
||||
return &SmartPortInfoScanner{
|
||||
Address: addr,
|
||||
Port: port,
|
||||
Conn: conn,
|
||||
Timeout: timeout,
|
||||
config: config,
|
||||
info: &Info{
|
||||
Address: addr,
|
||||
Port: port,
|
||||
Conn: conn,
|
||||
config: config,
|
||||
Result: Result{
|
||||
Service: Service{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭Scanner持有的连接(包括探测过程中可能创建的新连接)
|
||||
func (s *SmartPortInfoScanner) Close() {
|
||||
if s.info != nil && s.info.Conn != nil {
|
||||
_ = s.info.Conn.Close()
|
||||
s.info.Conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// SmartIdentify 智能服务识别:Banner优先 + 优化的探测策略
|
||||
// 返回值: (服务信息, 错误)
|
||||
// 注意:TCP连接成功后,端口必然开放,不应该再改变这个判断
|
||||
func (s *SmartPortInfoScanner) SmartIdentify() (*ServiceInfo, error) {
|
||||
// 第一阶段:读取初始Banner(大部分服务会主动发送)
|
||||
_, _ = s.tryInitialBanner()
|
||||
|
||||
// 如果初始Banner已识别,返回结果
|
||||
if s.info.Found {
|
||||
serviceInfo := s.buildServiceInfo()
|
||||
// SSL 多阶段探测
|
||||
serviceInfo = s.performSSLSecondStage(serviceInfo)
|
||||
return serviceInfo, nil
|
||||
}
|
||||
|
||||
// 第二阶段:智能探测策略(减少探测器数量)
|
||||
s.smartProbeStrategy()
|
||||
|
||||
// 构造返回结果
|
||||
serviceInfo := s.buildServiceInfo()
|
||||
|
||||
// SSL 多阶段探测(对所有服务进行检查)
|
||||
serviceInfo = s.performSSLSecondStage(serviceInfo)
|
||||
|
||||
return serviceInfo, nil
|
||||
}
|
||||
|
||||
// tryInitialBanner 尝试读取服务主动发送的Banner
|
||||
// 返回值: (响应数据, 错误)
|
||||
func (s *SmartPortInfoScanner) tryInitialBanner() ([]byte, error) {
|
||||
// 读取初始响应
|
||||
response, err := s.info.Read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(response) > 0 {
|
||||
// 使用原有的nmap指纹库解析Banner,保持准确性
|
||||
_ = s.info.tryProbes(response, []*Probe{null, commonProbe})
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
|
||||
// smartProbeStrategy 智能探测策略
|
||||
// 改进版:使用 nmap-service-probes.txt 中的 ports 字段和 rarity 排序
|
||||
func (s *SmartPortInfoScanner) smartProbeStrategy() {
|
||||
usedProbes := make(map[string]struct{})
|
||||
|
||||
// 阶段1:尝试端口特定探测器(使用 Probe.Ports,按 Rarity 排序)
|
||||
// 注意:端口特定探测器不按 intensity 过滤,因为它们是专门为该端口设计的
|
||||
portProbes := v.GetProbesForPort(s.Port)
|
||||
if len(portProbes) > 0 {
|
||||
if s.tryProbeList(portProbes, usedProbes) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段2:尝试 SSL 端口探测器(使用 Probe.SSLPorts)
|
||||
sslProbes := v.GetSSLProbesForPort(s.Port)
|
||||
if len(sslProbes) > 0 {
|
||||
if s.tryProbeList(sslProbes, usedProbes) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段3:回退到通用探测器(按 Rarity 排序,按 intensity 过滤)
|
||||
allProbes := v.GetAllProbesSortedByRarity()
|
||||
allProbes = portfinger.FilterProbesByIntensity(allProbes, defaultIntensity)
|
||||
// 限制回退探测器数量,避免过度探测
|
||||
maxFallback := 5
|
||||
if len(allProbes) > maxFallback {
|
||||
allProbes = allProbes[:maxFallback]
|
||||
}
|
||||
s.tryProbeList(allProbes, usedProbes)
|
||||
|
||||
// 如果所有探测都失败,标记为未知服务
|
||||
if s.info.Result.Service.Name == "" {
|
||||
s.info.Result.Service.Name = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// tryProbeList 尝试探测器列表
|
||||
// 使用 Probe.TotalWaitMS 设置动态超时,实现隐式 NULL 回退
|
||||
func (s *SmartPortInfoScanner) tryProbeList(probes []*Probe, usedProbes map[string]struct{}) bool {
|
||||
for _, probe := range probes {
|
||||
if _, used := usedProbes[probe.Name]; used {
|
||||
continue
|
||||
}
|
||||
usedProbes[probe.Name] = struct{}{}
|
||||
|
||||
probeData, err := DecodeData(probe.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用 TotalWaitMS 设置动态超时
|
||||
waitMS := probe.TotalWaitMS
|
||||
if waitMS <= 0 {
|
||||
waitMS = defaultTotalWaitMS
|
||||
}
|
||||
s.info.setReadTimeout(waitMS)
|
||||
|
||||
response := s.info.Connect(probeData)
|
||||
if len(response) == 0 {
|
||||
// 连接可能被关闭(如服务端返回 EOF),尝试重建连接后继续下一个探针
|
||||
s.reconnectIfNeeded()
|
||||
continue
|
||||
}
|
||||
|
||||
// 尝试匹配(GetInfo 会自动遍历 fallback 数组,包含 NULL 回退)
|
||||
s.info.GetInfo(response, probe)
|
||||
if s.info.Found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// reconnectIfNeeded 强制重建连接
|
||||
// 当探针收到空响应时调用,说明连接可能已被服务端关闭
|
||||
func (s *SmartPortInfoScanner) reconnectIfNeeded() {
|
||||
// 关闭旧连接
|
||||
if s.info.Conn != nil {
|
||||
_ = s.info.Conn.Close()
|
||||
s.info.Conn = nil
|
||||
s.Conn = nil
|
||||
}
|
||||
|
||||
// 重新建立连接
|
||||
newConn, err := common.WrapperTcpWithTimeout("tcp", fmt.Sprintf("%s:%d", s.Address, s.Port), s.Timeout)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.info.Conn = newConn
|
||||
s.Conn = newConn
|
||||
}
|
||||
|
||||
// performSSLSecondStage 执行 SSL 多阶段探测
|
||||
// 参考 gonmap 的策略:ssl → ssl-specific probes → https
|
||||
func (s *SmartPortInfoScanner) performSSLSecondStage(serviceInfo *ServiceInfo) *ServiceInfo {
|
||||
if serviceInfo.Name != "ssl" {
|
||||
// 不是SSL服务,直接返回
|
||||
return serviceInfo
|
||||
}
|
||||
// 第二阶段:SSL 专用探测器(如 RDP)
|
||||
for _, probeName := range sslSecondProbes {
|
||||
probe, exists := v.ProbesMapKName[probeName]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
probeData, err := DecodeData(probe.Data)
|
||||
if err != nil || len(probeData) == 0 {
|
||||
continue
|
||||
}
|
||||
response := s.info.Connect(probeData)
|
||||
if len(response) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 尝试识别服务
|
||||
s.info.GetInfo(response, &probe)
|
||||
if s.info.Found && s.info.Result.Service.Name != "ssl" {
|
||||
return s.buildServiceInfo()
|
||||
}
|
||||
}
|
||||
|
||||
// 第三阶段:尝试 HTTPS(通过 TLS 发送 HTTP GET)
|
||||
if serviceInfo.Name == "ssl" {
|
||||
newServiceInfo := s.tryHTTPSProbe()
|
||||
if newServiceInfo != nil {
|
||||
return newServiceInfo
|
||||
}
|
||||
}
|
||||
|
||||
return serviceInfo
|
||||
}
|
||||
|
||||
// tryHTTPSProbe 尝试 HTTPS 探测
|
||||
func (s *SmartPortInfoScanner) tryHTTPSProbe() *ServiceInfo {
|
||||
// 使用 GetRequest 探测器
|
||||
probe, exists := v.ProbesMapKName["GetRequest"]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
probeData, err := DecodeData(probe.Data)
|
||||
if err != nil || len(probeData) == 0 {
|
||||
return nil
|
||||
}
|
||||
response := s.info.Connect(probeData)
|
||||
if len(response) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 尝试识别服务
|
||||
s.info.GetInfo(response, &probe)
|
||||
if s.info.Found {
|
||||
serviceInfo := s.buildServiceInfo()
|
||||
// 自动转换 http → https
|
||||
if serviceInfo.Name == "http" {
|
||||
serviceInfo.Name = "https"
|
||||
}
|
||||
return serviceInfo
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildServiceInfo 构建ServiceInfo结果
|
||||
func (s *SmartPortInfoScanner) buildServiceInfo() *ServiceInfo {
|
||||
result := &s.info.Result
|
||||
|
||||
serviceInfo := &ServiceInfo{
|
||||
Name: result.Service.Name,
|
||||
Banner: result.Banner,
|
||||
Version: result.Service.Extras["version"],
|
||||
Extras: make(map[string]string),
|
||||
}
|
||||
|
||||
// 复制额外信息
|
||||
for k, v := range result.Service.Extras {
|
||||
serviceInfo.Extras[k] = v
|
||||
}
|
||||
return serviceInfo
|
||||
}
|
||||
|
||||
// tryProbes 尝试使用指定的探测器列表检查响应
|
||||
func (i *Info) tryProbes(response []byte, probes []*Probe) bool {
|
||||
for _, probe := range probes {
|
||||
i.GetInfo(response, probe)
|
||||
if i.Found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetInfo 分析响应数据并提取服务信息
|
||||
func (i *Info) GetInfo(response []byte, probe *Probe) {
|
||||
// 响应数据有效性检查
|
||||
if len(response) <= 0 {
|
||||
common.LogDebug("响应数据为空")
|
||||
return
|
||||
}
|
||||
|
||||
result := &i.Result
|
||||
var (
|
||||
softMatch Match
|
||||
softFound bool
|
||||
)
|
||||
|
||||
// 遍历 fallback 数组尝试匹配(参考 Nmap 的 servicescan_read_handler)
|
||||
// fallback 数组顺序: [自身, fallback指令中的探测器..., NULL探测器(TCP)]
|
||||
for depth := 0; depth < portfinger.MaxFallbacks+1; depth++ {
|
||||
fallback := probe.Fallbacks[depth]
|
||||
if fallback == nil {
|
||||
break
|
||||
}
|
||||
|
||||
// 尝试匹配当前 fallback 探测器的规则
|
||||
if matched, match := i.processMatches(response, fallback.Matchs); matched {
|
||||
return // 硬匹配成功,直接返回
|
||||
} else if match != nil && !softFound {
|
||||
// 记录第一个软匹配
|
||||
softFound = true
|
||||
softMatch = *match
|
||||
}
|
||||
}
|
||||
|
||||
// 处理未找到匹配的情况
|
||||
if !i.Found {
|
||||
i.handleNoMatch(response, result, softFound, softMatch)
|
||||
}
|
||||
}
|
||||
|
||||
// processMatches 处理匹配规则集
|
||||
func (i *Info) processMatches(response []byte, matches *[]Match) (bool, *Match) {
|
||||
var softMatch *Match
|
||||
|
||||
for _, match := range *matches {
|
||||
if !match.MatchPattern(response) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !match.IsSoft {
|
||||
i.handleHardMatch(response, &match)
|
||||
return true, nil
|
||||
} else if softMatch == nil {
|
||||
tmpMatch := match
|
||||
softMatch = &tmpMatch
|
||||
}
|
||||
}
|
||||
|
||||
return false, softMatch
|
||||
}
|
||||
|
||||
// handleHardMatch 处理硬匹配结果
|
||||
func (i *Info) handleHardMatch(response []byte, match *Match) {
|
||||
result := &i.Result
|
||||
extras := match.ParseVersionInfo(response)
|
||||
extrasMap := extras.ToMap()
|
||||
|
||||
result.Service.Name = match.Service
|
||||
result.Extras = extrasMap
|
||||
result.Banner = portfinger.TrimBanner(string(response))
|
||||
result.Service.Extras = extrasMap
|
||||
|
||||
// 特殊处理 microsoft-ds 服务
|
||||
if result.Service.Name == "microsoft-ds" {
|
||||
common.LogDebug("特殊处理 microsoft-ds 服务")
|
||||
result.Service.Extras["hostname"] = result.Banner
|
||||
}
|
||||
|
||||
i.Found = true
|
||||
common.LogDebug(fmt.Sprintf("服务识别结果: %s, Banner: %s", result.Service.Name, result.Banner))
|
||||
}
|
||||
|
||||
// handleNoMatch 处理未找到匹配的情况
|
||||
func (i *Info) handleNoMatch(response []byte, result *Result, softFound bool, softMatch Match) {
|
||||
result.Banner = portfinger.TrimBanner(string(response))
|
||||
|
||||
if !softFound {
|
||||
// 尝试识别 HTTP 服务(大小写不敏感)
|
||||
bannerLower := strings.ToLower(result.Banner)
|
||||
if strings.Contains(bannerLower, "http/") ||
|
||||
strings.Contains(bannerLower, "html") {
|
||||
common.LogDebug("识别为HTTP服务")
|
||||
result.Service.Name = "http"
|
||||
} else {
|
||||
common.LogDebug("未知服务")
|
||||
result.Service.Name = "unknown"
|
||||
}
|
||||
} else {
|
||||
extras := softMatch.ParseVersionInfo(response)
|
||||
result.Service.Extras = extras.ToMap()
|
||||
result.Service.Name = softMatch.Service
|
||||
i.Found = true
|
||||
common.LogDebug(fmt.Sprintf("软匹配服务: %s", result.Service.Name))
|
||||
}
|
||||
}
|
||||
|
||||
// Connect 发送数据并获取响应
|
||||
func (i *Info) Connect(msg []byte) []byte {
|
||||
_ = i.Write(msg)
|
||||
reply, _ := i.Read()
|
||||
return reply
|
||||
}
|
||||
|
||||
// setReadTimeout 设置读取超时时间(毫秒)
|
||||
func (i *Info) setReadTimeout(ms int) {
|
||||
if ms > 0 {
|
||||
i.readTimeoutMS = ms
|
||||
}
|
||||
}
|
||||
|
||||
// getReadTimeout 获取当前读取超时时间
|
||||
func (i *Info) getReadTimeout() time.Duration {
|
||||
if i.readTimeoutMS > 0 {
|
||||
return time.Duration(i.readTimeoutMS) * time.Millisecond
|
||||
}
|
||||
return time.Duration(defaultReadTimeoutMS) * time.Millisecond
|
||||
}
|
||||
|
||||
// WrTimeout 默认读写超时时间(秒)
|
||||
const WrTimeout = 3
|
||||
|
||||
// currentReadTimeoutMS 当前读取超时时间(毫秒),用于动态调整
|
||||
var defaultReadTimeoutMS = WrTimeout * 1000
|
||||
|
||||
// Write 写入数据到连接
|
||||
func (i *Info) Write(msg []byte) error {
|
||||
if i.Conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 设置写入超时
|
||||
_ = i.Conn.SetWriteDeadline(time.Now().Add(time.Second * time.Duration(WrTimeout)))
|
||||
|
||||
// 写入数据
|
||||
_, err := i.Conn.Write(msg)
|
||||
if err != nil && strings.Contains(err.Error(), "close") {
|
||||
// 关闭旧连接并清理
|
||||
oldConn := i.Conn
|
||||
i.Conn = nil
|
||||
_ = oldConn.Close()
|
||||
|
||||
// 尝试重新连接 - 支持SOCKS5代理
|
||||
newConn, retryErr := common.WrapperTcpWithTimeout("tcp", fmt.Sprintf("%s:%d", i.Address, i.Port), time.Duration(6)*time.Second)
|
||||
if retryErr != nil {
|
||||
return retryErr
|
||||
}
|
||||
|
||||
// 设置新连接并重试写入
|
||||
i.Conn = newConn
|
||||
_ = i.Conn.SetWriteDeadline(time.Now().Add(time.Second * time.Duration(WrTimeout)))
|
||||
_, err = i.Conn.Write(msg)
|
||||
|
||||
// 如果重试写入失败,清理新连接
|
||||
if err != nil {
|
||||
_ = i.Conn.Close()
|
||||
i.Conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// 记录发送的数据
|
||||
if err == nil {
|
||||
i.Result.Send = msg
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Read 从连接读取响应
|
||||
func (i *Info) Read() ([]byte, error) {
|
||||
if i.Conn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 设置读取超时(使用动态超时)
|
||||
_ = i.Conn.SetReadDeadline(time.Now().Add(i.getReadTimeout()))
|
||||
|
||||
// 读取数据
|
||||
result, err := readFromConn(i.Conn)
|
||||
if err != nil && strings.Contains(err.Error(), "close") {
|
||||
return result, err
|
||||
}
|
||||
|
||||
// 记录接收到的数据
|
||||
if len(result) > 0 {
|
||||
i.Result.Recv = result
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
// readFromConn 从连接读取数据的辅助函数
|
||||
// 使用 sync.Pool 复用缓冲区,减少高并发扫描时的 GC 压力
|
||||
func readFromConn(conn net.Conn) ([]byte, error) {
|
||||
const size = 2 * 1024
|
||||
|
||||
// 从对象池获取缓冲区
|
||||
bufInterface := readBufPool.Get()
|
||||
bufPtr, ok := bufInterface.(*[]byte)
|
||||
if !ok || bufPtr == nil {
|
||||
buf := make([]byte, size)
|
||||
bufPtr = &buf
|
||||
}
|
||||
buf := *bufPtr
|
||||
defer readBufPool.Put(bufPtr)
|
||||
|
||||
var result []byte
|
||||
|
||||
for {
|
||||
count, err := conn.Read(buf)
|
||||
|
||||
if count > 0 {
|
||||
result = append(result, buf[:count]...)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if len(result) > 0 {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
return result, nil
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
if count < size {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package core
|
||||
|
||||
/*
|
||||
service_probe_strategy_test.go - SmartProbeStrategy 策略逻辑测试
|
||||
|
||||
测试重点:
|
||||
1. 新探测策略 - 使用 Probe.Ports 和 Rarity 排序
|
||||
2. 动态超时 - 使用 TotalWaitMS
|
||||
3. NULL 回退 - 隐式 NULL 探测器匹配
|
||||
|
||||
说明:
|
||||
- 只测试策略逻辑,不测试实际的网络IO(那是集成测试的职责)
|
||||
*/
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/core/portfinger"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// 测试1:新探测策略(使用 Probe.Ports)
|
||||
// =============================================================================
|
||||
|
||||
// TestNewStrategy_ProbePortsUsed 验证新策略使用 Probe.Ports 字段
|
||||
func TestNewStrategy_ProbePortsUsed(t *testing.T) {
|
||||
v := portfinger.GetGlobalVScan()
|
||||
|
||||
// 验证端口80有对应的探测器
|
||||
probes := v.GetProbesForPort(80)
|
||||
if len(probes) == 0 {
|
||||
t.Error("端口 80 应该有探测器")
|
||||
}
|
||||
|
||||
// 验证 GetRequest 探测器在列表中
|
||||
found := false
|
||||
for _, p := range probes {
|
||||
if p.Name == "GetRequest" {
|
||||
found = true
|
||||
t.Logf("✓ GetRequest 探测器存在于端口 80 的探测器列表中 (rarity=%d)", p.Rarity)
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("GetRequest 探测器应该在端口 80 的列表中")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewStrategy_RaritySorting 验证探测器按 Rarity 排序
|
||||
func TestNewStrategy_RaritySorting(t *testing.T) {
|
||||
v := portfinger.GetGlobalVScan()
|
||||
|
||||
probes := v.GetProbesForPort(80)
|
||||
if len(probes) < 2 {
|
||||
t.Skip("端口 80 的探测器数量不足,跳过排序测试")
|
||||
}
|
||||
|
||||
// 验证按 rarity 从低到高排序
|
||||
for i := 1; i < len(probes); i++ {
|
||||
prev := probes[i-1].Rarity
|
||||
curr := probes[i].Rarity
|
||||
// 0 视为 10
|
||||
if prev == 0 {
|
||||
prev = 10
|
||||
}
|
||||
if curr == 0 {
|
||||
curr = 10
|
||||
}
|
||||
if prev > curr {
|
||||
t.Errorf("探测器未按 rarity 排序: [%d]=%d > [%d]=%d",
|
||||
i-1, probes[i-1].Rarity, i, probes[i].Rarity)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("✓ 端口 80 的 %d 个探测器已按 rarity 排序", len(probes))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 测试2:SSL 端口探测
|
||||
// =============================================================================
|
||||
|
||||
// TestSSLProbes_Port443 验证 443 端口的 SSL 探测器
|
||||
func TestSSLProbes_Port443(t *testing.T) {
|
||||
v := portfinger.GetGlobalVScan()
|
||||
|
||||
// 获取 ports 包含 443 的探测器
|
||||
probes := v.GetProbesForPort(443)
|
||||
t.Logf("端口 443 的 ports 探测器: %d 个", len(probes))
|
||||
|
||||
// 获取 sslports 包含 443 的探测器
|
||||
sslProbes := v.GetSSLProbesForPort(443)
|
||||
t.Logf("端口 443 的 sslports 探测器: %d 个", len(sslProbes))
|
||||
|
||||
// 至少应该有一些 SSL 相关探测器
|
||||
if len(probes) == 0 && len(sslProbes) == 0 {
|
||||
t.Error("端口 443 应该有探测器")
|
||||
}
|
||||
|
||||
// 验证 TLSSessionReq 存在
|
||||
for _, p := range probes {
|
||||
if p.Name == "TLSSessionReq" {
|
||||
t.Logf("✓ TLSSessionReq 存在于 ports 列表")
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, p := range sslProbes {
|
||||
if p.Name == "TLSSessionReq" {
|
||||
t.Logf("✓ TLSSessionReq 存在于 sslports 列表")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 测试3:Intensity 过滤
|
||||
// =============================================================================
|
||||
|
||||
// TestIntensityFilter 验证 intensity 过滤功能
|
||||
func TestIntensityFilter(t *testing.T) {
|
||||
// 创建测试探测器
|
||||
probes := []*portfinger.Probe{
|
||||
{Name: "p1", Rarity: 1},
|
||||
{Name: "p2", Rarity: 5},
|
||||
{Name: "p3", Rarity: 9},
|
||||
}
|
||||
|
||||
// intensity=5 应该过滤掉 rarity=9 的探测器
|
||||
filtered := portfinger.FilterProbesByIntensity(probes, 5)
|
||||
if len(filtered) != 2 {
|
||||
t.Errorf("intensity=5 应该返回 2 个探测器,实际返回 %d", len(filtered))
|
||||
}
|
||||
|
||||
// 验证 rarity=9 的探测器被过滤
|
||||
for _, p := range filtered {
|
||||
if p.Rarity > 5 {
|
||||
t.Errorf("rarity=%d 的探测器不应该通过 intensity=5 的过滤", p.Rarity)
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("✓ Intensity 过滤功能正常")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 测试4:Scanner 创建和基本功能
|
||||
// =============================================================================
|
||||
|
||||
// TestSmartPortInfoScanner_Creation 验证 Scanner 可以正常创建
|
||||
func TestSmartPortInfoScanner_Creation(t *testing.T) {
|
||||
config := common.GetGlobalConfig()
|
||||
if config == nil {
|
||||
config = &common.Config{}
|
||||
config.PortMap = make(map[int][]string)
|
||||
}
|
||||
|
||||
// 使用 nil 连接(实际测试中会使用真实连接)
|
||||
scanner := NewSmartPortInfoScanner("127.0.0.1", 80, nil, 3*time.Second, config)
|
||||
|
||||
if scanner == nil {
|
||||
t.Fatal("Scanner 创建失败")
|
||||
}
|
||||
|
||||
if scanner.Port != 80 {
|
||||
t.Errorf("端口设置错误: 期望 80, 实际 %d", scanner.Port)
|
||||
}
|
||||
|
||||
t.Log("✓ Scanner 创建成功")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 测试5:动态超时常量
|
||||
// =============================================================================
|
||||
|
||||
// TestDefaultConstants 验证默认常量值
|
||||
func TestDefaultConstants(t *testing.T) {
|
||||
// 验证默认等待时间
|
||||
if defaultTotalWaitMS != 6000 {
|
||||
t.Errorf("defaultTotalWaitMS 应该是 6000,实际是 %d", defaultTotalWaitMS)
|
||||
}
|
||||
|
||||
// 验证默认 intensity
|
||||
if defaultIntensity != 7 {
|
||||
t.Errorf("defaultIntensity 应该是 7,实际是 %d", defaultIntensity)
|
||||
}
|
||||
|
||||
t.Logf("✓ 默认常量: TotalWaitMS=%d, Intensity=%d", defaultTotalWaitMS, defaultIntensity)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 测试6:端口范围解析
|
||||
// =============================================================================
|
||||
|
||||
// TestPortInRange 验证端口范围解析
|
||||
func TestPortInRange(t *testing.T) {
|
||||
tests := []struct {
|
||||
port int
|
||||
portsStr string
|
||||
expected bool
|
||||
}{
|
||||
{80, "80", true},
|
||||
{80, "80,443", true},
|
||||
{8080, "8000-9000", true},
|
||||
{7999, "8000-9000", false},
|
||||
{443, "80,443,8080", true},
|
||||
{22, "80,443,8080", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := portfinger.PortInRange(tt.port, tt.portsStr)
|
||||
if result != tt.expected {
|
||||
t.Errorf("PortInRange(%d, %q) = %v, want %v",
|
||||
tt.port, tt.portsStr, result, tt.expected)
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("✓ 端口范围解析功能正常")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 测试7:真实场景模拟
|
||||
// =============================================================================
|
||||
|
||||
// TestRealWorldScenario_CommonPorts 验证常见端口的探测器配置
|
||||
func TestRealWorldScenario_CommonPorts(t *testing.T) {
|
||||
v := portfinger.GetGlobalVScan()
|
||||
|
||||
scenarios := []struct {
|
||||
port int
|
||||
description string
|
||||
}{
|
||||
{80, "HTTP"},
|
||||
{443, "HTTPS"},
|
||||
{8080, "HTTP-Alt"},
|
||||
{8443, "HTTPS-Alt"},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
probes := v.GetProbesForPort(s.port)
|
||||
sslProbes := v.GetSSLProbesForPort(s.port)
|
||||
total := len(probes) + len(sslProbes)
|
||||
|
||||
t.Logf("端口 %d (%s): ports=%d, sslports=%d, 总计=%d",
|
||||
s.port, s.description, len(probes), len(sslProbes), total)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
/*
|
||||
service_probe_test.go - ServiceProbe核心逻辑测试
|
||||
|
||||
注意:service_probe.go 包含大量网络IO和全局状态依赖。
|
||||
本测试文件专注于可测试的纯逻辑和算法正确性:
|
||||
1. buildServiceInfo - 数据转换逻辑
|
||||
2. handleNoMatch - HTTP服务识别逻辑
|
||||
3. handleHardMatch - 匹配结果处理
|
||||
4. readFromConn - 缓冲区读取逻辑
|
||||
|
||||
不测试的部分(需要集成测试):
|
||||
- SmartIdentify, PortInfo - 网络IO + 全局探测器依赖
|
||||
- Write, Read, Connect - 网络IO操作
|
||||
- 探测器策略函数 - 依赖全局 PortMap 和 VScan
|
||||
|
||||
"这代码把数据结构和网络IO混在一起了,应该分离。
|
||||
但既然现在无法重构,我们至少测试纯逻辑部分。"
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// 核心逻辑测试:数据转换
|
||||
// =============================================================================
|
||||
|
||||
// TestBuildServiceInfo 测试服务信息构建逻辑
|
||||
func TestBuildServiceInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupInfo func() *SmartPortInfoScanner
|
||||
expectedName string
|
||||
expectedBanner string
|
||||
hasExtras bool
|
||||
}{
|
||||
{
|
||||
name: "完整服务信息",
|
||||
setupInfo: func() *SmartPortInfoScanner {
|
||||
scanner := &SmartPortInfoScanner{
|
||||
Address: "192.168.1.1",
|
||||
Port: 80,
|
||||
info: &Info{
|
||||
Result: Result{
|
||||
Service: Service{
|
||||
Name: "http",
|
||||
Extras: map[string]string{
|
||||
"version": "Apache/2.4.41",
|
||||
"os": "Linux",
|
||||
},
|
||||
},
|
||||
Banner: "Apache/2.4.41 (Ubuntu)",
|
||||
},
|
||||
},
|
||||
}
|
||||
return scanner
|
||||
},
|
||||
expectedName: "http",
|
||||
expectedBanner: "Apache/2.4.41 (Ubuntu)",
|
||||
hasExtras: true,
|
||||
},
|
||||
{
|
||||
name: "只有服务名称",
|
||||
setupInfo: func() *SmartPortInfoScanner {
|
||||
scanner := &SmartPortInfoScanner{
|
||||
Address: "192.168.1.1",
|
||||
Port: 22,
|
||||
info: &Info{
|
||||
Result: Result{
|
||||
Service: Service{
|
||||
Name: "ssh",
|
||||
Extras: map[string]string{},
|
||||
},
|
||||
Banner: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
return scanner
|
||||
},
|
||||
expectedName: "ssh",
|
||||
expectedBanner: "",
|
||||
hasExtras: false,
|
||||
},
|
||||
{
|
||||
name: "未知服务",
|
||||
setupInfo: func() *SmartPortInfoScanner {
|
||||
scanner := &SmartPortInfoScanner{
|
||||
Address: "192.168.1.1",
|
||||
Port: 9999,
|
||||
info: &Info{
|
||||
Result: Result{
|
||||
Service: Service{
|
||||
Name: "unknown",
|
||||
Extras: map[string]string{},
|
||||
},
|
||||
Banner: "Binary data",
|
||||
},
|
||||
},
|
||||
}
|
||||
return scanner
|
||||
},
|
||||
expectedName: "unknown",
|
||||
expectedBanner: "Binary data",
|
||||
hasExtras: false,
|
||||
},
|
||||
{
|
||||
name: "包含版本号的服务",
|
||||
setupInfo: func() *SmartPortInfoScanner {
|
||||
scanner := &SmartPortInfoScanner{
|
||||
Address: "192.168.1.1",
|
||||
Port: 3306,
|
||||
info: &Info{
|
||||
Result: Result{
|
||||
Service: Service{
|
||||
Name: "mysql",
|
||||
Extras: map[string]string{
|
||||
"version": "5.7.33",
|
||||
"product": "MySQL",
|
||||
},
|
||||
},
|
||||
Banner: "MySQL 5.7.33",
|
||||
},
|
||||
},
|
||||
}
|
||||
return scanner
|
||||
},
|
||||
expectedName: "mysql",
|
||||
expectedBanner: "MySQL 5.7.33",
|
||||
hasExtras: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
scanner := tt.setupInfo()
|
||||
serviceInfo := scanner.buildServiceInfo()
|
||||
|
||||
// 验证服务名称
|
||||
if serviceInfo.Name != tt.expectedName {
|
||||
t.Errorf("Name = %q, 期望 %q", serviceInfo.Name, tt.expectedName)
|
||||
}
|
||||
|
||||
// 验证Banner
|
||||
if serviceInfo.Banner != tt.expectedBanner {
|
||||
t.Errorf("Banner = %q, 期望 %q", serviceInfo.Banner, tt.expectedBanner)
|
||||
}
|
||||
|
||||
// 验证Extras
|
||||
if tt.hasExtras && len(serviceInfo.Extras) == 0 {
|
||||
t.Error("期望有Extras数据,但为空")
|
||||
}
|
||||
|
||||
// 验证Version提取
|
||||
if version, ok := serviceInfo.Extras["version"]; ok {
|
||||
if serviceInfo.Version != version {
|
||||
t.Errorf("Version = %q, 期望从Extras提取 %q", serviceInfo.Version, version)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证Extras不为nil
|
||||
if serviceInfo.Extras == nil {
|
||||
t.Error("Extras不应为nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildServiceInfo_EmptyExtras 测试空Extras的处理
|
||||
func TestBuildServiceInfo_EmptyExtras(t *testing.T) {
|
||||
scanner := &SmartPortInfoScanner{
|
||||
Address: "192.168.1.1",
|
||||
Port: 80,
|
||||
info: &Info{
|
||||
Result: Result{
|
||||
Service: Service{
|
||||
Name: "http",
|
||||
Extras: nil, // nil Extras
|
||||
},
|
||||
Banner: "Test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
serviceInfo := scanner.buildServiceInfo()
|
||||
|
||||
// 验证不会panic
|
||||
if serviceInfo.Extras == nil {
|
||||
t.Error("Extras应被初始化,不应为nil")
|
||||
}
|
||||
|
||||
// 验证Version为空
|
||||
if serviceInfo.Version != "" {
|
||||
t.Errorf("Version应为空, 实际 %q", serviceInfo.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTTP识别逻辑测试
|
||||
// =============================================================================
|
||||
|
||||
// TestHandleNoMatch_HTTPDetection 测试HTTP服务识别逻辑
|
||||
func TestHandleNoMatch_HTTPDetection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
banner string
|
||||
softFound bool
|
||||
expectedService string
|
||||
}{
|
||||
{
|
||||
name: "HTTP协议头识别-大写",
|
||||
banner: "HTTP/1.1 200 OK Server: nginx", // TrimBanner会把\r\n替换为空格
|
||||
softFound: false,
|
||||
expectedService: "http",
|
||||
},
|
||||
{
|
||||
name: "HTTP协议头识别-小写http/",
|
||||
banner: "http/1.0 404 Not Found", // 修复后支持小写
|
||||
softFound: false,
|
||||
expectedService: "http", // 修复后大小写不敏感
|
||||
},
|
||||
{
|
||||
name: "HTML内容识别-小写html",
|
||||
banner: "<html><body>Test</body></html>",
|
||||
softFound: false,
|
||||
expectedService: "http",
|
||||
},
|
||||
{
|
||||
name: "HTML内容识别-大写HTML",
|
||||
banner: "<!DOCTYPE HTML>", // 修复后支持大写
|
||||
softFound: false,
|
||||
expectedService: "http", // 修复后大小写不敏感
|
||||
},
|
||||
{
|
||||
name: "HTTP协议头-混合大小写Http/",
|
||||
banner: "Http/2.0 200 OK",
|
||||
softFound: false,
|
||||
expectedService: "http",
|
||||
},
|
||||
{
|
||||
name: "HTML内容-混合大小写HtMl",
|
||||
banner: "<HtMl><body>Test</body></HtMl>",
|
||||
softFound: false,
|
||||
expectedService: "http",
|
||||
},
|
||||
{
|
||||
name: "非HTTP服务",
|
||||
banner: "SSH-2.0-OpenSSH_7.4",
|
||||
softFound: false,
|
||||
expectedService: "unknown",
|
||||
},
|
||||
{
|
||||
name: "空Banner",
|
||||
banner: "",
|
||||
softFound: false,
|
||||
expectedService: "unknown",
|
||||
},
|
||||
{
|
||||
name: "二进制数据",
|
||||
banner: "Binary Data", // TrimBanner把\x00\x01\x02\x03替换为空格,然后TrimSpace
|
||||
softFound: false,
|
||||
expectedService: "unknown",
|
||||
},
|
||||
{
|
||||
name: "软匹配覆盖-不检查HTTP",
|
||||
banner: "HTTP/1.1 200 OK",
|
||||
softFound: true, // 有软匹配时不应识别为HTTP
|
||||
expectedService: "test-service",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := &Info{
|
||||
Result: Result{},
|
||||
}
|
||||
|
||||
// 模拟软匹配
|
||||
var softMatch Match
|
||||
if tt.softFound {
|
||||
softMatch = Match{
|
||||
Service: "test-service",
|
||||
}
|
||||
}
|
||||
|
||||
// 调用handleNoMatch
|
||||
info.handleNoMatch([]byte(tt.banner), &info.Result, tt.softFound, softMatch)
|
||||
|
||||
// 验证服务识别结果
|
||||
if info.Result.Service.Name != tt.expectedService {
|
||||
t.Errorf("Service.Name = %q, 期望 %q", info.Result.Service.Name, tt.expectedService)
|
||||
}
|
||||
|
||||
// 验证Banner被正确设置
|
||||
if info.Result.Banner != tt.banner {
|
||||
t.Errorf("Banner = %q, 期望 %q", info.Result.Banner, tt.banner)
|
||||
}
|
||||
|
||||
// 验证Found标志
|
||||
if tt.softFound && !info.Found {
|
||||
t.Error("软匹配时Found应为true")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleNoMatch_HTTPVariants 测试HTTP识别的各种变体
|
||||
func TestHandleNoMatch_HTTPVariants(t *testing.T) {
|
||||
// 根据实际实现,只有包含"HTTP/"(大写)或"html"(小写)的才识别为http
|
||||
httpVariants := []string{
|
||||
"HTTP/1.0 200 OK",
|
||||
"HTTP/1.1 404 Not Found",
|
||||
"HTTP/2 500 Internal Server Error",
|
||||
"<html>",
|
||||
"<!DOCTYPE html>",
|
||||
"Content-Type: text/html",
|
||||
}
|
||||
|
||||
for _, banner := range httpVariants {
|
||||
t.Run(banner, func(t *testing.T) {
|
||||
info := &Info{
|
||||
Result: Result{},
|
||||
}
|
||||
|
||||
info.handleNoMatch([]byte(banner), &info.Result, false, Match{})
|
||||
|
||||
if info.Result.Service.Name != "http" {
|
||||
t.Errorf("Banner %q 应识别为http, 实际 %q", banner, info.Result.Service.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 匹配结果处理测试
|
||||
// =============================================================================
|
||||
|
||||
// TestHandleHardMatch 测试硬匹配处理逻辑
|
||||
func TestHandleHardMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response []byte
|
||||
matchService string
|
||||
expectedService string
|
||||
expectedFound bool
|
||||
checkMicrosoftDS bool
|
||||
}{
|
||||
{
|
||||
name: "标准HTTP匹配",
|
||||
response: []byte("HTTP/1.1 200 OK\r\nServer: nginx/1.18.0"),
|
||||
matchService: "http",
|
||||
expectedService: "http",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "SSH匹配",
|
||||
response: []byte("SSH-2.0-OpenSSH_8.0"),
|
||||
matchService: "ssh",
|
||||
expectedService: "ssh",
|
||||
expectedFound: true,
|
||||
},
|
||||
{
|
||||
name: "Microsoft-DS特殊处理",
|
||||
response: []byte("SMB Domain Info"),
|
||||
matchService: "microsoft-ds",
|
||||
expectedService: "microsoft-ds",
|
||||
expectedFound: true,
|
||||
checkMicrosoftDS: true,
|
||||
},
|
||||
{
|
||||
name: "空响应",
|
||||
response: []byte(""),
|
||||
matchService: "unknown",
|
||||
expectedService: "unknown",
|
||||
expectedFound: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := &Info{
|
||||
Result: Result{
|
||||
Service: Service{
|
||||
Extras: make(map[string]string),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 创建模拟Match
|
||||
match := &Match{
|
||||
Service: tt.matchService,
|
||||
}
|
||||
|
||||
// 调用handleHardMatch
|
||||
info.handleHardMatch(tt.response, match)
|
||||
|
||||
// 验证服务名称
|
||||
if info.Result.Service.Name != tt.expectedService {
|
||||
t.Errorf("Service.Name = %q, 期望 %q", info.Result.Service.Name, tt.expectedService)
|
||||
}
|
||||
|
||||
// 验证Found标志
|
||||
if info.Found != tt.expectedFound {
|
||||
t.Errorf("Found = %v, 期望 %v", info.Found, tt.expectedFound)
|
||||
}
|
||||
|
||||
// 验证Banner被设置
|
||||
if info.Result.Banner == "" && len(tt.response) > 0 {
|
||||
t.Error("Banner应被设置")
|
||||
}
|
||||
|
||||
// 验证microsoft-ds特殊处理
|
||||
if tt.checkMicrosoftDS {
|
||||
if hostname, ok := info.Result.Service.Extras["hostname"]; !ok {
|
||||
t.Error("microsoft-ds应设置hostname字段")
|
||||
} else if hostname != info.Result.Banner {
|
||||
t.Errorf("hostname = %q, 应等于Banner %q", hostname, info.Result.Banner)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 缓冲区读取逻辑测试
|
||||
// =============================================================================
|
||||
|
||||
// mockConn 模拟网络连接
|
||||
type mockConn struct {
|
||||
data []byte
|
||||
readPos int
|
||||
chunkSize int // 每次Read返回的字节数
|
||||
closed bool
|
||||
shouldError bool
|
||||
}
|
||||
|
||||
func (m *mockConn) Read(b []byte) (n int, err error) {
|
||||
if m.closed {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
if m.shouldError {
|
||||
return 0, net.ErrClosed
|
||||
}
|
||||
|
||||
if m.readPos >= len(m.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
// 模拟分块读取
|
||||
// chunkSize控制每次Read返回的字节数(不是缓冲区大小)
|
||||
readSize := m.chunkSize
|
||||
if readSize == 0 {
|
||||
// chunkSize=0表示一次性读取整个缓冲区
|
||||
readSize = len(b)
|
||||
}
|
||||
|
||||
remaining := len(m.data) - m.readPos
|
||||
if readSize > remaining {
|
||||
readSize = remaining
|
||||
}
|
||||
if readSize > len(b) {
|
||||
readSize = len(b)
|
||||
}
|
||||
|
||||
copy(b, m.data[m.readPos:m.readPos+readSize])
|
||||
m.readPos += readSize
|
||||
|
||||
// 关键:readFromConn在 count < size 时会停止读取
|
||||
// 所以如果 chunkSize > 0,我们要么返回满缓冲区,要么返回EOF
|
||||
// 为了测试分块读取,需要让readFromConn认为还有更多数据
|
||||
|
||||
return readSize, nil
|
||||
}
|
||||
|
||||
func (m *mockConn) Write(b []byte) (n int, err error) { return len(b), nil }
|
||||
func (m *mockConn) Close() error { m.closed = true; return nil }
|
||||
func (m *mockConn) LocalAddr() net.Addr { return nil }
|
||||
func (m *mockConn) RemoteAddr() net.Addr { return nil }
|
||||
func (m *mockConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
|
||||
// TestReadFromConn 测试连接读取逻辑
|
||||
func TestReadFromConn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
chunkSize int
|
||||
expectedLen int
|
||||
}{
|
||||
{
|
||||
name: "一次性读取完整数据",
|
||||
data: []byte("Hello, World!"),
|
||||
chunkSize: 0, // 0表示一次性读取
|
||||
expectedLen: 13,
|
||||
},
|
||||
{
|
||||
name: "分块读取-填满缓冲区才继续",
|
||||
data: bytes.Repeat([]byte("A"), 5000), // 超过2KB,会分多次读取
|
||||
chunkSize: 2048, // 每次填满缓冲区
|
||||
expectedLen: 5000,
|
||||
},
|
||||
{
|
||||
name: "分块读取-大数据",
|
||||
data: bytes.Repeat([]byte("Test"), 2048), // 8KB数据
|
||||
chunkSize: 2048, // 每次2KB
|
||||
expectedLen: 8192,
|
||||
},
|
||||
{
|
||||
name: "空数据",
|
||||
data: []byte{},
|
||||
chunkSize: 0,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "小于缓冲区的数据",
|
||||
data: []byte("X"),
|
||||
chunkSize: 0,
|
||||
expectedLen: 1,
|
||||
},
|
||||
{
|
||||
name: "恰好填满缓冲区",
|
||||
data: bytes.Repeat([]byte("B"), 2048),
|
||||
chunkSize: 2048,
|
||||
expectedLen: 2048,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
conn := &mockConn{
|
||||
data: tt.data,
|
||||
chunkSize: tt.chunkSize,
|
||||
}
|
||||
|
||||
result, err := readFromConn(conn)
|
||||
|
||||
// 验证没有错误
|
||||
if err != nil {
|
||||
t.Errorf("readFromConn() 错误 = %v", err)
|
||||
}
|
||||
|
||||
// 验证读取长度
|
||||
if len(result) != tt.expectedLen {
|
||||
t.Errorf("读取长度 = %d, 期望 %d", len(result), tt.expectedLen)
|
||||
}
|
||||
|
||||
// 验证数据内容
|
||||
if !bytes.Equal(result, tt.data) {
|
||||
t.Error("读取数据与原始数据不匹配")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFromConn_EOF 测试EOF处理
|
||||
func TestReadFromConn_EOF(t *testing.T) {
|
||||
conn := &mockConn{
|
||||
data: []byte("Data before EOF"),
|
||||
chunkSize: 100,
|
||||
}
|
||||
|
||||
result, err := readFromConn(conn)
|
||||
|
||||
// EOF时应返回已读取的数据,不返回错误
|
||||
if err != nil {
|
||||
t.Errorf("EOF时不应返回错误, 实际 %v", err)
|
||||
}
|
||||
|
||||
if len(result) != len(conn.data) {
|
||||
t.Errorf("应返回EOF前的数据, 长度 = %d, 期望 %d", len(result), len(conn.data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFromConn_Error 测试错误处理
|
||||
func TestReadFromConn_Error(t *testing.T) {
|
||||
conn := &mockConn{
|
||||
shouldError: true,
|
||||
}
|
||||
|
||||
result, err := readFromConn(conn)
|
||||
|
||||
// 应该返回错误
|
||||
if err == nil {
|
||||
t.Error("连接错误时应返回错误")
|
||||
}
|
||||
|
||||
// 结果应该为空或nil
|
||||
if len(result) != 0 {
|
||||
t.Errorf("错误时应返回空数据, 实际长度 %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 边界情况测试
|
||||
// =============================================================================
|
||||
|
||||
// TestReadFromConn_LargeData 测试大数据读取
|
||||
func TestReadFromConn_LargeData(t *testing.T) {
|
||||
// 模拟10MB数据
|
||||
largeData := bytes.Repeat([]byte("X"), 10*1024*1024)
|
||||
|
||||
conn := &mockConn{
|
||||
data: largeData,
|
||||
chunkSize: 2048, // 每次读2KB
|
||||
}
|
||||
|
||||
result, err := readFromConn(conn)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("大数据读取错误 = %v", err)
|
||||
}
|
||||
|
||||
if len(result) != len(largeData) {
|
||||
t.Errorf("大数据读取长度 = %d, 期望 %d", len(result), len(largeData))
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFromConn_BinaryData 测试二进制数据
|
||||
func TestReadFromConn_BinaryData(t *testing.T) {
|
||||
binaryData := []byte{0x00, 0x01, 0x02, 0xFF, 0xFE, 0xFD}
|
||||
|
||||
conn := &mockConn{
|
||||
data: binaryData,
|
||||
chunkSize: 0, // 一次性读取,避免提前终止
|
||||
}
|
||||
|
||||
result, err := readFromConn(conn)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("二进制数据读取错误 = %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(result, binaryData) {
|
||||
t.Errorf("二进制数据 = %v, 期望 %v", result, binaryData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/common/i18n"
|
||||
"github.com/shadow1ng/fscan/common/parsers"
|
||||
)
|
||||
|
||||
// ServiceScanStrategy 服务扫描策略
|
||||
type ServiceScanStrategy struct {
|
||||
*BaseScanStrategy
|
||||
}
|
||||
|
||||
// NewServiceScanStrategy 创建新的服务扫描策略
|
||||
func NewServiceScanStrategy() *ServiceScanStrategy {
|
||||
return &ServiceScanStrategy{
|
||||
BaseScanStrategy: NewBaseScanStrategy("服务扫描", FilterService),
|
||||
}
|
||||
}
|
||||
|
||||
// LogPluginInfo 重写以提供基于端口的插件过滤
|
||||
func (s *ServiceScanStrategy) LogPluginInfo(config *common.Config) {
|
||||
// 需要从命令行参数获取端口信息来进行过滤
|
||||
// 如果没有指定端口,使用默认端口进行过滤显示
|
||||
ports := common.GetFlagVars().Ports
|
||||
if ports == "" || ports == "all" {
|
||||
// 默认端口扫描:显示所有插件
|
||||
s.BaseScanStrategy.LogPluginInfo(config)
|
||||
} else {
|
||||
// 指定端口扫描:只显示匹配的插件
|
||||
s.showPluginsForSpecifiedPorts(config)
|
||||
}
|
||||
}
|
||||
|
||||
// showPluginsForSpecifiedPorts 显示指定端口的匹配插件
|
||||
func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config) {
|
||||
allPlugins, isCustomMode := s.GetPlugins(config)
|
||||
|
||||
// 解析端口
|
||||
ports := s.parsePortList(common.GetFlagVars().Ports)
|
||||
if len(ports) == 0 {
|
||||
s.BaseScanStrategy.LogPluginInfo(config)
|
||||
return
|
||||
}
|
||||
|
||||
// 收集所有匹配的插件(去重)
|
||||
pluginSet := make(map[string]struct{}, len(allPlugins))
|
||||
for _, port := range ports {
|
||||
for _, pluginName := range allPlugins {
|
||||
if s.pluginExists(pluginName) {
|
||||
if s.isPluginApplicableToPort(pluginName, port) && s.isPluginPassesFilterType(pluginName, isCustomMode, config) {
|
||||
pluginSet[pluginName] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为列表
|
||||
var applicablePlugins []string
|
||||
for pluginName := range pluginSet {
|
||||
applicablePlugins = append(applicablePlugins, pluginName)
|
||||
}
|
||||
|
||||
// 输出结果
|
||||
if len(applicablePlugins) > 0 {
|
||||
pluginStr := formatPluginList(applicablePlugins)
|
||||
if isCustomMode {
|
||||
common.LogInfo(i18n.Tr("service_plugin_custom", pluginStr))
|
||||
} else {
|
||||
common.LogInfo(i18n.Tr("service_plugin_info", pluginStr))
|
||||
}
|
||||
} else {
|
||||
common.LogInfo(i18n.GetText("service_plugin_none"))
|
||||
}
|
||||
}
|
||||
|
||||
// parsePortList 解析端口列表
|
||||
func (s *ServiceScanStrategy) parsePortList(portStr string) []int {
|
||||
if portStr == "" || portStr == "all" {
|
||||
return []int{}
|
||||
}
|
||||
|
||||
ports := []int{} // 初始化为空切片而非nil
|
||||
parts := strings.Split(portStr, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if port, err := strconv.Atoi(part); err == nil {
|
||||
// 验证端口范围 1-65535(与 scanner.go 的 parsePort 保持一致)
|
||||
if port >= 1 && port <= 65535 {
|
||||
ports = append(ports, port)
|
||||
} else {
|
||||
common.LogError(i18n.Tr("port_out_of_range", port))
|
||||
}
|
||||
}
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
// Name 返回策略名称
|
||||
func (s *ServiceScanStrategy) Name() string {
|
||||
return i18n.GetText("scan_strategy_service_name")
|
||||
}
|
||||
|
||||
// Description 返回策略描述
|
||||
func (s *ServiceScanStrategy) Description() string {
|
||||
return i18n.GetText("scan_strategy_service_desc")
|
||||
}
|
||||
|
||||
// Execute 执行服务扫描策略
|
||||
func (s *ServiceScanStrategy) Execute(config *common.Config, state *common.State, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
|
||||
// 验证扫描目标(需要同时检查 -h 和 -hf 参数)
|
||||
fv := common.GetFlagVars()
|
||||
if info.Host == "" && fv.HostsFile == "" {
|
||||
common.LogError(i18n.GetText("parse_error_target_empty"))
|
||||
return
|
||||
}
|
||||
|
||||
// 输出扫描开始信息
|
||||
s.LogScanStart()
|
||||
|
||||
// 验证插件配置
|
||||
if err := s.ValidateConfiguration(); err != nil {
|
||||
common.LogError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 输出插件信息(重写以提供端口过滤)
|
||||
s.LogPluginInfo(config)
|
||||
|
||||
// 执行主机扫描流程
|
||||
s.performHostScan(config, state, info, ch, wg)
|
||||
}
|
||||
|
||||
// performHostScan 执行主机扫描的完整流程
|
||||
func (s *ServiceScanStrategy) performHostScan(config *common.Config, state *common.State, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
|
||||
// 发现目标主机和端口
|
||||
targetInfos, err := s.discoverTargets(info.Host, info, config, state)
|
||||
if err != nil {
|
||||
common.LogError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 执行漏洞扫描
|
||||
if len(targetInfos) > 0 {
|
||||
ExecuteScanTasks(config, state, targetInfos, s, ch, wg)
|
||||
}
|
||||
}
|
||||
|
||||
// PrepareTargets 准备目标信息
|
||||
func (s *ServiceScanStrategy) PrepareTargets(info common.HostInfo, config *common.Config, state *common.State) []common.HostInfo {
|
||||
// 发现目标主机和端口
|
||||
targetInfos, err := s.discoverTargets(info.Host, info, config, state)
|
||||
if err != nil {
|
||||
common.LogError(err.Error())
|
||||
return nil
|
||||
}
|
||||
return targetInfos
|
||||
}
|
||||
|
||||
// LogVulnerabilityPluginInfo 输出服务扫描插件信息
|
||||
func (s *ServiceScanStrategy) LogVulnerabilityPluginInfo(targets []common.HostInfo, config *common.Config) {
|
||||
allPlugins, isCustomMode := s.GetPlugins(config)
|
||||
|
||||
// 获取实际会被使用的插件列表
|
||||
servicePluginSet := make(map[string]struct{}, len(allPlugins))
|
||||
|
||||
for _, pluginName := range allPlugins {
|
||||
// 使用统一插件系统检查插件存在性
|
||||
if !s.pluginExists(pluginName) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查插件是否通过过滤器类型检查
|
||||
if !s.isPluginPassesFilterType(pluginName, isCustomMode, config) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查插件是否适用于任意一个目标
|
||||
for _, target := range targets {
|
||||
if target.Port == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用 host:port 信息检查插件适用性(Web插件需要host信息)
|
||||
if s.isPluginApplicableToPortWithHost(pluginName, target.Host, target.Port) {
|
||||
servicePluginSet[pluginName] = struct{}{}
|
||||
break // 只要适用于一个目标就添加
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为切片
|
||||
var servicePlugins []string
|
||||
for pluginName := range servicePluginSet {
|
||||
servicePlugins = append(servicePlugins, pluginName)
|
||||
}
|
||||
|
||||
// 输出插件信息
|
||||
if len(servicePlugins) > 0 {
|
||||
common.LogInfo(i18n.Tr("service_plugin_info", strings.Join(servicePlugins, ", ")))
|
||||
} else {
|
||||
common.LogInfo(i18n.GetText("scan_no_service_plugins"))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 端口发现功能(从 PortDiscoveryService 合并)
|
||||
// =============================================================================
|
||||
|
||||
// discoverTargets 发现目标主机和端口
|
||||
func (s *ServiceScanStrategy) discoverTargets(hostInput string, baseInfo common.HostInfo, config *common.Config, state *common.State) ([]common.HostInfo, error) {
|
||||
// 标准流程:解析目标主机
|
||||
fv := common.GetFlagVars()
|
||||
hosts, err := parsers.ParseIP(hostInput, fv.HostsFile, fv.ExcludeHosts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.GetText("parse_target_failed"), err)
|
||||
}
|
||||
|
||||
var targetInfos []common.HostInfo
|
||||
|
||||
// 主机存活性检测和端口扫描
|
||||
if len(hosts) > 0 || len(state.GetHostPorts()) > 0 {
|
||||
// 主机存活检测
|
||||
if s.shouldPerformLivenessCheck(hosts, config) {
|
||||
hosts = CheckLive(hosts, false, config, state)
|
||||
common.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts)))
|
||||
}
|
||||
|
||||
// 端口扫描
|
||||
alivePorts := s.discoverAlivePorts(hosts, config, state)
|
||||
if len(alivePorts) > 0 {
|
||||
targetInfos = s.convertToTargetInfos(alivePorts, baseInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return targetInfos, nil
|
||||
}
|
||||
|
||||
// shouldPerformLivenessCheck 判断是否需要执行存活性检测
|
||||
func (s *ServiceScanStrategy) shouldPerformLivenessCheck(hosts []string, config *common.Config) bool {
|
||||
return !config.DisablePing && len(hosts) > 1
|
||||
}
|
||||
|
||||
// discoverAlivePorts 发现存活的端口
|
||||
func (s *ServiceScanStrategy) discoverAlivePorts(hosts []string, config *common.Config, state *common.State) []string {
|
||||
var alivePorts []string
|
||||
|
||||
// 如果已经有明确指定的host:port,直接使用(让后续SmartIdentify统一验证和识别)
|
||||
hostPorts := state.GetHostPorts()
|
||||
if len(hostPorts) > 0 {
|
||||
alivePorts = hostPorts
|
||||
common.LogInfo(i18n.Tr("alive_ports_count", len(alivePorts)))
|
||||
state.ClearHostPorts()
|
||||
return alivePorts
|
||||
}
|
||||
|
||||
// 根据扫描模式选择端口扫描方式
|
||||
if len(hosts) > 0 {
|
||||
alivePorts = EnhancedPortScan(hosts, config.Target.Ports, int64(config.Timeout.Seconds()), config, state)
|
||||
}
|
||||
|
||||
return alivePorts
|
||||
}
|
||||
|
||||
// convertToTargetInfos 将端口列表转换为目标信息
|
||||
func (s *ServiceScanStrategy) convertToTargetInfos(ports []string, baseInfo common.HostInfo) []common.HostInfo {
|
||||
var infos []common.HostInfo
|
||||
|
||||
for _, targetIP := range ports {
|
||||
hostParts := strings.Split(targetIP, ":")
|
||||
if len(hostParts) != 2 {
|
||||
common.LogError(i18n.Tr("invalid_target_format", targetIP))
|
||||
continue
|
||||
}
|
||||
|
||||
// 去除空格并过滤空值
|
||||
host := strings.TrimSpace(hostParts[0])
|
||||
portStr := strings.TrimSpace(hostParts[1])
|
||||
if host == "" || portStr == "" {
|
||||
common.LogError(i18n.Tr("invalid_target_format", targetIP))
|
||||
continue
|
||||
}
|
||||
|
||||
// 验证端口范围(与scanner.go中parsePort保持一致)
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
common.LogError(i18n.Tr("host_port_invalid", host, portStr))
|
||||
continue
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
common.LogError(i18n.Tr("host_port_out_of_range", host, port))
|
||||
continue
|
||||
}
|
||||
|
||||
info := baseInfo
|
||||
info.Host = host
|
||||
info.Port = port
|
||||
// 深拷贝Info避免多个target共享slice底层数组
|
||||
if len(baseInfo.Info) > 0 {
|
||||
info.Info = append([]string(nil), baseInfo.Info...)
|
||||
}
|
||||
infos = append(infos, info)
|
||||
}
|
||||
|
||||
return infos
|
||||
}
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
/*
|
||||
service_scanner_test.go - ServiceScanStrategy核心逻辑测试
|
||||
|
||||
注意:service_scanner.go 包含大量网络IO和全局状态依赖。
|
||||
本测试文件专注于可测试的纯逻辑和算法正确性:
|
||||
1. parsePortList - 端口解析逻辑
|
||||
2. shouldPerformLivenessCheck - 存活检测判断
|
||||
3. convertToTargetInfos - host:port数据转换
|
||||
|
||||
不测试的部分(需要集成测试):
|
||||
- Execute, performHostScan - 网络IO + 全局状态
|
||||
- discoverTargets - 依赖CheckLive, EnhancedPortScan
|
||||
- handleUDPPorts - 依赖全局common.Port
|
||||
- LogPluginInfo - 依赖插件系统和日志
|
||||
|
||||
"端口解析和数据转换是纯函数,应该测试。
|
||||
网络扫描和插件管理是副作用,需要集成测试。"
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// 核心逻辑测试:端口解析
|
||||
// =============================================================================
|
||||
|
||||
/*
|
||||
端口列表解析 - parsePortList 方法测试
|
||||
|
||||
测试价值:用户指定端口解析是扫描器的核心入口,解析错误会导致:
|
||||
- 扫描错误的端口
|
||||
- 跳过用户指定的端口
|
||||
- 扫描非法端口导致崩溃
|
||||
|
||||
"端口解析看起来简单,但涉及字符串转数字、范围验证、错误处理。
|
||||
这是真实的业务逻辑,bug会直接影响用户体验。必须测试。"
|
||||
*/
|
||||
|
||||
// TestParsePortList_BasicParsing 测试基本的端口解析
|
||||
func TestParsePortList_BasicParsing(t *testing.T) {
|
||||
s := NewServiceScanStrategy()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []int
|
||||
}{
|
||||
{
|
||||
name: "单个端口",
|
||||
input: "22",
|
||||
expected: []int{22},
|
||||
},
|
||||
{
|
||||
name: "两个端口-逗号分隔",
|
||||
input: "22,80",
|
||||
expected: []int{22, 80},
|
||||
},
|
||||
{
|
||||
name: "多个端口",
|
||||
input: "22,80,443,3306",
|
||||
expected: []int{22, 80, 443, 3306},
|
||||
},
|
||||
{
|
||||
name: "空字符串",
|
||||
input: "",
|
||||
expected: []int{},
|
||||
},
|
||||
{
|
||||
name: "all关键字",
|
||||
input: "all",
|
||||
expected: []int{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := s.parsePortList(tt.input)
|
||||
if !intSlicesEqual(result, tt.expected) {
|
||||
t.Errorf("parsePortList(%q) = %v, want %v",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePortList_Whitespace 测试空格处理
|
||||
func TestParsePortList_Whitespace(t *testing.T) {
|
||||
s := NewServiceScanStrategy()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []int
|
||||
}{
|
||||
{
|
||||
name: "端口前后有空格",
|
||||
input: " 22 ",
|
||||
expected: []int{22},
|
||||
},
|
||||
{
|
||||
name: "逗号前后有空格",
|
||||
input: "22 , 80",
|
||||
expected: []int{22, 80},
|
||||
},
|
||||
{
|
||||
name: "多个空格",
|
||||
input: " 22 , 80 , 443 ",
|
||||
expected: []int{22, 80, 443},
|
||||
},
|
||||
{
|
||||
name: "Tab字符",
|
||||
input: "22\t,\t80",
|
||||
expected: []int{22, 80},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := s.parsePortList(tt.input)
|
||||
if !intSlicesEqual(result, tt.expected) {
|
||||
t.Errorf("parsePortList(%q) = %v, want %v",
|
||||
tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePortList_RangeValidation 测试端口范围验证
|
||||
func TestParsePortList_RangeValidation(t *testing.T) {
|
||||
s := NewServiceScanStrategy()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []int
|
||||
note string
|
||||
}{
|
||||
{
|
||||
name: "最小有效端口-1",
|
||||
input: "1",
|
||||
expected: []int{1},
|
||||
note: "端口1是最小的有效端口",
|
||||
},
|
||||
{
|
||||
name: "最大有效端口-65535",
|
||||
input: "65535",
|
||||
expected: []int{65535},
|
||||
note: "端口65535是最大的有效端口",
|
||||
},
|
||||
{
|
||||
name: "边界值-1和65535",
|
||||
input: "1,65535",
|
||||
expected: []int{1, 65535},
|
||||
note: "测试边界值组合",
|
||||
},
|
||||
{
|
||||
name: "端口0-无效",
|
||||
input: "0",
|
||||
expected: []int{},
|
||||
note: "端口0应该被忽略",
|
||||
},
|
||||
{
|
||||
name: "端口65536-超出范围",
|
||||
input: "65536",
|
||||
expected: []int{},
|
||||
note: "超出最大端口应该被忽略",
|
||||
},
|
||||
{
|
||||
name: "负数端口",
|
||||
input: "-1",
|
||||
expected: []int{},
|
||||
note: "负数端口应该被忽略",
|
||||
},
|
||||
{
|
||||
name: "混合有效和无效端口",
|
||||
input: "0,22,80,65536,443",
|
||||
expected: []int{22, 80, 443},
|
||||
note: "只保留有效端口",
|
||||
},
|
||||
{
|
||||
name: "常见端口范围边界",
|
||||
input: "1,1023,1024,49151,49152,65535",
|
||||
expected: []int{1, 1023, 1024, 49151, 49152, 65535},
|
||||
note: "测试特权端口、注册端口、动态端口的边界",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := s.parsePortList(tt.input)
|
||||
if !intSlicesEqual(result, tt.expected) {
|
||||
t.Errorf("parsePortList(%q) = %v, want %v\nNote: %s",
|
||||
tt.input, result, tt.expected, tt.note)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePortList_InvalidInput 测试非法输入处理
|
||||
func TestParsePortList_InvalidInput(t *testing.T) {
|
||||
s := NewServiceScanStrategy()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected []int
|
||||
note string
|
||||
}{
|
||||
{
|
||||
name: "非数字字符",
|
||||
input: "abc",
|
||||
expected: []int{},
|
||||
note: "非数字应该被忽略",
|
||||
},
|
||||
{
|
||||
name: "混合数字和字母",
|
||||
input: "22,abc,80",
|
||||
expected: []int{22, 80},
|
||||
note: "只提取有效的数字",
|
||||
},
|
||||
{
|
||||
name: "小数",
|
||||
input: "22.5",
|
||||
expected: []int{},
|
||||
note: "小数应该被忽略",
|
||||
},
|
||||
{
|
||||
name: "科学计数法",
|
||||
input: "1e3",
|
||||
expected: []int{},
|
||||
note: "科学计数法应该被忽略",
|
||||
},
|
||||
{
|
||||
name: "空白项",
|
||||
input: "22,,80",
|
||||
expected: []int{22, 80},
|
||||
note: "空白项应该被跳过",
|
||||
},
|
||||
{
|
||||
name: "仅逗号",
|
||||
input: ",,,",
|
||||
expected: []int{},
|
||||
note: "仅逗号应该返回空列表",
|
||||
},
|
||||
{
|
||||
name: "超大数字",
|
||||
input: "999999",
|
||||
expected: []int{},
|
||||
note: "超大数字应该被忽略",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := s.parsePortList(tt.input)
|
||||
if !intSlicesEqual(result, tt.expected) {
|
||||
t.Errorf("parsePortList(%q) = %v, want %v\nNote: %s",
|
||||
tt.input, result, tt.expected, tt.note)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParsePortList_ProductionScenarios 测试生产环境真实场景
|
||||
func TestParsePortList_ProductionScenarios(t *testing.T) {
|
||||
s := NewServiceScanStrategy()
|
||||
|
||||
t.Run("常见Web端口", func(t *testing.T) {
|
||||
input := "80,443,8080,8443"
|
||||
expected := []int{80, 443, 8080, 8443}
|
||||
result := s.parsePortList(input)
|
||||
if !intSlicesEqual(result, expected) {
|
||||
t.Errorf("应该正确解析常见Web端口")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("数据库端口", func(t *testing.T) {
|
||||
input := "3306,5432,1433,27017"
|
||||
expected := []int{3306, 5432, 1433, 27017}
|
||||
result := s.parsePortList(input)
|
||||
if !intSlicesEqual(result, expected) {
|
||||
t.Errorf("应该正确解析常见数据库端口")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("用户复制粘贴带空格", func(t *testing.T) {
|
||||
// 用户从文档复制 "22, 80, 443" 粘贴到命令行
|
||||
input := "22, 80, 443"
|
||||
expected := []int{22, 80, 443}
|
||||
result := s.parsePortList(input)
|
||||
if !intSlicesEqual(result, expected) {
|
||||
t.Errorf("应该正确处理用户复制粘贴的空格")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("用户手误输入无效端口", func(t *testing.T) {
|
||||
// 用户错误输入了0端口
|
||||
input := "0,22,80"
|
||||
expected := []int{22, 80}
|
||||
result := s.parsePortList(input)
|
||||
if !intSlicesEqual(result, expected) {
|
||||
t.Errorf("应该过滤掉无效端口0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("高端口号-动态端口", func(t *testing.T) {
|
||||
// 测试动态端口范围 49152-65535
|
||||
input := "49152,50000,60000,65535"
|
||||
expected := []int{49152, 50000, 60000, 65535}
|
||||
result := s.parsePortList(input)
|
||||
if !intSlicesEqual(result, expected) {
|
||||
t.Errorf("应该正确解析高端口号")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestParsePortList_ReturnValue 测试返回值特性
|
||||
func TestParsePortList_ReturnValue(t *testing.T) {
|
||||
s := NewServiceScanStrategy()
|
||||
|
||||
t.Run("返回切片而非nil", func(t *testing.T) {
|
||||
result := s.parsePortList("")
|
||||
if result == nil {
|
||||
t.Error("空输入应该返回空切片,而不是nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("端口不重复-但不保证去重", func(t *testing.T) {
|
||||
// 注意:当前实现不去重,如果用户输入 "22,22",会返回 [22, 22]
|
||||
// 这是可以接受的,因为上层逻辑会处理重复
|
||||
input := "22,22"
|
||||
result := s.parsePortList(input)
|
||||
// 这里我们只测试解析是否正确,不测试去重
|
||||
if len(result) != 2 || result[0] != 22 || result[1] != 22 {
|
||||
t.Errorf("当前实现不去重,应该返回两个22")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// intSlicesEqual 比较两个int切片是否相等
|
||||
func intSlicesEqual(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 存活检测判断测试
|
||||
// =============================================================================
|
||||
|
||||
// TestShouldPerformLivenessCheck 测试存活检测判断逻辑
|
||||
func TestShouldPerformLivenessCheck(t *testing.T) {
|
||||
strategy := NewServiceScanStrategy()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
hosts []string
|
||||
disablePing bool
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "多主机+允许Ping",
|
||||
hosts: []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"},
|
||||
disablePing: false,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "多主机+禁用Ping",
|
||||
hosts: []string{"192.168.1.1", "192.168.1.2"},
|
||||
disablePing: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "单主机+允许Ping",
|
||||
hosts: []string{"192.168.1.1"},
|
||||
disablePing: false,
|
||||
expected: false, // 单主机不需要存活检测
|
||||
},
|
||||
{
|
||||
name: "单主机+禁用Ping",
|
||||
hosts: []string{"192.168.1.1"},
|
||||
disablePing: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "空主机列表+允许Ping",
|
||||
hosts: []string{},
|
||||
disablePing: false,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "空主机列表+禁用Ping",
|
||||
hosts: []string{},
|
||||
disablePing: true,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "两个主机-边界情况",
|
||||
hosts: []string{"192.168.1.1", "192.168.1.2"},
|
||||
disablePing: false,
|
||||
expected: true, // >1 触发检测
|
||||
},
|
||||
{
|
||||
name: "大量主机",
|
||||
hosts: make([]string, 100),
|
||||
disablePing: false,
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 设置 Config 对象
|
||||
cfg := common.GetGlobalConfig()
|
||||
oldDisablePing := cfg.DisablePing
|
||||
cfg.DisablePing = tt.disablePing
|
||||
defer func() {
|
||||
cfg.DisablePing = oldDisablePing
|
||||
}()
|
||||
|
||||
result := strategy.shouldPerformLivenessCheck(tt.hosts, cfg)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("shouldPerformLivenessCheck() = %v, 期望 %v (hosts=%d, disablePing=%v)",
|
||||
result, tt.expected, len(tt.hosts), tt.disablePing)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 数据转换测试
|
||||
// =============================================================================
|
||||
|
||||
// TestConvertToTargetInfos 测试端口列表转目标信息
|
||||
func TestConvertToTargetInfos(t *testing.T) {
|
||||
strategy := NewServiceScanStrategy()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ports []string
|
||||
baseInfo common.HostInfo
|
||||
expectedLen int
|
||||
validateFunc func(*testing.T, []common.HostInfo)
|
||||
}{
|
||||
{
|
||||
name: "单个目标",
|
||||
ports: []string{"192.168.1.1:80"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 1,
|
||||
validateFunc: func(t *testing.T, infos []common.HostInfo) {
|
||||
if infos[0].Host != "192.168.1.1" {
|
||||
t.Errorf("Host = %q, 期望 '192.168.1.1'", infos[0].Host)
|
||||
}
|
||||
if infos[0].Port != 80 {
|
||||
t.Errorf("Ports = %q, 期望 '80'", infos[0].Port)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "多个目标",
|
||||
ports: []string{"192.168.1.1:80", "192.168.1.2:443", "192.168.1.3:8080"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 3,
|
||||
validateFunc: func(t *testing.T, infos []common.HostInfo) {
|
||||
expected := []struct {
|
||||
host string
|
||||
port int
|
||||
}{
|
||||
{"192.168.1.1", 80},
|
||||
{"192.168.1.2", 443},
|
||||
{"192.168.1.3", 8080},
|
||||
}
|
||||
for i, exp := range expected {
|
||||
if infos[i].Host != exp.host {
|
||||
t.Errorf("infos[%d].Host = %q, 期望 %q", i, infos[i].Host, exp.host)
|
||||
}
|
||||
if infos[i].Port != exp.port {
|
||||
t.Errorf("infos[%d].Port = %d, 期望 %d", i, infos[i].Port, exp.port)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "继承baseInfo属性",
|
||||
ports: []string{"192.168.1.1:80"},
|
||||
baseInfo: common.HostInfo{
|
||||
URL: "http://example.com",
|
||||
Info: []string{"info1", "info2"},
|
||||
},
|
||||
expectedLen: 1,
|
||||
validateFunc: func(t *testing.T, infos []common.HostInfo) {
|
||||
if infos[0].URL != "http://example.com" {
|
||||
t.Errorf("URL = %q, 期望 'http://example.com'", infos[0].URL)
|
||||
}
|
||||
if len(infos[0].Info) != 2 {
|
||||
t.Errorf("Infostr长度 = %d, 期望 2", len(infos[0].Info))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "空端口列表",
|
||||
ports: []string{},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 0,
|
||||
validateFunc: nil,
|
||||
},
|
||||
{
|
||||
name: "非法格式-无冒号",
|
||||
ports: []string{"192.168.1.1"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 0, // 非法格式被过滤
|
||||
validateFunc: nil,
|
||||
},
|
||||
{
|
||||
name: "非法格式-多个冒号",
|
||||
ports: []string{"192.168.1.1:80:443"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 0, // 非法格式被过滤
|
||||
validateFunc: nil,
|
||||
},
|
||||
{
|
||||
name: "混合-有效和无效",
|
||||
ports: []string{"192.168.1.1:80", "invalid", "192.168.1.2:443"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 2,
|
||||
validateFunc: func(t *testing.T, infos []common.HostInfo) {
|
||||
if infos[0].Host != "192.168.1.1" || infos[0].Port != 80 {
|
||||
t.Errorf("第一个目标错误: %s:%d", infos[0].Host, infos[0].Port)
|
||||
}
|
||||
if infos[1].Host != "192.168.1.2" || infos[1].Port != 443 {
|
||||
t.Errorf("第二个目标错误: %s:%d", infos[1].Host, infos[1].Port)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "IPv6地址",
|
||||
ports: []string{"::1:8080"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 0, // Split会产生多个部分,被判定为非法
|
||||
validateFunc: nil,
|
||||
},
|
||||
{
|
||||
name: "域名+端口",
|
||||
ports: []string{"example.com:80", "test.local:443"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 2,
|
||||
validateFunc: func(t *testing.T, infos []common.HostInfo) {
|
||||
if infos[0].Host != "example.com" {
|
||||
t.Errorf("Host = %q, 期望 'example.com'", infos[0].Host)
|
||||
}
|
||||
if infos[1].Host != "test.local" {
|
||||
t.Errorf("Host = %q, 期望 'test.local'", infos[1].Host)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "端口为0-被拒绝",
|
||||
ports: []string{"192.168.1.1:0"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 0, // 修复后:端口0被验证并拒绝
|
||||
validateFunc: nil,
|
||||
},
|
||||
{
|
||||
name: "高端口-65535合法",
|
||||
ports: []string{"192.168.1.1:65535"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 1,
|
||||
validateFunc: func(t *testing.T, infos []common.HostInfo) {
|
||||
if infos[0].Port != 65535 {
|
||||
t.Errorf("Ports = %q, 期望 '65535'", infos[0].Port)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "超大端口-被拒绝",
|
||||
ports: []string{"192.168.1.1:65536"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 0, // 修复后:端口65536被拒绝
|
||||
validateFunc: nil,
|
||||
},
|
||||
{
|
||||
name: "负数端口-被拒绝",
|
||||
ports: []string{"192.168.1.1:-80"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 0, // 修复后:负数端口被拒绝
|
||||
validateFunc: nil,
|
||||
},
|
||||
{
|
||||
name: "混合-过滤非法端口",
|
||||
ports: []string{"192.168.1.1:80", "192.168.1.2:0", "192.168.1.3:65536", "192.168.1.4:443"},
|
||||
baseInfo: common.HostInfo{},
|
||||
expectedLen: 2, // 只有80和443合法
|
||||
validateFunc: func(t *testing.T, infos []common.HostInfo) {
|
||||
if infos[0].Port != 80 {
|
||||
t.Errorf("第一个端口 = %q, 期望 '80'", infos[0].Port)
|
||||
}
|
||||
if infos[1].Port != 443 {
|
||||
t.Errorf("第二个端口 = %q, 期望 '443'", infos[1].Port)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := strategy.convertToTargetInfos(tt.ports, tt.baseInfo)
|
||||
|
||||
// 验证长度
|
||||
if len(result) != tt.expectedLen {
|
||||
t.Errorf("convertToTargetInfos() 长度 = %d, 期望 %d", len(result), tt.expectedLen)
|
||||
}
|
||||
|
||||
// 执行自定义验证
|
||||
if tt.validateFunc != nil && len(result) > 0 {
|
||||
tt.validateFunc(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 边界情况测试
|
||||
// =============================================================================
|
||||
|
||||
// TestConvertToTargetInfos_EdgeCases 测试边界情况
|
||||
func TestConvertToTargetInfos_EdgeCases(t *testing.T) {
|
||||
strategy := NewServiceScanStrategy()
|
||||
|
||||
t.Run("空字符串端口", func(t *testing.T) {
|
||||
ports := []string{""}
|
||||
result := strategy.convertToTargetInfos(ports, common.HostInfo{})
|
||||
if len(result) != 0 {
|
||||
t.Errorf("空字符串应被过滤, 实际长度 %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("只有冒号", func(t *testing.T) {
|
||||
ports := []string{":"}
|
||||
result := strategy.convertToTargetInfos(ports, common.HostInfo{})
|
||||
// 修复后:Split产生["", ""],TrimSpace后都是空,被过滤
|
||||
if len(result) != 0 {
|
||||
t.Errorf("只有冒号应被过滤, 实际长度 %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("冒号前后有空格", func(t *testing.T) {
|
||||
ports := []string{"192.168.1.1 : 80"}
|
||||
result := strategy.convertToTargetInfos(ports, common.HostInfo{})
|
||||
// 修复后:Split产生["192.168.1.1 ", " 80"],TrimSpace后去除空格
|
||||
if len(result) != 1 {
|
||||
t.Errorf("带空格的冒号应产生1个结果, 实际长度 %d", len(result))
|
||||
}
|
||||
if len(result) > 0 {
|
||||
// 修复后:空格应被去除
|
||||
if result[0].Host != "192.168.1.1" {
|
||||
t.Errorf("Host = %q, 期望 '192.168.1.1'(空格已去除)", result[0].Host)
|
||||
}
|
||||
if result[0].Port != 80 {
|
||||
t.Errorf("Ports = %q, 期望 '80'(空格已去除)", result[0].Port)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("大量目标", func(t *testing.T) {
|
||||
var ports []string
|
||||
for i := 1; i <= 1000; i++ {
|
||||
ports = append(ports, "192.168.1.1:"+string(rune(i)))
|
||||
}
|
||||
result := strategy.convertToTargetInfos(ports, common.HostInfo{})
|
||||
// 由于端口是rune转换,大部分会失败,只验证不panic
|
||||
if result == nil {
|
||||
t.Error("不应返回nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestParsePortList_SpecialCases 测试特殊情况
|
||||
func TestParsePortList_SpecialCases(t *testing.T) {
|
||||
strategy := NewServiceScanStrategy()
|
||||
|
||||
t.Run("Unicode空格", func(t *testing.T) {
|
||||
// 包含全角空格
|
||||
result := strategy.parsePortList("80,443")
|
||||
// 全角逗号不会被分割,整个字符串作为一个部分
|
||||
if len(result) != 0 {
|
||||
t.Errorf("全角逗号应导致解析失败, 实际长度 %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("制表符分隔", func(t *testing.T) {
|
||||
result := strategy.parsePortList("80\t443")
|
||||
// 制表符不是逗号,不会分割
|
||||
if len(result) != 0 {
|
||||
t.Errorf("制表符不应分割端口, 实际长度 %d", len(result))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("换行符", func(t *testing.T) {
|
||||
result := strategy.parsePortList("80\n443")
|
||||
// 换行符不是逗号
|
||||
if len(result) != 0 {
|
||||
t.Errorf("换行符不应分割端口, 实际长度 %d", len(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestShouldPerformLivenessCheck_ConcurrentSafety 测试并发安全性
|
||||
func TestShouldPerformLivenessCheck_ConcurrentSafety(t *testing.T) {
|
||||
strategy := NewServiceScanStrategy()
|
||||
hosts := []string{"192.168.1.1", "192.168.1.2"}
|
||||
|
||||
// 保存原始值
|
||||
cfg := common.GetGlobalConfig()
|
||||
oldDisablePing := cfg.DisablePing
|
||||
defer func() {
|
||||
cfg.DisablePing = oldDisablePing
|
||||
}()
|
||||
|
||||
cfg.DisablePing = false
|
||||
|
||||
// 并发调用
|
||||
done := make(chan bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
go func() {
|
||||
_ = strategy.shouldPerformLivenessCheck(hosts, cfg)
|
||||
done <- true
|
||||
}()
|
||||
}
|
||||
|
||||
// 等待所有goroutine完成
|
||||
for i := 0; i < 100; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 深拷贝测试
|
||||
// =============================================================================
|
||||
|
||||
// TestConvertToTargetInfos_DeepCopy 测试Infostr深拷贝
|
||||
func TestConvertToTargetInfos_DeepCopy(t *testing.T) {
|
||||
strategy := NewServiceScanStrategy()
|
||||
|
||||
t.Run("Infostr深拷贝验证", func(t *testing.T) {
|
||||
baseInfo := common.HostInfo{
|
||||
Info: []string{"info1", "info2"},
|
||||
}
|
||||
|
||||
// 转换两个目标
|
||||
result := strategy.convertToTargetInfos(
|
||||
[]string{"192.168.1.1:80", "192.168.1.2:80"},
|
||||
baseInfo,
|
||||
)
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("期望2个结果, 实际 %d", len(result))
|
||||
}
|
||||
|
||||
// 验证初始状态:两个target的Infostr应该相等但不共享底层数组
|
||||
if len(result[0].Info) != 2 || len(result[1].Info) != 2 {
|
||||
t.Error("Infostr应被正确复制")
|
||||
}
|
||||
|
||||
// 关键测试:修改第一个target的Infostr
|
||||
result[0].Info = append(result[0].Info, "modified")
|
||||
|
||||
// 验证第二个target的Infostr未被影响(深拷贝成功)
|
||||
if len(result[1].Info) != 2 {
|
||||
t.Errorf("深拷贝失败: result[1].Info长度 = %d, 期望 2 (不应受result[0]影响)",
|
||||
len(result[1].Info))
|
||||
}
|
||||
|
||||
// 验证baseInfo的Infostr也未被影响
|
||||
if len(baseInfo.Info) != 2 {
|
||||
t.Errorf("深拷贝失败: baseInfo.Info长度 = %d, 期望 2 (不应受修改影响)",
|
||||
len(baseInfo.Info))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空Infostr不panic", func(t *testing.T) {
|
||||
baseInfo := common.HostInfo{
|
||||
Info: nil,
|
||||
}
|
||||
|
||||
result := strategy.convertToTargetInfos(
|
||||
[]string{"192.168.1.1:80"},
|
||||
baseInfo,
|
||||
)
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("期望1个结果, 实际 %d", len(result))
|
||||
}
|
||||
|
||||
// 验证不会panic
|
||||
if result[0].Info != nil {
|
||||
t.Error("nil Infostr应保持nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空slice不分配内存", func(t *testing.T) {
|
||||
baseInfo := common.HostInfo{
|
||||
Info: []string{},
|
||||
}
|
||||
|
||||
result := strategy.convertToTargetInfos(
|
||||
[]string{"192.168.1.1:80"},
|
||||
baseInfo,
|
||||
)
|
||||
|
||||
// 空slice应该被跳过深拷贝(性能优化)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("期望1个结果, 实际 %d", len(result))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// highPriorityPorts 高价值端口优先级表
|
||||
// 数字越小优先级越高,用户最关心这些服务能快速出结果
|
||||
var highPriorityPorts = map[int]int{
|
||||
80: 1, // HTTP
|
||||
443: 2, // HTTPS
|
||||
22: 3, // SSH
|
||||
3389: 4, // RDP
|
||||
445: 5, // SMB
|
||||
3306: 6, // MySQL
|
||||
1433: 7, // MSSQL
|
||||
6379: 8, // Redis
|
||||
21: 9, // FTP
|
||||
23: 10, // Telnet
|
||||
8080: 11, // HTTP-Alt
|
||||
8443: 12, // HTTPS-Alt
|
||||
5432: 13, // PostgreSQL
|
||||
27017: 14, // MongoDB
|
||||
1521: 15, // Oracle
|
||||
5900: 16, // VNC
|
||||
25: 17, // SMTP
|
||||
110: 18, // POP3
|
||||
143: 19, // IMAP
|
||||
53: 20, // DNS
|
||||
}
|
||||
|
||||
// SocketIterator 流式生成 host:port 组合
|
||||
// 设计原则:O(1) 内存,按需生成
|
||||
// 使用端口喷洒策略:Port1全IP -> Port2全IP -> ...
|
||||
// 优势:流量分散,避免单IP限速
|
||||
type SocketIterator struct {
|
||||
hosts []string
|
||||
ports []int
|
||||
hostIdx int
|
||||
portIdx int
|
||||
total int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewSocketIterator 创建流式迭代器
|
||||
// 自动对端口进行智能排序:高价值端口优先,让用户更快看到有意义的结果
|
||||
func NewSocketIterator(hosts []string, ports []int, exclude map[int]struct{}) *SocketIterator {
|
||||
validPorts := filterExcludedPorts(ports, exclude)
|
||||
sortedPorts := sortPortsByPriority(validPorts)
|
||||
return &SocketIterator{
|
||||
hosts: hosts,
|
||||
ports: sortedPorts,
|
||||
total: len(hosts) * len(sortedPorts),
|
||||
}
|
||||
}
|
||||
|
||||
// sortPortsByPriority 智能排序端口
|
||||
// 策略:高价值端口优先,其余按数字升序
|
||||
func sortPortsByPriority(ports []int) []int {
|
||||
if len(ports) <= 1 {
|
||||
return ports
|
||||
}
|
||||
|
||||
result := make([]int, len(ports))
|
||||
copy(result, ports)
|
||||
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
pi, pj := result[i], result[j]
|
||||
priI, okI := highPriorityPorts[pi]
|
||||
priJ, okJ := highPriorityPorts[pj]
|
||||
|
||||
// 都有优先级:按优先级排序
|
||||
if okI && okJ {
|
||||
return priI < priJ
|
||||
}
|
||||
// 只有一个有优先级:有优先级的排前面
|
||||
if okI {
|
||||
return true
|
||||
}
|
||||
if okJ {
|
||||
return false
|
||||
}
|
||||
// 都没有优先级:按端口号升序
|
||||
return pi < pj
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Next 返回下一个 host:port 组合,ok=false 表示迭代结束
|
||||
// 端口喷洒顺序:先遍历所有IP的同一端口,再换下一个端口
|
||||
func (it *SocketIterator) Next() (string, int, bool) {
|
||||
it.mu.Lock()
|
||||
defer it.mu.Unlock()
|
||||
|
||||
// 空输入或迭代结束
|
||||
if len(it.hosts) == 0 || it.portIdx >= len(it.ports) {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
host := it.hosts[it.hostIdx]
|
||||
port := it.ports[it.portIdx]
|
||||
|
||||
// 端口喷洒:先遍历所有IP,再换端口
|
||||
it.hostIdx++
|
||||
if it.hostIdx >= len(it.hosts) {
|
||||
it.hostIdx = 0
|
||||
it.portIdx++
|
||||
}
|
||||
|
||||
return host, port, true
|
||||
}
|
||||
|
||||
// Total 返回总任务数(用于进度条)
|
||||
func (it *SocketIterator) Total() int {
|
||||
return it.total
|
||||
}
|
||||
|
||||
// filterExcludedPorts 过滤排除的端口
|
||||
func filterExcludedPorts(ports []int, exclude map[int]struct{}) []int {
|
||||
if len(exclude) == 0 {
|
||||
return ports
|
||||
}
|
||||
result := make([]int, 0, len(ports))
|
||||
for _, p := range ports {
|
||||
if _, excluded := exclude[p]; !excluded {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/*
|
||||
socket_iterator_test.go - SocketIterator 高价值测试
|
||||
|
||||
测试重点:
|
||||
1. 端口喷洒顺序 - 这是核心设计,顺序错误会导致单IP限速
|
||||
2. 并发安全性 - 多worker并发调用Next()不丢失不重复
|
||||
3. 边界情况 - 空输入、单元素
|
||||
|
||||
不测试:
|
||||
- getter方法(Total)- 太简单
|
||||
- 内部状态 - 只关心外部行为
|
||||
*/
|
||||
|
||||
// TestSocketIterator_PortSprayOrder 验证端口喷洒顺序
|
||||
//
|
||||
// 这是最重要的测试:顺序必须是先遍历所有IP的同一端口,再换端口
|
||||
// 正确顺序:Port1[IP1,IP2,IP3] → Port2[IP1,IP2,IP3]
|
||||
// 错误顺序:IP1[Port1,Port2,Port3] → IP2[Port1,Port2,Port3]
|
||||
func TestSocketIterator_PortSprayOrder(t *testing.T) {
|
||||
hosts := []string{"192.168.1.1", "192.168.1.2", "192.168.1.3"}
|
||||
ports := []int{80, 443}
|
||||
|
||||
it := NewSocketIterator(hosts, ports, nil)
|
||||
|
||||
// 期望的顺序:先所有IP的80端口,再所有IP的443端口
|
||||
expected := []struct {
|
||||
host string
|
||||
port int
|
||||
}{
|
||||
{"192.168.1.1", 80},
|
||||
{"192.168.1.2", 80},
|
||||
{"192.168.1.3", 80},
|
||||
{"192.168.1.1", 443},
|
||||
{"192.168.1.2", 443},
|
||||
{"192.168.1.3", 443},
|
||||
}
|
||||
|
||||
for i, exp := range expected {
|
||||
host, port, ok := it.Next()
|
||||
if !ok {
|
||||
t.Fatalf("第%d次迭代提前结束", i+1)
|
||||
}
|
||||
if host != exp.host || port != exp.port {
|
||||
t.Errorf("第%d次迭代: 期望 %s:%d, 实际 %s:%d",
|
||||
i+1, exp.host, exp.port, host, port)
|
||||
}
|
||||
}
|
||||
|
||||
// 验证迭代结束
|
||||
_, _, ok := it.Next()
|
||||
if ok {
|
||||
t.Error("迭代应该已结束")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSocketIterator_ConcurrentSafety 验证并发安全性
|
||||
//
|
||||
// 多个goroutine同时调用Next(),所有任务必须:
|
||||
// 1. 不丢失 - 每个host:port组合只出现一次
|
||||
// 2. 不重复 - 总数等于预期
|
||||
func TestSocketIterator_ConcurrentSafety(t *testing.T) {
|
||||
// 构造较大的测试集
|
||||
hosts := make([]string, 100)
|
||||
for i := range hosts {
|
||||
hosts[i] = fmt.Sprintf("192.168.1.%d", i+1)
|
||||
}
|
||||
ports := []int{22, 80, 443, 3306, 6379}
|
||||
|
||||
it := NewSocketIterator(hosts, ports, nil)
|
||||
expectedTotal := len(hosts) * len(ports)
|
||||
|
||||
// 记录所有结果
|
||||
results := make(map[string]int)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 启动10个并发worker
|
||||
workers := 10
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
host, port, ok := it.Next()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("%s:%d", host, port)
|
||||
mu.Lock()
|
||||
results[key]++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 验证:每个组合只出现一次
|
||||
if len(results) != expectedTotal {
|
||||
t.Errorf("任务丢失或重复: 期望 %d 个唯一组合, 实际 %d", expectedTotal, len(results))
|
||||
}
|
||||
|
||||
// 验证:没有重复
|
||||
for key, count := range results {
|
||||
if count != 1 {
|
||||
t.Errorf("任务重复: %s 出现 %d 次", key, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSocketIterator_ExcludePorts 验证端口过滤
|
||||
func TestSocketIterator_ExcludePorts(t *testing.T) {
|
||||
hosts := []string{"192.168.1.1"}
|
||||
ports := []int{22, 80, 443, 3306}
|
||||
exclude := map[int]struct{}{
|
||||
80: {},
|
||||
3306: {},
|
||||
}
|
||||
|
||||
it := NewSocketIterator(hosts, ports, exclude)
|
||||
|
||||
// 应该只有22和443,按优先级排序:443(优先级2) 在 22(优先级3) 之前
|
||||
var gotPorts []int
|
||||
for {
|
||||
_, port, ok := it.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
gotPorts = append(gotPorts, port)
|
||||
}
|
||||
|
||||
if len(gotPorts) != 2 {
|
||||
t.Fatalf("期望2个端口, 实际 %d", len(gotPorts))
|
||||
}
|
||||
// 443优先级高于22,所以443在前
|
||||
if gotPorts[0] != 443 || gotPorts[1] != 22 {
|
||||
t.Errorf("期望 [443, 22] (按优先级排序), 实际 %v", gotPorts)
|
||||
}
|
||||
|
||||
// 验证Total也正确
|
||||
if it.Total() != 2 {
|
||||
t.Errorf("Total() 应该是2, 实际 %d", it.Total())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSocketIterator_EmptyInputs 验证边界情况
|
||||
func TestSocketIterator_EmptyInputs(t *testing.T) {
|
||||
t.Run("空hosts", func(t *testing.T) {
|
||||
it := NewSocketIterator(nil, []int{80}, nil)
|
||||
_, _, ok := it.Next()
|
||||
if ok {
|
||||
t.Error("空hosts应该立即返回false")
|
||||
}
|
||||
if it.Total() != 0 {
|
||||
t.Errorf("Total() 应该是0, 实际 %d", it.Total())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空ports", func(t *testing.T) {
|
||||
it := NewSocketIterator([]string{"192.168.1.1"}, nil, nil)
|
||||
_, _, ok := it.Next()
|
||||
if ok {
|
||||
t.Error("空ports应该立即返回false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("全部被排除", func(t *testing.T) {
|
||||
exclude := map[int]struct{}{80: {}, 443: {}}
|
||||
it := NewSocketIterator([]string{"192.168.1.1"}, []int{80, 443}, exclude)
|
||||
_, _, ok := it.Next()
|
||||
if ok {
|
||||
t.Error("全部端口被排除应该立即返回false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSocketIterator_PortPrioritySort 验证端口优先级排序
|
||||
// 高价值端口(80, 443, 22等)应该排在前面
|
||||
func TestSocketIterator_PortPrioritySort(t *testing.T) {
|
||||
hosts := []string{"192.168.1.1"}
|
||||
// 故意乱序输入,包含高优先级和普通端口
|
||||
ports := []int{9999, 22, 8888, 80, 7777, 443, 3389, 1234}
|
||||
|
||||
it := NewSocketIterator(hosts, ports, nil)
|
||||
|
||||
var gotPorts []int
|
||||
for {
|
||||
_, port, ok := it.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
gotPorts = append(gotPorts, port)
|
||||
}
|
||||
|
||||
// 期望顺序:高优先级端口按优先级排序,然后是普通端口按数字升序
|
||||
// 80(优先级1), 443(2), 22(3), 3389(4), 然后 1234, 7777, 8888, 9999
|
||||
expected := []int{80, 443, 22, 3389, 1234, 7777, 8888, 9999}
|
||||
|
||||
if len(gotPorts) != len(expected) {
|
||||
t.Fatalf("端口数量不匹配: 期望 %d, 实际 %d", len(expected), len(gotPorts))
|
||||
}
|
||||
|
||||
for i, exp := range expected {
|
||||
if gotPorts[i] != exp {
|
||||
t.Errorf("第%d个端口: 期望 %d, 实际 %d\n完整结果: %v", i, exp, gotPorts[i], gotPorts)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSocketIterator_SingleElements 验证单元素情况
|
||||
func TestSocketIterator_SingleElements(t *testing.T) {
|
||||
t.Run("单IP单端口", func(t *testing.T) {
|
||||
it := NewSocketIterator([]string{"10.0.0.1"}, []int{8080}, nil)
|
||||
|
||||
host, port, ok := it.Next()
|
||||
if !ok || host != "10.0.0.1" || port != 8080 {
|
||||
t.Errorf("期望 10.0.0.1:8080, 实际 %s:%d, ok=%v", host, port, ok)
|
||||
}
|
||||
|
||||
_, _, ok = it.Next()
|
||||
if ok {
|
||||
t.Error("应该只有一个元素")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
"github.com/shadow1ng/fscan/common/i18n"
|
||||
)
|
||||
|
||||
// ===============================
|
||||
// Web服务检测
|
||||
// ===============================
|
||||
|
||||
// WebPortDetector 简化的Web检测器 - 保持API兼容
|
||||
type WebPortDetector struct{}
|
||||
|
||||
// GetWebPortDetector 获取检测器实例 - 保持API兼容,删除单例模式
|
||||
func GetWebPortDetector() *WebPortDetector {
|
||||
return &WebPortDetector{}
|
||||
}
|
||||
|
||||
// DetectHTTPScheme 智能检测HTTP/HTTPS协议
|
||||
// 策略:TLS握手优先(快速且准确),失败后尝试HTTP
|
||||
// 返回: "https", "http", 或 "" (都不是Web服务)
|
||||
func DetectHTTPScheme(host string, port int, config *common.Config) string {
|
||||
// 优化:先快速检测 TCP 连通性
|
||||
if !isPortReachable(host, port, config) {
|
||||
return ""
|
||||
}
|
||||
|
||||
timeout := config.Network.WebTimeout
|
||||
addr := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
// 第一步:尝试TLS握手(优先检测HTTPS)
|
||||
// 优势:握手失败代价小,不需要发送完整HTTP请求
|
||||
tlsDialer := &net.Dialer{Timeout: timeout}
|
||||
tlsConn, err := tls.DialWithDialer(
|
||||
tlsDialer,
|
||||
"tcp", addr,
|
||||
&tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
MinVersion: tls.VersionTLS10, // 兼容老版本TLS
|
||||
},
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
_ = tlsConn.Close()
|
||||
return "https"
|
||||
}
|
||||
|
||||
// TLS握手失败,记录原因
|
||||
|
||||
// 第二步:尝试HTTP请求(回退检测HTTP)
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse // 不跟随重定向
|
||||
},
|
||||
}
|
||||
|
||||
// 使用HEAD请求(更轻量)
|
||||
httpURL := fmt.Sprintf("http://%s", addr)
|
||||
resp, err := client.Head(httpURL)
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
return "http"
|
||||
}
|
||||
|
||||
// HTTP也失败,记录并返回空
|
||||
return ""
|
||||
}
|
||||
|
||||
// createHTTPClient 创建统一的HTTP客户端 - 支持HTTP/HTTPS和代理
|
||||
func createHTTPClient(config *common.Config) *http.Client {
|
||||
timeout := config.Network.WebTimeout
|
||||
|
||||
// 创建基础Transport,配置连接和 TLS 超时
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
DisableKeepAlives: true,
|
||||
// 设置连接超时,避免长时间等待无响应的服务器
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: timeout,
|
||||
}).DialContext,
|
||||
// TLS 握手超时
|
||||
TLSHandshakeTimeout: timeout,
|
||||
}
|
||||
|
||||
// 配置代理设置
|
||||
networkConfig := config.Network
|
||||
if networkConfig.HTTPProxy != "" {
|
||||
// 使用HTTP代理
|
||||
if proxyURL, err := url.Parse(networkConfig.HTTPProxy); err == nil {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
} else {
|
||||
common.LogError(i18n.Tr("http_proxy_config_error", err))
|
||||
}
|
||||
} else if networkConfig.Socks5Proxy != "" {
|
||||
// 使用SOCKS5代理 - 需要特殊处理
|
||||
if _, err := url.Parse(networkConfig.Socks5Proxy); err == nil {
|
||||
// SOCKS5代理需要使用代理管理器
|
||||
// 这里先记录警告,建议使用HTTP代理进行Web检测
|
||||
common.LogError(i18n.GetText("socks5_not_supported_web"))
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse // 不跟随重定向
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DetectHTTPServiceOnly HTTP协议检测 - 保持API兼容,简化实现
|
||||
func (w *WebPortDetector) DetectHTTPServiceOnly(host string, port int, config *common.Config) bool {
|
||||
// 优化:先快速检测 TCP 连通性,避免在不可达端口上浪费双倍超时时间
|
||||
// 对于不存在的端口,这可以将检测时间从 2×timeout 减少到 1×timeout
|
||||
if !isPortReachable(host, port, config) {
|
||||
return false
|
||||
}
|
||||
|
||||
client := createHTTPClient(config)
|
||||
|
||||
// 尝试HTTP
|
||||
if w.tryHTTP(client, host, port, "http") {
|
||||
return true
|
||||
}
|
||||
|
||||
// 尝试HTTPS
|
||||
if w.tryHTTP(client, host, port, "https") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isPortReachable 快速检测端口是否可达(TCP 连接测试)
|
||||
// 用于在 HTTP/HTTPS 检测前过滤不可达端口,避免双重超时
|
||||
func isPortReachable(host string, port int, config *common.Config) bool {
|
||||
timeout := config.Network.WebTimeout
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_ = conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// tryHTTP 尝试HTTP请求 - 简化的核心逻辑
|
||||
func (w *WebPortDetector) tryHTTP(client *http.Client, host string, port int, protocol string) bool {
|
||||
// 构造URL
|
||||
var url string
|
||||
if (port == 80 && protocol == "http") || (port == 443 && protocol == "https") {
|
||||
url = fmt.Sprintf("%s://%s", protocol, host)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s://%s:%d", protocol, host, port)
|
||||
}
|
||||
|
||||
// 发送HEAD请求
|
||||
req, err := http.NewRequest("HEAD", url, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
req.Header.Set("User-Agent", "fscan-web-detector/2.1")
|
||||
req.Header.Set("Accept", "*/*")
|
||||
|
||||
// 使用统一的SafeHTTPDo以确保遵循限速策略和代理设置
|
||||
resp, err := common.SafeHTTPDo(client, req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 简单有效的判断:有HTTP状态码就是Web服务
|
||||
return resp.StatusCode > 0 && resp.StatusCode < 600
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// 基于服务指纹的Web服务识别
|
||||
// ===============================
|
||||
|
||||
// Web服务缓存 - 简化的全局缓存
|
||||
var (
|
||||
webServiceCache = make(map[string]*ServiceInfo)
|
||||
webCacheMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// IsWebServiceByFingerprint 基于服务指纹判断Web服务 - 保持API兼容
|
||||
// 服务识别规则 - 编译期常量,避免运行时分配
|
||||
var (
|
||||
nonWebKeywords = []string{
|
||||
"oracle", "mysql", "postgresql", "redis", "mongodb", "ssh",
|
||||
"telnet", "ftp", "smtp", "pop3", "imap", "ldap", "snmp", "vnc", "rdp", "smb",
|
||||
}
|
||||
webKeywords = []string{
|
||||
"http", "https", "ssl", "tls", "nginx", "apache", "iis", "tomcat",
|
||||
"jetty", "nodejs", "php", "asp", "jsp",
|
||||
}
|
||||
bannerKeywords = []string{"server:", "http/", "content-type:"}
|
||||
)
|
||||
|
||||
// IsWebServiceByFingerprint 通过指纹判断是否为Web服务
|
||||
func IsWebServiceByFingerprint(serviceInfo *ServiceInfo) bool {
|
||||
if serviceInfo == nil || serviceInfo.Name == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
serviceName := strings.ToLower(serviceInfo.Name)
|
||||
|
||||
// 非Web服务优先检查(短路)
|
||||
for _, keyword := range nonWebKeywords {
|
||||
if strings.Contains(serviceName, keyword) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Web服务名检查
|
||||
for _, keyword := range webKeywords {
|
||||
if strings.Contains(serviceName, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Banner特征检查
|
||||
if serviceInfo.Banner != "" {
|
||||
banner := strings.ToLower(serviceInfo.Banner)
|
||||
for _, keyword := range bannerKeywords {
|
||||
if strings.Contains(banner, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// MarkAsWebService 标记Web服务 - 保持API兼容
|
||||
func MarkAsWebService(host string, port int, serviceInfo *ServiceInfo) {
|
||||
cacheKey := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
webCacheMutex.Lock()
|
||||
defer webCacheMutex.Unlock()
|
||||
|
||||
webServiceCache[cacheKey] = serviceInfo
|
||||
}
|
||||
|
||||
// GetWebServiceInfo 获取Web服务信息
|
||||
func GetWebServiceInfo(host string, port int) (*ServiceInfo, bool) {
|
||||
cacheKey := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
webCacheMutex.RLock()
|
||||
defer webCacheMutex.RUnlock()
|
||||
|
||||
serviceInfo, exists := webServiceCache[cacheKey]
|
||||
return serviceInfo, exists
|
||||
}
|
||||
|
||||
// IsMarkedWebService 检查是否已标记为Web服务
|
||||
func IsMarkedWebService(host string, port int) bool {
|
||||
_, exists := GetWebServiceInfo(host, port)
|
||||
return exists
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// 指纹缓存
|
||||
// ===============================
|
||||
|
||||
// 指纹缓存 - 存储 host:port → 指纹列表的映射
|
||||
var (
|
||||
fingerprintCache = make(map[string][]string)
|
||||
fingerprintCacheMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// SetFingerprints 存储目标的指纹信息
|
||||
func SetFingerprints(host string, port int, fingerprints []string) {
|
||||
if len(fingerprints) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s:%d", host, port)
|
||||
|
||||
fingerprintCacheMutex.Lock()
|
||||
defer fingerprintCacheMutex.Unlock()
|
||||
|
||||
fingerprintCache[cacheKey] = fingerprints
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// Web扫描策略
|
||||
// ===============================
|
||||
|
||||
// WebScanStrategy Web扫描策略
|
||||
type WebScanStrategy struct {
|
||||
*BaseScanStrategy
|
||||
}
|
||||
|
||||
// NewWebScanStrategy 创建新的Web扫描策略
|
||||
func NewWebScanStrategy() *WebScanStrategy {
|
||||
return &WebScanStrategy{
|
||||
BaseScanStrategy: NewBaseScanStrategy("Web扫描", FilterWeb),
|
||||
}
|
||||
}
|
||||
|
||||
// Name 返回策略名称
|
||||
func (s *WebScanStrategy) Name() string {
|
||||
return i18n.GetText("scan_strategy_web_name")
|
||||
}
|
||||
|
||||
// Description 返回策略描述
|
||||
func (s *WebScanStrategy) Description() string {
|
||||
return i18n.GetText("scan_strategy_web_desc")
|
||||
}
|
||||
|
||||
// Execute 执行Web扫描策略
|
||||
func (s *WebScanStrategy) Execute(config *common.Config, state *common.State, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) {
|
||||
// 输出扫描开始信息
|
||||
s.LogScanStart()
|
||||
|
||||
// 验证插件配置
|
||||
if err := s.ValidateConfiguration(); err != nil {
|
||||
common.LogError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 准备URL目标
|
||||
targets := s.PrepareTargets(info, state)
|
||||
|
||||
// 输出插件信息
|
||||
s.LogPluginInfo(config)
|
||||
|
||||
// 执行扫描任务
|
||||
ExecuteScanTasks(config, state, targets, s, ch, wg)
|
||||
}
|
||||
|
||||
// PrepareTargets 准备URL目标列表
|
||||
func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common.State) []common.HostInfo {
|
||||
var targetInfos []common.HostInfo
|
||||
|
||||
// 首先从State获取URL目标
|
||||
urls := state.GetURLs()
|
||||
for _, urlStr := range urls {
|
||||
urlInfo := s.createTargetFromURL(baseInfo, urlStr)
|
||||
if urlInfo != nil {
|
||||
targetInfos = append(targetInfos, *urlInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果URLs为空但baseInfo.Url有值,使用baseInfo.URL
|
||||
if len(targetInfos) == 0 && baseInfo.URL != "" {
|
||||
urlInfo := s.createTargetFromURL(baseInfo, baseInfo.URL)
|
||||
if urlInfo != nil {
|
||||
targetInfos = append(targetInfos, *urlInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return targetInfos
|
||||
}
|
||||
|
||||
// createTargetFromURL 从URL创建目标信息
|
||||
func (s *WebScanStrategy) createTargetFromURL(baseInfo common.HostInfo, urlStr string) *common.HostInfo {
|
||||
// 确保URL包含协议头
|
||||
if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") {
|
||||
urlStr = "http://" + urlStr
|
||||
}
|
||||
|
||||
// 解析URL获取Host和Port信息
|
||||
parsedURL, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
common.LogError(i18n.Tr("url_parse_failed", urlStr, err))
|
||||
return nil
|
||||
}
|
||||
|
||||
urlInfo := baseInfo
|
||||
urlInfo.URL = urlStr
|
||||
urlInfo.Host = parsedURL.Hostname()
|
||||
|
||||
// 设置端口
|
||||
portStr := parsedURL.Port()
|
||||
if portStr == "" {
|
||||
// 根据协议设置默认端口
|
||||
if parsedURL.Scheme == "https" {
|
||||
urlInfo.Port = 443
|
||||
} else {
|
||||
urlInfo.Port = 80
|
||||
}
|
||||
} else {
|
||||
// 解析端口字符串为整数
|
||||
var port int
|
||||
if _, err := fmt.Sscanf(portStr, "%d", &port); err == nil {
|
||||
urlInfo.Port = port
|
||||
} else {
|
||||
// 解析失败时使用默认端口
|
||||
if parsedURL.Scheme == "https" {
|
||||
urlInfo.Port = 443
|
||||
} else {
|
||||
urlInfo.Port = 80
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标记为Web服务,确保Web插件能识别此目标
|
||||
MarkAsWebService(urlInfo.Host, urlInfo.Port, &ServiceInfo{Name: "http"})
|
||||
|
||||
return &urlInfo
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
/*
|
||||
web_scanner_test.go - WebScanner核心逻辑测试
|
||||
|
||||
注意:web_scanner.go 包含网络IO和缓存管理。
|
||||
本测试文件专注于可测试的纯逻辑和算法正确性:
|
||||
1. IsWebServiceByFingerprint - Web服务识别逻辑
|
||||
2. createTargetFromURL - URL解析和HostInfo构建
|
||||
3. 缓存操作 - MarkAsWebService, GetWebServiceInfo, IsMarkedWebService
|
||||
4. 指纹缓存 - SetFingerprints, GetFingerprints
|
||||
|
||||
不测试的部分(需要集成测试):
|
||||
- createHTTPClient - 依赖全局配置
|
||||
- tryHTTP, DetectHTTPServiceOnly - 网络IO
|
||||
- Execute - 完整流程
|
||||
|
||||
"服务识别和URL解析是纯逻辑,应该测试。
|
||||
缓存操作需要验证并发安全性。"
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// 核心逻辑测试:Web服务识别
|
||||
// =============================================================================
|
||||
|
||||
// TestIsWebServiceByFingerprint 测试Web服务识别逻辑
|
||||
func TestIsWebServiceByFingerprint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serviceInfo *ServiceInfo
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil服务信息",
|
||||
serviceInfo: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "空服务名",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "HTTP服务",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "http",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "HTTPS服务",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "https",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Nginx服务",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "nginx",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Apache服务",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "apache",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "IIS服务",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "iis",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Tomcat服务",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "tomcat",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "MySQL服务-非Web",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "mysql",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Redis服务-非Web",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "redis",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "SSH服务-非Web",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "ssh",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "FTP服务-非Web",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "ftp",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "大小写混合-HTTP",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "HTTP/1.1",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "包含Web关键字-http-server",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "custom-http-server",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Banner包含Server头",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "unknown",
|
||||
Banner: "Server: Apache/2.4.41",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Banner包含HTTP协议",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "unknown",
|
||||
Banner: "HTTP/1.1 200 OK",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Banner包含Content-Type",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "unknown",
|
||||
Banner: "Content-Type: text/html",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Banner大写-SERVER",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "unknown",
|
||||
Banner: "SERVER: NGINX/1.18.0",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "非Web服务名+非Web Banner",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "telnet",
|
||||
Banner: "Telnet Server Ready",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "未知服务+无Banner",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "unknown",
|
||||
Banner: "",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "PHP服务",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "php",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "JSP服务",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "jsp",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "ASP服务",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "asp",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "SSL/TLS服务",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "ssl",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "包含非Web关键字-postgresql",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "postgresql-server",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsWebServiceByFingerprint(tt.serviceInfo)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsWebServiceByFingerprint() = %v, 期望 %v (Name=%q, Banner=%q)",
|
||||
result, tt.expected, tt.serviceInfo.Name, tt.serviceInfo.Banner)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// URL解析测试
|
||||
// =============================================================================
|
||||
|
||||
// TestCreateTargetFromURL 测试URL解析和HostInfo构建
|
||||
func TestCreateTargetFromURL(t *testing.T) {
|
||||
strategy := NewWebScanStrategy()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseInfo common.HostInfo
|
||||
urlStr string
|
||||
expectNil bool
|
||||
expectedHost string
|
||||
expectedPort int
|
||||
expectedURL string
|
||||
}{
|
||||
{
|
||||
name: "完整HTTP URL",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "http://example.com",
|
||||
expectNil: false,
|
||||
expectedHost: "example.com",
|
||||
expectedPort: 80,
|
||||
expectedURL: "http://example.com",
|
||||
},
|
||||
{
|
||||
name: "完整HTTPS URL",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "https://example.com",
|
||||
expectNil: false,
|
||||
expectedHost: "example.com",
|
||||
expectedPort: 443,
|
||||
expectedURL: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "HTTP+自定义端口",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "http://example.com:8080",
|
||||
expectNil: false,
|
||||
expectedHost: "example.com",
|
||||
expectedPort: 8080,
|
||||
expectedURL: "http://example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "HTTPS+自定义端口",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "https://example.com:8443",
|
||||
expectNil: false,
|
||||
expectedHost: "example.com",
|
||||
expectedPort: 8443,
|
||||
expectedURL: "https://example.com:8443",
|
||||
},
|
||||
{
|
||||
name: "无协议头-自动添加http",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "example.com",
|
||||
expectNil: false,
|
||||
expectedHost: "example.com",
|
||||
expectedPort: 80,
|
||||
expectedURL: "http://example.com",
|
||||
},
|
||||
{
|
||||
name: "无协议头+端口",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "example.com:8080",
|
||||
expectNil: false,
|
||||
expectedHost: "example.com",
|
||||
expectedPort: 8080,
|
||||
expectedURL: "http://example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "IP地址",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "http://192.168.1.1",
|
||||
expectNil: false,
|
||||
expectedHost: "192.168.1.1",
|
||||
expectedPort: 80,
|
||||
expectedURL: "http://192.168.1.1",
|
||||
},
|
||||
{
|
||||
name: "IP地址+端口",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "http://192.168.1.1:8080",
|
||||
expectNil: false,
|
||||
expectedHost: "192.168.1.1",
|
||||
expectedPort: 8080,
|
||||
expectedURL: "http://192.168.1.1:8080",
|
||||
},
|
||||
{
|
||||
name: "带路径的URL",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "http://example.com/path/to/page",
|
||||
expectNil: false,
|
||||
expectedHost: "example.com",
|
||||
expectedPort: 80,
|
||||
expectedURL: "http://example.com/path/to/page",
|
||||
},
|
||||
{
|
||||
name: "带查询参数的URL",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "http://example.com/?key=value",
|
||||
expectNil: false,
|
||||
expectedHost: "example.com",
|
||||
expectedPort: 80,
|
||||
expectedURL: "http://example.com/?key=value",
|
||||
},
|
||||
{
|
||||
name: "继承baseInfo属性",
|
||||
baseInfo: common.HostInfo{
|
||||
Info: []string{"info1", "info2"},
|
||||
},
|
||||
urlStr: "http://example.com",
|
||||
expectNil: false,
|
||||
expectedHost: "example.com",
|
||||
expectedPort: 80,
|
||||
expectedURL: "http://example.com",
|
||||
},
|
||||
{
|
||||
name: "非法URL-无效字符",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "http://example.com:abc",
|
||||
expectNil: true, // 端口非法,解析失败
|
||||
},
|
||||
{
|
||||
name: "localhost",
|
||||
baseInfo: common.HostInfo{},
|
||||
urlStr: "http://localhost:8080",
|
||||
expectNil: false,
|
||||
expectedHost: "localhost",
|
||||
expectedPort: 8080,
|
||||
expectedURL: "http://localhost:8080",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(tt.baseInfo, tt.urlStr)
|
||||
|
||||
// 验证是否为nil
|
||||
if tt.expectNil {
|
||||
if result != nil {
|
||||
t.Errorf("期望返回nil, 实际返回 %+v", result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("不应返回nil")
|
||||
}
|
||||
|
||||
// 验证Host
|
||||
if result.Host != tt.expectedHost {
|
||||
t.Errorf("Host = %q, 期望 %q", result.Host, tt.expectedHost)
|
||||
}
|
||||
|
||||
// 验证Ports
|
||||
if result.Port != tt.expectedPort {
|
||||
t.Errorf("Port = %d, 期望 %d", result.Port, tt.expectedPort)
|
||||
}
|
||||
|
||||
// 验证Url
|
||||
if result.URL != tt.expectedURL {
|
||||
t.Errorf("URL = %q, 期望 %q", result.URL, tt.expectedURL)
|
||||
}
|
||||
|
||||
// 验证baseInfo属性继承
|
||||
if len(tt.baseInfo.Info) > 0 {
|
||||
if len(result.Info) != len(tt.baseInfo.Info) {
|
||||
t.Errorf("Infostr未继承, 长度 = %d, 期望 %d",
|
||||
len(result.Info), len(tt.baseInfo.Info))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 缓存管理测试
|
||||
// =============================================================================
|
||||
|
||||
// TestWebServiceCache 测试Web服务缓存操作
|
||||
func TestWebServiceCache(t *testing.T) {
|
||||
// 清空缓存
|
||||
webCacheMutex.Lock()
|
||||
webServiceCache = make(map[string]*ServiceInfo)
|
||||
webCacheMutex.Unlock()
|
||||
|
||||
t.Run("存储和读取", func(t *testing.T) {
|
||||
serviceInfo := &ServiceInfo{
|
||||
Name: "http",
|
||||
Banner: "Apache/2.4.41",
|
||||
}
|
||||
|
||||
// 标记Web服务
|
||||
MarkAsWebService("192.168.1.1", 80, serviceInfo)
|
||||
|
||||
// 验证IsMarkedWebService
|
||||
if !IsMarkedWebService("192.168.1.1", 80) {
|
||||
t.Error("IsMarkedWebService应返回true")
|
||||
}
|
||||
|
||||
// 验证GetWebServiceInfo
|
||||
info, exists := GetWebServiceInfo("192.168.1.1", 80)
|
||||
if !exists {
|
||||
t.Error("GetWebServiceInfo应返回exists=true")
|
||||
}
|
||||
if info.Name != "http" {
|
||||
t.Errorf("Name = %q, 期望 'http'", info.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不存在的服务", func(t *testing.T) {
|
||||
if IsMarkedWebService("192.168.1.2", 80) {
|
||||
t.Error("不存在的服务应返回false")
|
||||
}
|
||||
|
||||
info, exists := GetWebServiceInfo("192.168.1.2", 80)
|
||||
if exists {
|
||||
t.Error("不存在的服务应返回exists=false")
|
||||
}
|
||||
if info != nil {
|
||||
t.Error("不存在的服务应返回nil info")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("覆盖写入", func(t *testing.T) {
|
||||
serviceInfo1 := &ServiceInfo{Name: "http"}
|
||||
serviceInfo2 := &ServiceInfo{Name: "https"}
|
||||
|
||||
MarkAsWebService("192.168.1.3", 80, serviceInfo1)
|
||||
MarkAsWebService("192.168.1.3", 80, serviceInfo2)
|
||||
|
||||
info, _ := GetWebServiceInfo("192.168.1.3", 80)
|
||||
if info.Name != "https" {
|
||||
t.Errorf("覆盖后Name = %q, 期望 'https'", info.Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不同端口独立存储", func(t *testing.T) {
|
||||
serviceInfo80 := &ServiceInfo{Name: "http"}
|
||||
serviceInfo443 := &ServiceInfo{Name: "https"}
|
||||
|
||||
MarkAsWebService("192.168.1.4", 80, serviceInfo80)
|
||||
MarkAsWebService("192.168.1.4", 443, serviceInfo443)
|
||||
|
||||
info80, _ := GetWebServiceInfo("192.168.1.4", 80)
|
||||
info443, _ := GetWebServiceInfo("192.168.1.4", 443)
|
||||
|
||||
if info80.Name != "http" {
|
||||
t.Errorf("端口80的Name = %q, 期望 'http'", info80.Name)
|
||||
}
|
||||
if info443.Name != "https" {
|
||||
t.Errorf("端口443的Name = %q, 期望 'https'", info443.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestWebServiceCache_Concurrent 测试并发安全性
|
||||
func TestWebServiceCache_Concurrent(t *testing.T) {
|
||||
// 清空缓存
|
||||
webCacheMutex.Lock()
|
||||
webServiceCache = make(map[string]*ServiceInfo)
|
||||
webCacheMutex.Unlock()
|
||||
|
||||
t.Run("不同key并发写入", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 100
|
||||
|
||||
// 并发写入不同端口
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
serviceInfo := &ServiceInfo{
|
||||
Name: "http",
|
||||
}
|
||||
MarkAsWebService("192.168.1.1", id, serviceInfo)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 验证数据完整性
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
if !IsMarkedWebService("192.168.1.1", i) {
|
||||
t.Errorf("端口 %d 应被标记", i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("同一key并发读写", func(t *testing.T) {
|
||||
// 这才是真正的race condition测试
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 100
|
||||
const testHost = "192.168.1.100"
|
||||
const testPort = 80
|
||||
|
||||
// 同时读写同一个key
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(2)
|
||||
|
||||
// 写goroutine
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
serviceInfo := &ServiceInfo{
|
||||
Name: "http",
|
||||
Banner: fmt.Sprintf("writer-%d", id),
|
||||
}
|
||||
MarkAsWebService(testHost, testPort, serviceInfo)
|
||||
}(i)
|
||||
|
||||
// 读goroutine
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
info, exists := GetWebServiceInfo(testHost, testPort)
|
||||
// 不验证具体内容(因为写入顺序不确定)
|
||||
// 只验证不会panic或返回不一致的exists/info
|
||||
if exists && info == nil {
|
||||
t.Error("exists=true但info=nil,数据不一致")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 验证最终状态一致
|
||||
info, exists := GetWebServiceInfo(testHost, testPort)
|
||||
if !exists {
|
||||
t.Error("应该至少有一次写入成功")
|
||||
}
|
||||
if info == nil {
|
||||
t.Error("exists=true但info=nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 指纹缓存测试
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// 边界情况测试
|
||||
// =============================================================================
|
||||
|
||||
// TestCreateTargetFromURL_EdgeCases 测试URL解析边界情况
|
||||
func TestCreateTargetFromURL_EdgeCases(t *testing.T) {
|
||||
strategy := NewWebScanStrategy()
|
||||
|
||||
t.Run("空URL", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "")
|
||||
// url.Parse("")会成功,但Hostname()返回空
|
||||
if result == nil {
|
||||
t.Skip("空URL解析行为依赖于url.Parse实现")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("只有协议", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "http://")
|
||||
// url.Parse("http://")会成功,但Host为空
|
||||
if result != nil && result.Host == "" {
|
||||
t.Log("Empty host check passed as expected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("特殊字符URL", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "http://例子.com")
|
||||
// 中文域名可能成功解析(IDN)
|
||||
if result == nil {
|
||||
t.Log("中文域名解析失败(预期行为)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IPv6地址", func(t *testing.T) {
|
||||
result := strategy.createTargetFromURL(common.HostInfo{}, "http://[::1]:8080")
|
||||
if result == nil {
|
||||
t.Error("IPv6地址应能正确解析")
|
||||
} else {
|
||||
if result.Host != "::1" {
|
||||
t.Errorf("IPv6 Host = %q, 期望 '::1'", result.Host)
|
||||
}
|
||||
if result.Port != 8080 {
|
||||
t.Errorf("IPv6 Ports = %q, 期望 '8080'", result.Port)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIsWebServiceByFingerprint_Priority 测试识别优先级
|
||||
func TestIsWebServiceByFingerprint_Priority(t *testing.T) {
|
||||
t.Run("非Web服务名优先级高于Web Banner", func(t *testing.T) {
|
||||
// 服务名是mysql,但Banner包含Web特征
|
||||
serviceInfo := &ServiceInfo{
|
||||
Name: "mysql",
|
||||
Banner: "Server: Apache",
|
||||
}
|
||||
result := IsWebServiceByFingerprint(serviceInfo)
|
||||
if result {
|
||||
t.Error("非Web服务名应优先,即使Banner包含Web特征")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Web服务名优先级高于非Web Banner", func(t *testing.T) {
|
||||
serviceInfo := &ServiceInfo{
|
||||
Name: "http",
|
||||
Banner: "MySQL Server Ready",
|
||||
}
|
||||
result := IsWebServiceByFingerprint(serviceInfo)
|
||||
if !result {
|
||||
t.Error("Web服务名应优先,即使Banner包含非Web特征")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 协议检测测试
|
||||
// =============================================================================
|
||||
|
||||
// TestDetectHTTPScheme 测试HTTP/HTTPS协议智能检测
|
||||
func TestDetectHTTPScheme(t *testing.T) {
|
||||
// 设置WebTimeout避免测试超时
|
||||
cfg := common.GetGlobalConfig()
|
||||
oldTimeout := cfg.Network.WebTimeout
|
||||
cfg.Network.WebTimeout = 2 * time.Second
|
||||
defer func() { cfg.Network.WebTimeout = oldTimeout }()
|
||||
|
||||
t.Run("HTTPS服务器检测", func(t *testing.T) {
|
||||
// 创建HTTPS测试服务器
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// 解析服务器地址
|
||||
host, portStr, err := net.SplitHostPort(server.Listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("解析服务器地址失败: %v", err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
// 测试检测
|
||||
result := DetectHTTPScheme(host, port, cfg)
|
||||
if result != "https" {
|
||||
t.Errorf("DetectHTTPScheme() = %q, 期望 'https'", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP服务器检测", func(t *testing.T) {
|
||||
// 创建HTTP测试服务器
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// 解析服务器地址
|
||||
host, portStr, err := net.SplitHostPort(server.Listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("解析服务器地址失败: %v", err)
|
||||
}
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
// 测试检测
|
||||
result := DetectHTTPScheme(host, port, cfg)
|
||||
if result != "http" {
|
||||
t.Errorf("DetectHTTPScheme() = %q, 期望 'http'", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不存在的服务", func(t *testing.T) {
|
||||
// 使用127.0.0.1的一个未使用端口
|
||||
result := DetectHTTPScheme("127.0.0.1", 65534, cfg)
|
||||
if result != "" {
|
||||
t.Errorf("不存在的服务应返回空字符串, 实际 %q", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("非Web服务端口", func(t *testing.T) {
|
||||
// 创建一个TCP监听器但不响应HTTP
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Skipf("无法创建监听器: %v", err)
|
||||
}
|
||||
defer func() { _ = listener.Close() }()
|
||||
|
||||
// 启动一个接受连接但立即关闭的goroutine
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// 解析端口
|
||||
_, portStr, _ := net.SplitHostPort(listener.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
// 测试检测
|
||||
result := DetectHTTPScheme("127.0.0.1", port, cfg)
|
||||
if result != "" {
|
||||
t.Logf("非Web服务检测返回: %q (预期空字符串,但立即关闭连接可能被误判)", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TLS版本兼容性", func(t *testing.T) {
|
||||
// 测试TLS 1.0兼容性(DetectHTTPScheme设置MinVersion为TLS 1.0)
|
||||
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
server.TLS = &tls.Config{
|
||||
MinVersion: tls.VersionTLS10,
|
||||
MaxVersion: tls.VersionTLS10,
|
||||
}
|
||||
server.StartTLS()
|
||||
defer server.Close()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(server.Listener.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
result := DetectHTTPScheme(host, port, cfg)
|
||||
if result != "https" {
|
||||
t.Errorf("TLS 1.0服务器应被检测为https, 实际 %q", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user