mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-24 04:01:52 +08:00
feat: 统一服务缓存 + 指纹驱动插件匹配
将 webServiceCache 扩展为通用 serviceCache,所有指纹识别结果 统一缓存,插件匹配时端口不命中则回退到服务名称匹配。 删除多余的 service_cache.go,复用已有的 ServiceInfo 体系。 补充 nil 防御、Explicit 标记、大量单元/集成/回归测试。
This commit is contained in:
@@ -137,11 +137,11 @@ func (b *BaseScanStrategy) isPluginApplicableToPortWithHost(pluginName string, t
|
||||
}
|
||||
}
|
||||
|
||||
// 端口不匹配时,按服务识别结果匹配
|
||||
// 端口不匹配时,按指纹识别结果匹配
|
||||
// 例:8881 端口上识别到 ssh 服务 → ssh 插件应该执行
|
||||
if targetHost != "" && targetPort > 0 {
|
||||
if svcName, ok := GetServiceName(targetHost, targetPort); ok {
|
||||
if strings.EqualFold(svcName, pluginName) {
|
||||
if info, ok := GetCachedServiceInfo(targetHost, targetPort); ok && info != nil {
|
||||
if strings.EqualFold(info.Name, pluginName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -97,6 +97,10 @@ func (p *NetworkProfile) RecommendConcurrency(userThreadNum int, explicit bool)
|
||||
// probePorts 探测用的端口列表(高响应率的常见端口)
|
||||
var probePorts = []int{80, 443, 22}
|
||||
|
||||
func networkProbeAddress(host string, port int) string {
|
||||
return net.JoinHostPort(host, strconv.Itoa(port))
|
||||
}
|
||||
|
||||
// ProbeNetwork 探测目标网络环境
|
||||
// 从 hosts 中抽样,用低并发 TCP 连接测量 RTT 和丢包率
|
||||
// 整个过程控制在数秒内完成
|
||||
@@ -140,7 +144,7 @@ func ProbeNetwork(ctx context.Context, hosts []string, session *common.ScanSessi
|
||||
go func(h string, p int) {
|
||||
defer func() { <-sem; wg.Done() }()
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", h, p)
|
||||
addr := networkProbeAddress(h, p)
|
||||
start := time.Now()
|
||||
conn, err := session.DialTCP(ctx, "tcp", addr, probeTimeout)
|
||||
rtt := time.Since(start)
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestClassifyNetwork(t *testing.T) {
|
||||
|
||||
t.Run("公网 RTT 分布(低丢包)", func(t *testing.T) {
|
||||
rtts := makeDurations([]int{60, 70, 80, 90, 100, 110, 120, 150, 200, 300}) // ms
|
||||
p := classifyNetwork(rtts, 0, 10) // 无丢包
|
||||
p := classifyNetwork(rtts, 0, 10) // 无丢包
|
||||
|
||||
if p.Env != EnvInternet {
|
||||
t.Errorf("env = %v, want Internet", p.Env)
|
||||
@@ -72,7 +72,7 @@ func TestClassifyNetwork(t *testing.T) {
|
||||
|
||||
t.Run("高丢包归类为慢速", func(t *testing.T) {
|
||||
rtts := makeDurations([]int{60, 70, 80, 90, 100}) // ms, 5 responded
|
||||
p := classifyNetwork(rtts, 5, 10) // 50% loss
|
||||
p := classifyNetwork(rtts, 5, 10) // 50% loss
|
||||
|
||||
if p.Env != EnvSlow {
|
||||
t.Errorf("env = %v, want Slow (高丢包)", p.Env)
|
||||
@@ -96,14 +96,14 @@ func TestClassifyNetwork(t *testing.T) {
|
||||
|
||||
func TestRecommendConcurrency(t *testing.T) {
|
||||
tests := []struct {
|
||||
env NetworkEnv
|
||||
lossRate float64
|
||||
userT int
|
||||
explicit bool
|
||||
wantTMin int
|
||||
wantTMax int
|
||||
wantCeil int
|
||||
desc string
|
||||
env NetworkEnv
|
||||
lossRate float64
|
||||
userT int
|
||||
explicit bool
|
||||
wantTMin int
|
||||
wantTMax int
|
||||
wantCeil int
|
||||
desc string
|
||||
}{
|
||||
{EnvLAN, 0.0, 600, false, 800, 1000, -1, "内网自动: ×1.5"},
|
||||
{EnvWAN, 0.0, 600, false, 550, 650, -1, "局域网自动: ×1.0"},
|
||||
@@ -156,6 +156,24 @@ func TestPickSamples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkProbeAddressUsesJoinHostPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
host string
|
||||
port int
|
||||
want string
|
||||
}{
|
||||
{"127.0.0.1", 80, "127.0.0.1:80"},
|
||||
{"::1", 443, "[::1]:443"},
|
||||
{"2001:db8::1", 22, "[2001:db8::1]:22"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := networkProbeAddress(tt.host, tt.port); got != tt.want {
|
||||
t.Fatalf("networkProbeAddress(%q, %d) = %q, want %q", tt.host, tt.port, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 辅助
|
||||
// =============================================================================
|
||||
|
||||
+2
-3
@@ -707,8 +707,8 @@ func processServiceResult(ctx context.Context, host string, port int, addr strin
|
||||
return
|
||||
}
|
||||
|
||||
// 缓存服务名称,供插件按服务类型匹配(解决非标准端口问题)
|
||||
MarkServiceName(host, port, serviceInfo.Name)
|
||||
// 缓存指纹识别结果,供插件按服务类型匹配(解决非标准端口问题)
|
||||
CacheServiceInfo(host, port, serviceInfo)
|
||||
|
||||
// 保存并输出服务信息
|
||||
details := buildServiceDetails(port, serviceInfo)
|
||||
@@ -716,7 +716,6 @@ func processServiceResult(ctx context.Context, host string, port int, addr strin
|
||||
|
||||
if isWeb {
|
||||
details["is_web"] = true
|
||||
MarkAsWebService(host, port, serviceInfo)
|
||||
}
|
||||
|
||||
_ = session.SaveResult(&output.ScanResult{
|
||||
|
||||
@@ -212,6 +212,48 @@ func TestFormatAddress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWebServiceURLIPv6(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr string
|
||||
serviceInfo *ServiceInfo
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "http default port",
|
||||
addr: "[2001:db8::1]:80",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "http",
|
||||
},
|
||||
want: "http://[2001:db8::1]",
|
||||
},
|
||||
{
|
||||
name: "https default port",
|
||||
addr: "[2001:db8::1]:443",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "https",
|
||||
},
|
||||
want: "https://[2001:db8::1]",
|
||||
},
|
||||
{
|
||||
name: "http non-default port",
|
||||
addr: "[2001:db8::1]:8080",
|
||||
serviceInfo: &ServiceInfo{
|
||||
Name: "http",
|
||||
},
|
||||
want: "http://[2001:db8::1]:8080",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := buildWebServiceURL(tt.addr, tt.serviceInfo); got != tt.want {
|
||||
t.Fatalf("buildWebServiceURL(%q) = %q, want %q", tt.addr, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 排除端口逻辑测试(从EnhancedPortScan:28-32行提取)
|
||||
// =============================================================================
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 服务识别缓存:host:port → 服务名称
|
||||
// 端口扫描阶段写入,插件匹配阶段读取
|
||||
// 解决非标准端口上的服务无法匹配对应插件的问题
|
||||
var (
|
||||
serviceNameCache = make(map[string]string)
|
||||
serviceCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// MarkServiceName 记录端口上识别到的服务名称
|
||||
func MarkServiceName(host string, port int, serviceName string) {
|
||||
if serviceName == "" || serviceName == "unknown" {
|
||||
return
|
||||
}
|
||||
key := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
serviceCacheMu.Lock()
|
||||
serviceNameCache[key] = strings.ToLower(serviceName)
|
||||
serviceCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// GetServiceName 查询端口上的服务名称
|
||||
func GetServiceName(host string, port int) (string, bool) {
|
||||
key := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
serviceCacheMu.RLock()
|
||||
name, ok := serviceNameCache[key]
|
||||
serviceCacheMu.RUnlock()
|
||||
return name, ok
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,16 @@ func TestParsePortList_BasicParsing(t *testing.T) {
|
||||
input: "22,80,443,3306",
|
||||
expected: []int{22, 80, 443, 3306},
|
||||
},
|
||||
{
|
||||
name: "端口范围",
|
||||
input: "80-82",
|
||||
expected: []int{80, 81, 82},
|
||||
},
|
||||
{
|
||||
name: "端口和范围混合",
|
||||
input: "22,80-81",
|
||||
expected: []int{22, 80, 81},
|
||||
},
|
||||
{
|
||||
name: "空字符串",
|
||||
input: "",
|
||||
@@ -281,7 +291,7 @@ func TestParsePortList_ProductionScenarios(t *testing.T) {
|
||||
|
||||
t.Run("数据库端口", func(t *testing.T) {
|
||||
input := "3306,5432,1433,27017"
|
||||
expected := []int{3306, 5432, 1433, 27017}
|
||||
expected := []int{1433, 3306, 5432, 27017}
|
||||
result := s.parsePortList(input)
|
||||
if !intSlicesEqual(result, expected) {
|
||||
t.Errorf("应该正确解析常见数据库端口")
|
||||
@@ -317,6 +327,13 @@ func TestParsePortList_ProductionScenarios(t *testing.T) {
|
||||
t.Errorf("应该正确解析高端口号")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("端口组", func(t *testing.T) {
|
||||
result := s.parsePortList("web")
|
||||
if !sliceContains(result, 80) || !sliceContains(result, 443) {
|
||||
t.Errorf("web端口组应该包含80和443, 实际 %v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestParsePortList_ReturnValue 测试返回值特性
|
||||
@@ -330,14 +347,11 @@ func TestParsePortList_ReturnValue(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("端口不重复-但不保证去重", func(t *testing.T) {
|
||||
// 注意:当前实现不去重,如果用户输入 "22,22",会返回 [22, 22]
|
||||
// 这是可以接受的,因为上层逻辑会处理重复
|
||||
t.Run("重复端口会去重", func(t *testing.T) {
|
||||
input := "22,22"
|
||||
result := s.parsePortList(input)
|
||||
// 这里我们只测试解析是否正确,不测试去重
|
||||
if len(result) != 2 || result[0] != 22 || result[1] != 22 {
|
||||
t.Errorf("当前实现不去重,应该返回两个22")
|
||||
if len(result) != 1 || result[0] != 22 {
|
||||
t.Errorf("重复端口应该去重, 实际 %v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -355,6 +369,15 @@ func intSlicesEqual(a, b []int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func sliceContains(values []int, target int) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 存活检测判断测试
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/shadow1ng/fscan/common"
|
||||
)
|
||||
|
||||
func TestCLIExplicitDefaultTuningFlagsSurviveTuneConfig(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
oldFlagSet := flag.CommandLine
|
||||
oldFlagVars := *common.GetFlagVars()
|
||||
defer func() {
|
||||
os.Args = oldArgs
|
||||
flag.CommandLine = oldFlagSet
|
||||
*common.GetFlagVars() = oldFlagVars
|
||||
}()
|
||||
|
||||
*common.GetFlagVars() = common.FlagVars{}
|
||||
flag.CommandLine = flag.NewFlagSet("fscan-test", flag.ContinueOnError)
|
||||
os.Args = []string{
|
||||
"fscan-test",
|
||||
"-silent",
|
||||
"-h", "127.0.0.1",
|
||||
"-time", "3",
|
||||
"-mt", "20",
|
||||
"-retry", "3",
|
||||
"-icmp-rate", "0.1",
|
||||
"-num", "20",
|
||||
}
|
||||
|
||||
info := &common.HostInfo{}
|
||||
if err := common.Flag(info); err != nil {
|
||||
t.Fatalf("Flag error = %v", err)
|
||||
}
|
||||
|
||||
cfg, _, err := common.BuildConfig(common.GetFlagVars(), info)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildConfig error = %v", err)
|
||||
}
|
||||
if !cfg.TimeoutExplicit || !cfg.ModuleThreadNumExplicit ||
|
||||
!cfg.MaxRetriesExplicit || !cfg.Network.ICMPRateExplicit ||
|
||||
!cfg.POC.NumExplicit {
|
||||
t.Fatalf("explicit flags not propagated: timeout=%v mt=%v retry=%v icmp=%v num=%v",
|
||||
cfg.TimeoutExplicit,
|
||||
cfg.ModuleThreadNumExplicit,
|
||||
cfg.MaxRetriesExplicit,
|
||||
cfg.Network.ICMPRateExplicit,
|
||||
cfg.POC.NumExplicit)
|
||||
}
|
||||
|
||||
ep := &EnvironmentProfile{
|
||||
Net: NetworkProfile{
|
||||
Env: EnvLAN,
|
||||
RTTMedian: time.Millisecond,
|
||||
RTTStddev: 200 * time.Microsecond,
|
||||
LossRate: 0,
|
||||
Samples: 30,
|
||||
},
|
||||
System: SystemProfile{FDLimit: 65536, NumCPU: 8},
|
||||
}
|
||||
ep.TuneConfig(cfg, makeTestSession(cfg))
|
||||
|
||||
if cfg.Timeout != 3*time.Second {
|
||||
t.Fatalf("Timeout = %v, want explicit default 3s", cfg.Timeout)
|
||||
}
|
||||
if cfg.ModuleThreadNum != 20 {
|
||||
t.Fatalf("ModuleThreadNum = %d, want explicit default 20", cfg.ModuleThreadNum)
|
||||
}
|
||||
if cfg.MaxRetries != 3 {
|
||||
t.Fatalf("MaxRetries = %d, want explicit default 3", cfg.MaxRetries)
|
||||
}
|
||||
if cfg.Network.ICMPRate != 0.1 {
|
||||
t.Fatalf("ICMPRate = %.2f, want explicit default 0.10", cfg.Network.ICMPRate)
|
||||
}
|
||||
if cfg.POC.Num != 20 {
|
||||
t.Fatalf("POC.Num = %d, want explicit default 20", cfg.POC.Num)
|
||||
}
|
||||
}
|
||||
+32
-14
@@ -204,10 +204,11 @@ func (w *WebPortDetector) tryHTTP(ctx context.Context, client *http.Client, sess
|
||||
// 基于服务指纹的Web服务识别
|
||||
// ===============================
|
||||
|
||||
// Web服务缓存 - 简化的全局缓存
|
||||
// 服务识别缓存 - 存储所有识别到的服务(不仅限于 Web)
|
||||
// 端口扫描阶段写入,插件匹配阶段读取
|
||||
var (
|
||||
webServiceCache = make(map[string]*ServiceInfo)
|
||||
webCacheMutex sync.RWMutex
|
||||
serviceCache = make(map[string]*ServiceInfo)
|
||||
serviceCacheMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// IsWebServiceByFingerprint 基于服务指纹判断Web服务 - 保持API兼容
|
||||
@@ -259,28 +260,45 @@ func IsWebServiceByFingerprint(serviceInfo *ServiceInfo) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// MarkAsWebService 标记Web服务 - 保持API兼容
|
||||
func MarkAsWebService(host string, port int, serviceInfo *ServiceInfo) {
|
||||
// CacheServiceInfo 缓存识别到的服务信息
|
||||
func CacheServiceInfo(host string, port int, serviceInfo *ServiceInfo) {
|
||||
cacheKey := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
webCacheMutex.Lock()
|
||||
defer webCacheMutex.Unlock()
|
||||
serviceCacheMutex.Lock()
|
||||
defer serviceCacheMutex.Unlock()
|
||||
|
||||
webServiceCache[cacheKey] = serviceInfo
|
||||
serviceCache[cacheKey] = serviceInfo
|
||||
}
|
||||
|
||||
// GetWebServiceInfo 获取Web服务信息
|
||||
func GetWebServiceInfo(host string, port int) (*ServiceInfo, bool) {
|
||||
// MarkAsWebService 标记 Web 服务(兼容旧调用)
|
||||
func MarkAsWebService(host string, port int, serviceInfo *ServiceInfo) {
|
||||
CacheServiceInfo(host, port, serviceInfo)
|
||||
}
|
||||
|
||||
// GetCachedServiceInfo 获取缓存的服务信息
|
||||
func GetCachedServiceInfo(host string, port int) (*ServiceInfo, bool) {
|
||||
cacheKey := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
|
||||
webCacheMutex.RLock()
|
||||
defer webCacheMutex.RUnlock()
|
||||
serviceCacheMutex.RLock()
|
||||
defer serviceCacheMutex.RUnlock()
|
||||
|
||||
serviceInfo, exists := webServiceCache[cacheKey]
|
||||
serviceInfo, exists := serviceCache[cacheKey]
|
||||
return serviceInfo, exists
|
||||
}
|
||||
|
||||
// IsMarkedWebService 检查是否已标记为Web服务
|
||||
// GetWebServiceInfo 获取 Web 服务信息(兼容旧调用)
|
||||
func GetWebServiceInfo(host string, port int) (*ServiceInfo, bool) {
|
||||
info, exists := GetCachedServiceInfo(host, port)
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
if !IsWebServiceByFingerprint(info) {
|
||||
return nil, false
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
|
||||
// IsMarkedWebService 检查是否为 Web 服务
|
||||
func IsMarkedWebService(host string, port int) bool {
|
||||
_, exists := GetWebServiceInfo(host, port)
|
||||
return exists
|
||||
|
||||
@@ -440,9 +440,9 @@ func TestCreateTargetFromURL(t *testing.T) {
|
||||
// TestWebServiceCache 测试Web服务缓存操作
|
||||
func TestWebServiceCache(t *testing.T) {
|
||||
// 清空缓存
|
||||
webCacheMutex.Lock()
|
||||
webServiceCache = make(map[string]*ServiceInfo)
|
||||
webCacheMutex.Unlock()
|
||||
serviceCacheMutex.Lock()
|
||||
serviceCache = make(map[string]*ServiceInfo)
|
||||
serviceCacheMutex.Unlock()
|
||||
|
||||
t.Run("存储和读取", func(t *testing.T) {
|
||||
serviceInfo := &ServiceInfo{
|
||||
@@ -517,9 +517,9 @@ func TestWebServiceCache(t *testing.T) {
|
||||
// TestWebServiceCache_Concurrent 测试并发安全性
|
||||
func TestWebServiceCache_Concurrent(t *testing.T) {
|
||||
// 清空缓存
|
||||
webCacheMutex.Lock()
|
||||
webServiceCache = make(map[string]*ServiceInfo)
|
||||
webCacheMutex.Unlock()
|
||||
serviceCacheMutex.Lock()
|
||||
serviceCache = make(map[string]*ServiceInfo)
|
||||
serviceCacheMutex.Unlock()
|
||||
|
||||
t.Run("不同key并发写入", func(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
Reference in New Issue
Block a user