mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-02-09 02:09:17 +08:00
## 架构重构
- 全局变量消除,迁移至 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)
355 lines
8.5 KiB
Go
355 lines
8.5 KiB
Go
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)
|
||
}
|
||
}
|