mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 03:10:42 +08:00
将 webServiceCache 扩展为通用 serviceCache,所有指纹识别结果 统一缓存,插件匹配时端口不命中则回退到服务名称匹配。 删除多余的 service_cache.go,复用已有的 ServiceInfo 体系。 补充 nil 防御、Explicit 标记、大量单元/集成/回归测试。
355 lines
12 KiB
Go
355 lines
12 KiB
Go
package core
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/shadow1ng/fscan/plugins"
|
|
)
|
|
|
|
// registerTestPlugins 注册测试用插件(名字和服务识别结果一致)
|
|
func registerTestPlugins(t *testing.T) {
|
|
t.Helper()
|
|
plugins.RegisterWithOptions("ssh", func() plugins.Plugin { return nil }, []int{22, 2222}, nil, true)
|
|
plugins.RegisterWithOptions("mysql", func() plugins.Plugin { return nil }, []int{3306}, nil, true)
|
|
plugins.RegisterWithOptions("ftp", func() plugins.Plugin { return nil }, []int{21}, nil, true)
|
|
plugins.RegisterWithOptions("redis", func() plugins.Plugin { return nil }, []int{6379}, nil, true)
|
|
plugins.RegisterWithOptions("postgresql", func() plugins.Plugin { return nil }, []int{5432}, nil, true)
|
|
plugins.RegisterWithOptions("telnet", func() plugins.Plugin { return nil }, []int{23}, nil, true)
|
|
plugins.RegisterWithOptions("mssql", func() plugins.Plugin { return nil }, []int{1433}, nil, true)
|
|
plugins.RegisterWithOptions("vnc", func() plugins.Plugin { return nil }, []int{5900}, nil, true)
|
|
plugins.RegisterWithOptions("webtitle", func() plugins.Plugin { return nil }, []int{}, []string{plugins.PluginTypeWeb}, true)
|
|
}
|
|
|
|
func clearServiceCache() {
|
|
serviceCacheMutex.Lock()
|
|
serviceCache = make(map[string]*ServiceInfo)
|
|
serviceCacheMutex.Unlock()
|
|
}
|
|
|
|
// =============================================================================
|
|
// 单元测试:CacheServiceInfo / GetCachedServiceInfo
|
|
// =============================================================================
|
|
|
|
func TestCacheServiceInfo_BasicCRUD(t *testing.T) {
|
|
clearServiceCache()
|
|
|
|
t.Run("缓存后可读取", func(t *testing.T) {
|
|
CacheServiceInfo("10.0.0.1", 22, &ServiceInfo{Name: "ssh", Version: "OpenSSH_8.9"})
|
|
info, ok := GetCachedServiceInfo("10.0.0.1", 22)
|
|
if !ok {
|
|
t.Fatal("缓存未命中")
|
|
}
|
|
if info.Name != "ssh" || info.Version != "OpenSSH_8.9" {
|
|
t.Errorf("got Name=%q Version=%q", info.Name, info.Version)
|
|
}
|
|
})
|
|
|
|
t.Run("不同端口独立", func(t *testing.T) {
|
|
CacheServiceInfo("10.0.0.1", 3306, &ServiceInfo{Name: "mysql"})
|
|
CacheServiceInfo("10.0.0.1", 5432, &ServiceInfo{Name: "postgresql"})
|
|
i1, _ := GetCachedServiceInfo("10.0.0.1", 3306)
|
|
i2, _ := GetCachedServiceInfo("10.0.0.1", 5432)
|
|
if i1.Name != "mysql" || i2.Name != "postgresql" {
|
|
t.Errorf("端口混淆: 3306=%q 5432=%q", i1.Name, i2.Name)
|
|
}
|
|
})
|
|
|
|
t.Run("不同主机独立", func(t *testing.T) {
|
|
CacheServiceInfo("10.0.0.1", 22, &ServiceInfo{Name: "ssh"})
|
|
CacheServiceInfo("10.0.0.2", 22, &ServiceInfo{Name: "telnet"})
|
|
i1, _ := GetCachedServiceInfo("10.0.0.1", 22)
|
|
i2, _ := GetCachedServiceInfo("10.0.0.2", 22)
|
|
if i1.Name != "ssh" || i2.Name != "telnet" {
|
|
t.Errorf("主机混淆: .1=%q .2=%q", i1.Name, i2.Name)
|
|
}
|
|
})
|
|
|
|
t.Run("覆盖写入", func(t *testing.T) {
|
|
CacheServiceInfo("10.0.0.5", 80, &ServiceInfo{Name: "unknown"})
|
|
CacheServiceInfo("10.0.0.5", 80, &ServiceInfo{Name: "http"})
|
|
info, _ := GetCachedServiceInfo("10.0.0.5", 80)
|
|
if info.Name != "http" {
|
|
t.Errorf("覆盖失败: %q", info.Name)
|
|
}
|
|
})
|
|
|
|
t.Run("未缓存返回 false", func(t *testing.T) {
|
|
if _, ok := GetCachedServiceInfo("192.168.99.99", 12345); ok {
|
|
t.Error("应返回 false")
|
|
}
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// 单元测试:Web 服务过滤
|
|
// =============================================================================
|
|
|
|
func TestWebServiceFiltering(t *testing.T) {
|
|
clearServiceCache()
|
|
|
|
webNames := []string{"http", "https", "ssl", "tls", "nginx", "apache", "iis", "tomcat"}
|
|
nonWebNames := []string{"ssh", "mysql", "postgresql", "redis", "mongodb", "ftp", "smtp", "telnet", "vnc", "rdp"}
|
|
|
|
for _, name := range webNames {
|
|
clearServiceCache()
|
|
CacheServiceInfo("10.0.0.1", 443, &ServiceInfo{Name: name})
|
|
if !IsMarkedWebService("10.0.0.1", 443) {
|
|
t.Errorf("%q 应被识别为 Web 服务", name)
|
|
}
|
|
}
|
|
|
|
for _, name := range nonWebNames {
|
|
clearServiceCache()
|
|
CacheServiceInfo("10.0.0.1", 9999, &ServiceInfo{Name: name})
|
|
if IsMarkedWebService("10.0.0.1", 9999) {
|
|
t.Errorf("%q 不应被识别为 Web 服务", name)
|
|
}
|
|
}
|
|
|
|
t.Run("GetWebServiceInfo 过滤非 Web", func(t *testing.T) {
|
|
clearServiceCache()
|
|
CacheServiceInfo("10.0.0.1", 3306, &ServiceInfo{Name: "mysql"})
|
|
if _, ok := GetWebServiceInfo("10.0.0.1", 3306); ok {
|
|
t.Error("mysql 不应通过 GetWebServiceInfo")
|
|
}
|
|
})
|
|
|
|
t.Run("GetWebServiceInfo 返回 Web", func(t *testing.T) {
|
|
clearServiceCache()
|
|
CacheServiceInfo("10.0.0.1", 8080, &ServiceInfo{Name: "nginx"})
|
|
info, ok := GetWebServiceInfo("10.0.0.1", 8080)
|
|
if !ok || info.Name != "nginx" {
|
|
t.Error("nginx 应通过 GetWebServiceInfo")
|
|
}
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// 集成测试:指纹驱动插件匹配
|
|
// =============================================================================
|
|
|
|
func TestIntegration_FingerprintDrivenPluginMatch(t *testing.T) {
|
|
clearServiceCache()
|
|
registerTestPlugins(t)
|
|
|
|
CacheServiceInfo("10.0.0.1", 22, &ServiceInfo{Name: "ssh"})
|
|
CacheServiceInfo("10.0.0.1", 8881, &ServiceInfo{Name: "ssh"})
|
|
CacheServiceInfo("10.0.0.1", 13306, &ServiceInfo{Name: "mysql"})
|
|
CacheServiceInfo("10.0.0.1", 80, &ServiceInfo{Name: "http"})
|
|
CacheServiceInfo("10.0.0.1", 9443, &ServiceInfo{Name: "https"})
|
|
CacheServiceInfo("10.0.0.1", 2121, &ServiceInfo{Name: "ftp"})
|
|
CacheServiceInfo("10.0.0.1", 6380, &ServiceInfo{Name: "redis"})
|
|
|
|
strategy := NewServiceScanStrategy()
|
|
|
|
tests := []struct {
|
|
plugin, host string
|
|
port int
|
|
want bool
|
|
desc string
|
|
}{
|
|
{"ssh", "10.0.0.1", 22, true, "SSH 标准端口"},
|
|
{"ssh", "10.0.0.1", 8881, true, "SSH 非标准端口(指纹匹配)"},
|
|
{"mysql", "10.0.0.1", 13306, true, "MySQL 非标准端口"},
|
|
{"ftp", "10.0.0.1", 2121, true, "FTP 非标准端口"},
|
|
{"redis", "10.0.0.1", 6380, true, "Redis 非标准端口"},
|
|
{"ssh", "10.0.0.1", 13306, false, "SSH 不匹配 MySQL 端口"},
|
|
{"mysql", "10.0.0.1", 8881, false, "MySQL 不匹配 SSH 端口"},
|
|
{"redis", "10.0.0.1", 22, false, "Redis 不匹配 SSH 标准端口"},
|
|
{"ssh", "10.0.0.1", 65000, false, "SSH 不匹配未识别端口"},
|
|
{"webtitle", "10.0.0.1", 80, true, "Web 匹配 http"},
|
|
{"webtitle", "10.0.0.1", 9443, true, "Web 匹配 https 非标准"},
|
|
{"webtitle", "10.0.0.1", 22, false, "Web 不匹配 SSH"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.desc, func(t *testing.T) {
|
|
got := strategy.isPluginApplicableToPortWithHost(tt.plugin, tt.host, tt.port)
|
|
if got != tt.want {
|
|
t.Errorf("plugin=%q port=%d: got %v, want %v", tt.plugin, tt.port, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 集成测试:非标准端口完整流程
|
|
// =============================================================================
|
|
|
|
func TestIntegration_NonStandardPortScanFlow(t *testing.T) {
|
|
clearServiceCache()
|
|
registerTestPlugins(t)
|
|
|
|
host := "172.16.0.100"
|
|
CacheServiceInfo(host, 8881, &ServiceInfo{
|
|
Name: "ssh", Version: "OpenSSH_8.2p1",
|
|
Banner: "SSH-2.0-OpenSSH_8.2p1", Extras: map[string]string{"os": "Linux"},
|
|
})
|
|
|
|
strategy := NewServiceScanStrategy()
|
|
|
|
if !strategy.isPluginApplicableToPortWithHost("ssh", host, 8881) {
|
|
t.Error("SSH 应匹配 8881")
|
|
}
|
|
if strategy.isPluginApplicableToPortWithHost("mysql", host, 8881) {
|
|
t.Error("MySQL 不应匹配 8881 上的 SSH")
|
|
}
|
|
if IsMarkedWebService(host, 8881) {
|
|
t.Error("SSH 不应标记为 Web")
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 集成测试:同一主机多服务
|
|
// =============================================================================
|
|
|
|
func TestIntegration_MultiServiceSameHost(t *testing.T) {
|
|
clearServiceCache()
|
|
registerTestPlugins(t)
|
|
|
|
host := "192.168.1.100"
|
|
CacheServiceInfo(host, 2222, &ServiceInfo{Name: "ssh"})
|
|
CacheServiceInfo(host, 33060, &ServiceInfo{Name: "mysql"})
|
|
CacheServiceInfo(host, 8080, &ServiceInfo{Name: "http"})
|
|
CacheServiceInfo(host, 63790, &ServiceInfo{Name: "redis"})
|
|
|
|
strategy := NewServiceScanStrategy()
|
|
|
|
checks := []struct {
|
|
plugin string
|
|
port int
|
|
want bool
|
|
}{
|
|
{"ssh", 2222, true}, {"ssh", 33060, false}, {"ssh", 8080, false},
|
|
{"mysql", 33060, true}, {"mysql", 2222, false},
|
|
{"redis", 63790, true}, {"redis", 2222, false},
|
|
{"webtitle", 8080, true}, {"webtitle", 2222, false},
|
|
}
|
|
|
|
for _, c := range checks {
|
|
got := strategy.isPluginApplicableToPortWithHost(c.plugin, host, c.port)
|
|
if got != c.want {
|
|
t.Errorf("plugin=%q port=%d: got %v, want %v", c.plugin, c.port, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 边界测试
|
|
// =============================================================================
|
|
|
|
func TestServiceCache_EdgeCases(t *testing.T) {
|
|
clearServiceCache()
|
|
registerTestPlugins(t)
|
|
strategy := NewServiceScanStrategy()
|
|
|
|
t.Run("空服务名不匹配", func(t *testing.T) {
|
|
CacheServiceInfo("10.0.0.1", 9999, &ServiceInfo{Name: ""})
|
|
if strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.1", 9999) {
|
|
t.Error("空服务名不应匹配")
|
|
}
|
|
})
|
|
|
|
t.Run("unknown 不匹配", func(t *testing.T) {
|
|
CacheServiceInfo("10.0.0.1", 8888, &ServiceInfo{Name: "unknown"})
|
|
if strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.1", 8888) {
|
|
t.Error("unknown 不应匹配")
|
|
}
|
|
})
|
|
|
|
t.Run("大小写不敏感", func(t *testing.T) {
|
|
clearServiceCache()
|
|
CacheServiceInfo("10.0.0.1", 5555, &ServiceInfo{Name: "SSH"})
|
|
if !strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.1", 5555) {
|
|
t.Error("SSH 大写应匹配 ssh 插件")
|
|
}
|
|
})
|
|
|
|
t.Run("host 为空不查缓存", func(t *testing.T) {
|
|
CacheServiceInfo("10.0.0.1", 8881, &ServiceInfo{Name: "ssh"})
|
|
if strategy.isPluginApplicableToPortWithHost("ssh", "", 8881) {
|
|
t.Error("host 为空不应匹配")
|
|
}
|
|
})
|
|
|
|
t.Run("nil ServiceInfo 不 panic", func(t *testing.T) {
|
|
CacheServiceInfo("10.0.0.1", 7777, nil)
|
|
got := strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.1", 7777)
|
|
if got {
|
|
t.Error("nil ServiceInfo 不应匹配")
|
|
}
|
|
})
|
|
|
|
t.Run("IPv6", func(t *testing.T) {
|
|
clearServiceCache()
|
|
CacheServiceInfo("::1", 22, &ServiceInfo{Name: "ssh"})
|
|
if _, ok := GetCachedServiceInfo("::1", 22); !ok {
|
|
t.Error("IPv6 缓存失败")
|
|
}
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// 并发安全
|
|
// =============================================================================
|
|
|
|
func TestServiceCache_ConcurrentSafety(t *testing.T) {
|
|
clearServiceCache()
|
|
var wg sync.WaitGroup
|
|
|
|
for i := 0; i < 100; i++ {
|
|
wg.Add(3)
|
|
go func(p int) { defer wg.Done(); CacheServiceInfo("10.0.0.1", p, &ServiceInfo{Name: "ssh"}) }(i)
|
|
go func(p int) { defer wg.Done(); GetCachedServiceInfo("10.0.0.1", p) }(i)
|
|
go func(p int) { defer wg.Done(); IsMarkedWebService("10.0.0.1", p) }(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
for i := 0; i < 100; i++ {
|
|
if _, ok := GetCachedServiceInfo("10.0.0.1", i); !ok {
|
|
t.Errorf("并发写入丢失: port=%d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 回归测试:#588
|
|
// =============================================================================
|
|
|
|
func TestRegression_Issue588(t *testing.T) {
|
|
clearServiceCache()
|
|
registerTestPlugins(t)
|
|
|
|
CacheServiceInfo("192.168.1.50", 8881, &ServiceInfo{Name: "ssh", Version: "OpenSSH_7.4"})
|
|
strategy := NewServiceScanStrategy()
|
|
|
|
if !strategy.isPluginApplicableToPortWithHost("ssh", "192.168.1.50", 8881) {
|
|
t.Fatal("#588: SSH 应匹配 8881")
|
|
}
|
|
for _, p := range []string{"mysql", "ftp", "redis", "postgresql", "telnet", "vnc", "mssql"} {
|
|
if strategy.isPluginApplicableToPortWithHost(p, "192.168.1.50", 8881) {
|
|
t.Errorf("#588: %q 不应匹配 8881 上的 SSH", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 端口匹配优先于缓存
|
|
// =============================================================================
|
|
|
|
func TestIntegration_PortMatchPrecedence(t *testing.T) {
|
|
clearServiceCache()
|
|
registerTestPlugins(t)
|
|
|
|
CacheServiceInfo("10.0.0.1", 22, &ServiceInfo{Name: "http"})
|
|
strategy := NewServiceScanStrategy()
|
|
|
|
if !strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.1", 22) {
|
|
t.Error("SSH 应通过端口匹配命中 22(即使缓存是 http)")
|
|
}
|
|
if !IsMarkedWebService("10.0.0.1", 22) {
|
|
t.Error("缓存是 http,应标记为 Web")
|
|
}
|
|
}
|