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)
This commit is contained in:
ZacharyZcR
2026-01-11 20:16:23 +08:00
parent 6b13b2e84f
commit 71b92d4408
948 changed files with 92335 additions and 24630 deletions
+146
View File
@@ -0,0 +1,146 @@
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) {
pool, err := ants.NewPoolWithFunc(size, fn, ants.WithPreAlloc(true))
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)
}
}
+237
View File
@@ -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)
}
+153
View File
@@ -0,0 +1,153 @@
package core
import (
"fmt"
"strings"
"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) {
// 验证扫描目标
if info.Host == "" {
common.LogError(i18n.GetText("parse_error_target_empty"))
return
}
// 输出存活探测开始信息
common.LogBase(i18n.GetText("scan_alive_start"))
// 执行存活探测
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
// 显示扫描信息
if len(hosts) == 1 {
common.LogBase(i18n.Tr("alive_scan_start_single", hosts[0]))
} else {
common.LogBase(i18n.Tr("alive_scan_start_multi", len(hosts), hosts[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() {
// 输出分隔线
common.LogBase("=" + strings.Repeat("=", 60))
// 输出扫描结果摘要
common.LogBase(i18n.GetText("scan_alive_summary_title"))
// 基础统计
common.LogBase(i18n.Tr("alive_total_hosts", s.stats.TotalHosts))
common.LogBase(i18n.Tr("alive_hosts_count", s.stats.AliveHosts))
common.LogBase(i18n.Tr("alive_dead_hosts", s.stats.DeadHosts))
common.LogBase(i18n.Tr("alive_success_rate", fmt.Sprintf("%.2f%%", s.stats.SuccessRate)))
common.LogBase(i18n.Tr("alive_scan_duration", s.stats.ScanDuration.Round(time.Millisecond)))
// 如果有存活主机,显示详细列表
if s.stats.AliveHosts > 0 {
common.LogBase("")
common.LogBase(i18n.GetText("scan_alive_hosts_list"))
for i, host := range s.stats.AliveHostList {
common.LogSuccess(i18n.Tr("alive_host_item", i+1, host))
}
}
// 输出分隔线
common.LogBase("=" + strings.Repeat("=", 60))
}
// 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
}
+120
View File
@@ -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)
}
})
}
}
+327
View File
@@ -0,0 +1,327 @@
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")
}
if len(allPlugins) > 0 {
pluginStr := formatPluginList(allPlugins)
if isCustomMode {
common.LogBase(i18n.Tr("plugins_custom_specified", prefix, pluginStr))
} else {
common.LogBase(i18n.Tr("plugins_info", prefix, pluginStr))
}
} else {
common.LogBase(i18n.Tr("plugins_none", 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))
}
// LogPluginInfoWithPort 带端口信息的插件显示
func (b *BaseScanStrategy) LogPluginInfoWithPort(targetHost string, targetPort int, 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")
}
// 过滤适用的插件
var applicablePlugins []string
for _, pluginName := range allPlugins {
if b.pluginExists(pluginName) {
if b.IsPluginApplicableByName(pluginName, targetHost, targetPort, isCustomMode, config) {
applicablePlugins = append(applicablePlugins, pluginName)
}
}
}
if len(applicablePlugins) > 0 {
pluginStr := formatPluginList(applicablePlugins)
if isCustomMode {
common.LogBase(i18n.Tr("plugins_custom_specified", prefix, pluginStr))
} else {
common.LogBase(i18n.Tr("plugins_info", prefix, pluginStr))
}
} else {
common.LogBase(i18n.Tr("plugins_none", prefix))
}
}
// ValidateConfiguration 验证扫描配置
func (b *BaseScanStrategy) ValidateConfiguration() error {
return nil
}
// LogScanStart 输出扫描开始信息(已精简,仅在非服务扫描模式下显示)
func (b *BaseScanStrategy) LogScanStart() {
// 服务扫描模式下不显示(插件信息已足够说明)
// 仅在本地/Web等特殊模式下显示
switch b.filterType {
case FilterLocal:
common.LogBase(i18n.GetText("start_local_scan"))
case FilterWeb:
common.LogBase(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
}
+354
View File
@@ -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)
}
}
+66
View File
@@ -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
}
+168
View File
@@ -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)
}
}
}
+546
View File
@@ -0,0 +1,546 @@
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"}
// CheckLive 检测主机存活状态
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)
// 输出存活统计信息
printAliveStats(aliveHosts, hostslist)
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.LogBase(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.LogBase(i18n.Tr("icmp_connect_failed", err))
common.LogBase(i18n.GetText("insufficient_privileges"))
common.LogBase(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]))
}
}
}
// 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)
}
// 等待响应
start := time.Now()
for {
// 加锁读取aliveHosts长度
aliveHostsMu.Lock()
aliveCount := len(*aliveHosts)
aliveHostsMu.Unlock()
// 所有主机都已响应则退出
if aliveCount == len(hostslist) {
break
}
// 根据主机数量设置超时时间
since := time.Since(start)
wait := time.Second * 6
if len(hostslist) <= 256 {
wait = time.Second * 3
}
if since > wait {
break
}
}
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()
}
// 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
}
// 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
}
+531
View File
@@ -0,0 +1,531 @@
package core
import (
"fmt"
"testing"
)
// 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("警告:不同主机可能产生相同标识符(取决于前两字符)")
}
})
}
// 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])
}
}
+76
View File
@@ -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.LogBase(i18n.Tr("local_plugin_info", localPlugin))
} else {
common.LogBase(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.LogBase(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}
}
+147
View File
@@ -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)
}
}
+518
View File
@@ -0,0 +1,518 @@
package core
import (
"fmt"
"math"
"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
}
// estimateScanTime 估算扫描时间
// 参数: totalTasks - 总任务数, threads - 线程数, timeout - 超时时间(秒)
// 返回: 估算的扫描时间(秒)
func estimateScanTime(totalTasks int, threads int, timeout int64) int64 {
if totalTasks == 0 || threads == 0 {
return 0
}
// 假设约50%的端口会快速返回关闭状态(平均耗时 timeout/4)
// 约50%的端口需要完整超时(耗时 timeout)
// 因此平均每个任务耗时 = timeout * 0.5 * (0.25 + 1.0) = timeout * 0.625
avgTaskTime := float64(timeout) * 0.625
// 计算需要多少批次(向上取整)
parallelBatches := math.Ceil(float64(totalTasks) / float64(threads))
// 总时间 = 批次数 × 平均任务时间
estimatedSeconds := int64(parallelBatches * avgTaskTime)
return estimatedSeconds
}
// EnhancedPortScan 高性能端口扫描函数
// 使用滑动窗口调度 + 自适应线程池 + 流式迭代器
func EnhancedPortScan(hosts []string, ports string, timeout int64, config *common.Config, state *common.State) []string {
// 解析端口和排除端口
portList := parsers.ParsePort(ports)
if len(portList) == 0 {
common.LogError(i18n.Tr("invalid_port", ports))
return nil
}
// 使用config中的排除端口配置
excludePorts := parsers.ParsePort(config.Target.ExcludePorts)
exclude := make(map[int]struct{}, len(excludePorts))
for _, p := range excludePorts {
exclude[p] = struct{}{}
}
// 创建流式迭代器(O(1) 内存,端口喷洒策略)
iter := NewSocketIterator(hosts, portList, exclude)
totalTasks := iter.Total()
// 使用传入的配置
threadNum := config.ThreadNum
// 估算并显示扫描时间
if totalTasks > 0 {
estimatedTime := estimateScanTime(totalTasks, threadNum, timeout)
common.LogBase(i18n.Tr("port_scan_start", totalTasks, estimatedTime, estimatedTime/60))
}
// 初始化端口扫描进度条
if totalTasks > 0 && config.Output.ShowProgress {
description := fmt.Sprintf("端口扫描中(%d线程)", threadNum)
common.InitProgressBar(int64(totalTasks), description)
}
// 初始化并发控制
to := time.Duration(timeout) * time.Second
var count int64
collector := newResultCollector()
failedCollector := &failedPortCollector{}
var wg sync.WaitGroup
// 创建自适应线程池(支持动态调整)
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
}
defer pool.Release()
// 滑动窗口调度:维护固定数量的"飞行中"任务
slidingWindowSchedule(iter, pool, &wg, threadNum)
// 收集结果
aliveAddrs := collector.GetAll()
// 完成端口扫描进度条
if common.IsProgressActive() {
common.FinishProgressBar()
}
common.LogBase(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 banner (简洁单行,方便复制)
func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) string {
var parts []string
parts = append(parts, addr)
if serviceInfo.Name != "unknown" {
parts = append(parts, serviceInfo.Name)
}
// Banner 优先,其次是版本信息
if len(serviceInfo.Banner) > 0 && len(serviceInfo.Banner) < 100 {
parts = append(parts, strings.TrimSpace(serviceInfo.Banner))
} else if serviceInfo.Version != "" {
parts = append(parts, serviceInfo.Version)
}
return strings.Join(parts, " ")
}
// 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:代理连接验证(防止非标准SOCKS5代理的"全回显"问题)
if !verifyProxyConnection(conn, addr) {
_ = conn.Close()
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")
}
// verifyProxyConnection 验证代理连接是否真正可用
// 防止非标准SOCKS5代理的"全回显"问题:代理连接成功但目标实际不可达
// 返回 true 表示连接有效,false 表示连接无效(目标不可达)
func verifyProxyConnection(conn net.Conn, addr string) bool {
// 如果没有使用代理,跳过验证
if !common.IsProxyEnabled() {
return true
}
// 设置短超时进行连接验证(100ms)
// 如果目标端口真的开放,不会在这么短时间内收到错误
// 如果目标不可达,非标准代理可能会立即返回错误
_ = conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
// 尝试读取(非阻塞检查)
buf := make([]byte, 1)
_, err := conn.Read(buf)
// 重置超时设置
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
errLower := strings.ToLower(err.Error())
for _, pattern := range proxyFailurePatterns {
if strings.Contains(errLower, pattern) {
common.LogDebug(fmt.Sprintf("代理连接验证失败 %s: %v", addr, err))
return false
}
}
// 超时错误是正常的(目标没有主动发送数据)
// EOF 也可能是正常的(某些服务的行为)
}
return true
}
// 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
}
+92
View File
@@ -0,0 +1,92 @@
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")
}
}
// BenchmarkEstimateScanTime 测试扫描时间估算性能
func BenchmarkEstimateScanTime(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = estimateScanTime(10000, 600, 3)
}
}
+723
View File
@@ -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) {
// 场景1ulimit设置太低
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限制错误")
}
})
// 场景2Windows端口耗尽
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("认证失败不应被识别为资源耗尽")
}
})
}
+104
View File
@@ -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
}
+361
View File
@@ -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)
}
}
+71
View File
@@ -0,0 +1,71 @@
package portfinger
import (
"fmt"
"regexp"
"strings"
)
// 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:], "")
// 解码并编译正则表达式
patternUnescaped, decodeErr := DecodePattern(pattern)
if decodeErr != nil {
return match, decodeErr
}
patternCompiled, compileErr := regexp.Compile(string(patternUnescaped))
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
}
matched := m.PatternCompiled.Match(response)
if matched {
// 提取匹配到的子组
submatches := m.PatternCompiled.FindStringSubmatch(string(response))
if len(submatches) > 1 {
m.FoundItems = submatches[1:] // 排除完整匹配,只保留分组
}
}
return matched
}
+372
View File
@@ -0,0 +1,372 @@
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 测试Unicode响应
func TestMatchPattern_UnicodeResponse(t *testing.T) {
compiled, _ := regexp.Compile(`服务器`)
m := &Match{
PatternCompiled: compiled,
}
response := []byte("HTTP/1.1 200 OK\r\nServer: 服务器\r\n")
result := m.MatchPattern(response)
if !result {
t.Error("Unicode内容应被匹配")
}
}
File diff suppressed because it is too large Load Diff
+246
View File
@@ -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)
}
}
}
+151
View File
@@ -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
}
+341
View File
@@ -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] 应该是 NULLTCP 探测器)
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)
}
}
}
}
+123
View File
@@ -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()
}
+73
View File
@@ -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
}
+129
View File
@@ -0,0 +1,129 @@
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{}
// 确保有匹配项
if len(m.FoundItems) == 0 {
return extras
}
// 替换版本信息中的占位符(单次扫描)
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)
}
+531
View File
@@ -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变空格被trim33保留
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)
}
}
})
}
+350
View File
@@ -0,0 +1,350 @@
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.LogBase(i18n.GetText("active_reverse_shell"))
}
if state.IsSocks5ProxyActive() {
common.LogBase(i18n.GetText("active_socks5_proxy"))
}
if state.IsForwardShellActive() {
common.LogBase(i18n.GetText("active_forward_shell"))
}
common.LogBase(i18n.GetText("press_ctrl_c_exit"))
// 优雅等待信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
<-sigChan
common.LogBase(i18n.GetText("received_exit_signal"))
}
// 完成扫描
finishScan(config, state)
}
// finishScan 完成扫描并输出结果
func finishScan(config *common.Config, state *common.State) {
// 确保进度条正确完成
if common.IsProgressActive() {
common.FinishProgressBar()
}
// 输出扫描完成信息
common.LogBase(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.Error != nil {
common.LogError(i18n.Tr("plugin_scan_error", target.Host, target.Port, result.Error))
} else if result.Success {
// 保存成功的扫描结果到文件
savePluginResult(&target, pluginName, result)
}
}
}
}()
}
// 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
}
}
+440
View File
@@ -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)
}
}
+578
View File
@@ -0,0 +1,578 @@
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 {
continue
}
// 尝试匹配(GetInfo 会自动遍历 fallback 数组,包含 NULL 回退)
s.info.GetInfo(response, probe)
if s.info.Found {
return true
}
}
return false
}
// 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
}
}
}
+246
View File
@@ -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))
}
// =============================================================================
// 测试2SSL 端口探测
// =============================================================================
// 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
}
}
}
// =============================================================================
// 测试3Intensity 过滤
// =============================================================================
// 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)
}
}
+643
View File
@@ -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)
}
}
+345
View File
@@ -0,0 +1,345 @@
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.LogBase(i18n.Tr("service_plugin_custom", pluginStr))
} else {
common.LogBase(i18n.Tr("service_plugin_info", pluginStr))
}
} else {
common.LogBase(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) {
// 验证扫描目标
if info.Host == "" {
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.LogBase(i18n.Tr("service_plugin_info", strings.Join(servicePlugins, ", ")))
} else {
common.LogBase(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.LogBase(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.LogBase(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)
common.LogBase(i18n.Tr("alive_ports_count", len(alivePorts)))
}
// UDP端口特殊处理(当前仅支持SNMP的161端口)
udpPorts := s.handleUDPPorts(hosts)
if len(udpPorts) > 0 {
alivePorts = append(alivePorts, udpPorts...)
common.LogBase(i18n.Tr("alive_ports_count", len(alivePorts)))
}
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
}
// handleUDPPorts 处理UDP端口的特殊逻辑
func (s *ServiceScanStrategy) handleUDPPorts(hosts []string) []string {
var udpPorts []string
// 检查是否包含SNMP端口161
portList := parsers.ParsePort(common.GetFlagVars().Ports)
hasPort161 := false
for _, port := range portList {
if port == 161 {
hasPort161 = true
break
}
}
// 如果端口列表包含161,则为每个主机添加UDP 161端口
if hasPort161 {
for _, host := range hosts {
udpPorts = append(udpPorts, fmt.Sprintf("%s:161", host))
}
if len(udpPorts) > 0 {
common.LogBase(i18n.GetText("scan_snmp_udp_ports_added"))
}
}
return udpPorts
}
+827
View File
@@ -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("80443")
// 全角逗号不会被分割,整个字符串作为一个部分
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))
}
})
}
+71
View File
@@ -0,0 +1,71 @@
package core
import (
"sync"
)
// 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)
return &SocketIterator{
hosts: hosts,
ports: validPorts,
total: len(hosts) * len(validPorts),
}
}
// 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
}
+199
View File
@@ -0,0 +1,199 @@
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
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))
}
if gotPorts[0] != 22 || gotPorts[1] != 443 {
t.Errorf("期望 [22, 443], 实际 %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_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("应该只有一个元素")
}
})
}
+428
View File
@@ -0,0 +1,428 @@
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
}
// GetFingerprints 获取目标的指纹信息
func GetFingerprints(host string, port int) ([]string, bool) {
cacheKey := fmt.Sprintf("%s:%d", host, port)
fingerprintCacheMutex.RLock()
defer fingerprintCacheMutex.RUnlock()
fingerprints, exists := fingerprintCache[cacheKey]
return fingerprints, exists
}
// ===============================
// 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
}
}
}
return &urlInfo
}
+881
View File
@@ -0,0 +1,881 @@
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")
}
})
}
// =============================================================================
// 指纹缓存测试
// =============================================================================
// TestFingerprintCache 测试指纹缓存操作
func TestFingerprintCache(t *testing.T) {
// 清空缓存
fingerprintCacheMutex.Lock()
fingerprintCache = make(map[string][]string)
fingerprintCacheMutex.Unlock()
t.Run("存储和读取指纹", func(t *testing.T) {
fingerprints := []string{"nginx", "http", "ssl"}
SetFingerprints("192.168.1.1", 80, fingerprints)
result, exists := GetFingerprints("192.168.1.1", 80)
if !exists {
t.Error("GetFingerprints应返回exists=true")
}
if len(result) != 3 {
t.Errorf("指纹数量 = %d, 期望 3", len(result))
}
for i, fp := range fingerprints {
if result[i] != fp {
t.Errorf("指纹[%d] = %q, 期望 %q", i, result[i], fp)
}
}
})
t.Run("空指纹列表不存储", func(t *testing.T) {
SetFingerprints("192.168.1.2", 80, []string{})
_, exists := GetFingerprints("192.168.1.2", 80)
if exists {
t.Error("空指纹列表不应被存储")
}
})
t.Run("不存在的指纹", func(t *testing.T) {
result, exists := GetFingerprints("192.168.1.3", 80)
if exists {
t.Error("不存在的指纹应返回exists=false")
}
if result != nil {
t.Errorf("不存在的指纹应返回nil, 实际 %v", result)
}
})
t.Run("覆盖写入指纹", func(t *testing.T) {
fingerprints1 := []string{"nginx"}
fingerprints2 := []string{"apache", "php"}
SetFingerprints("192.168.1.4", 80, fingerprints1)
SetFingerprints("192.168.1.4", 80, fingerprints2)
result, _ := GetFingerprints("192.168.1.4", 80)
if len(result) != 2 {
t.Errorf("覆盖后指纹数量 = %d, 期望 2", len(result))
}
})
t.Run("不同端口独立存储指纹", func(t *testing.T) {
fp80 := []string{"http"}
fp443 := []string{"https"}
SetFingerprints("192.168.1.5", 80, fp80)
SetFingerprints("192.168.1.5", 443, fp443)
result80, _ := GetFingerprints("192.168.1.5", 80)
result443, _ := GetFingerprints("192.168.1.5", 443)
if result80[0] != "http" {
t.Errorf("端口80指纹 = %v, 期望 ['http']", result80)
}
if result443[0] != "https" {
t.Errorf("端口443指纹 = %v, 期望 ['https']", result443)
}
})
}
// TestFingerprintCache_Concurrent 测试指纹缓存并发安全性
func TestFingerprintCache_Concurrent(t *testing.T) {
// 清空缓存
fingerprintCacheMutex.Lock()
fingerprintCache = make(map[string][]string)
fingerprintCacheMutex.Unlock()
var wg sync.WaitGroup
numGoroutines := 100
// 并发写入
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fingerprints := []string{"test"}
SetFingerprints("192.168.1.1", id, fingerprints)
}(i)
}
// 并发读取
for i := 0; i < numGoroutines; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
_, _ = GetFingerprints("192.168.1.1", id)
}(i)
}
wg.Wait()
// 验证数据完整性
for i := 0; i < numGoroutines; i++ {
_, exists := GetFingerprints("192.168.1.1", i)
if !exists {
t.Errorf("端口 %d 指纹应存在", i)
}
}
}
// =============================================================================
// 边界情况测试
// =============================================================================
// 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)
}
})
}