Harden scan robustness and tests

This commit is contained in:
ZacharyZcR
2026-06-14 22:23:48 +08:00
parent 5ad914a1bb
commit c49c23c7f0
100 changed files with 4483 additions and 412 deletions
+99
View File
@@ -2,6 +2,9 @@ package core
import (
"testing"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/plugins"
)
// =============================================================================
@@ -272,6 +275,102 @@ func TestOrderWebPlugins(t *testing.T) {
}
}
func TestBaseScanStrategyPluginSelectionAndApplicability(t *testing.T) {
registerTestPlugins(t)
plugins.RegisterWithOptions("core_test_local", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false)
plugins.RegisterWithOptions("core_test_udp", func() plugins.Plugin { return nil }, []int{161}, []string{plugins.PluginTypeUDP}, true)
clearServiceCache()
cfg := common.NewConfig()
cfg.Mode = "ssh, missing_plugin, webtitle"
strategy := NewBaseScanStrategy("service", FilterService)
got, custom := strategy.GetPlugins(cfg)
if !custom {
t.Fatal("explicit mode should be marked as custom")
}
if !slicesEqual(got, []string{"ssh", "webtitle"}) {
t.Fatalf("custom plugins = %#v, want ssh/webtitle", got)
}
cfg.Mode = "all"
servicePlugins, custom := strategy.GetPlugins(cfg)
if custom {
t.Fatal("all mode should not be custom")
}
if !containsString(servicePlugins, "ssh") || containsString(servicePlugins, "core_test_local") || containsString(servicePlugins, "core_test_udp") {
t.Fatalf("service filtered plugins = %#v", servicePlugins)
}
if !strategy.pluginExists("ssh") || strategy.pluginExists("missing_plugin") {
t.Fatal("pluginExists returned wrong result")
}
if !strategy.isPluginApplicableToPort("ssh", 22) || strategy.isPluginApplicableToPort("ssh", 23) {
t.Fatal("port applicability for ssh is wrong")
}
CacheServiceInfo("10.0.0.9", 22222, &ServiceInfo{Name: "ssh"})
if !strategy.isPluginApplicableToPortWithHost("ssh", "10.0.0.9", 22222) {
t.Fatal("service cache should allow ssh on a non-standard port")
}
if !strategy.IsPluginApplicableByName("ssh", "10.0.0.9", 1, true, cfg) {
t.Fatal("custom mode should respect explicitly selected plugin")
}
if strategy.IsPluginApplicableByName("missing_plugin", "10.0.0.9", 22, true, cfg) {
t.Fatal("missing plugin should never be applicable")
}
}
func TestBaseScanStrategyFilterTypes(t *testing.T) {
plugins.RegisterWithOptions("core_test_local_filter", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeLocal}, false)
plugins.RegisterWithOptions("core_test_web_filter", func() plugins.Plugin { return nil }, nil, []string{plugins.PluginTypeWeb}, true)
plugins.RegisterWithOptions("core_test_udp_filter", func() plugins.Plugin { return nil }, []int{53}, []string{plugins.PluginTypeUDP}, true)
cfg := common.NewConfig()
localStrategy := NewBaseScanStrategy("local", FilterLocal)
if localStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) {
t.Fatal("local plugin should require explicit -local selection")
}
cfg.LocalPlugin = "core_test_local_filter"
if !localStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) {
t.Fatal("explicit local plugin should pass local filter")
}
serviceStrategy := NewBaseScanStrategy("service", FilterService)
if !serviceStrategy.isPluginPassesFilterType("ssh", false, cfg) {
t.Fatal("service plugin should pass service filter")
}
if serviceStrategy.isPluginPassesFilterType("core_test_local_filter", false, cfg) ||
serviceStrategy.isPluginPassesFilterType("core_test_udp_filter", false, cfg) {
t.Fatal("service filter should reject local and UDP plugins")
}
webStrategy := NewBaseScanStrategy("web", FilterWeb)
if !webStrategy.isPluginPassesFilterType("core_test_web_filter", false, cfg) ||
webStrategy.isPluginPassesFilterType("ssh", false, cfg) {
t.Fatal("web filter should only allow web plugins")
}
if webPluginOrder("webtitle") != 0 || webPluginOrder("webpoc") != 2 || webPluginOrder("other") != 1 {
t.Fatal("web plugin order changed")
}
}
func TestFormatPluginList(t *testing.T) {
if got := formatPluginList([]string{"a", "b", "c"}); got != "a, b, c" {
t.Fatalf("short plugin list = %q", got)
}
if got := formatPluginList([]string{"a", "b", "c", "d", "e", "f"}); got == "" || got == "a, b, c, d, e, f" {
t.Fatalf("long plugin list should be summarized, got %q", got)
}
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
// TestNewBaseScanStrategy 测试构造函数
func TestNewBaseScanStrategy(t *testing.T) {
tests := []struct {
+14 -3
View File
@@ -447,15 +447,26 @@ func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) s
// Banner 信息
if len(serviceInfo.Banner) > 0 {
banner := strings.TrimSpace(serviceInfo.Banner)
if len(banner) > 80 {
banner = banner[:80] + "..."
}
banner = truncateString(banner, 80)
fmt.Fprintf(&msg, " Banner:(%s)", banner)
}
return msg.String()
}
func truncateString(s string, maxRunes int) string {
if maxRunes < 0 {
return s
}
for i := range s {
if maxRunes == 0 {
return s[:i] + "..."
}
maxRunes--
}
return s
}
func buildWebServiceURL(addr string, serviceInfo *ServiceInfo) string {
protocol := "http"
serviceName := ""
+117
View File
@@ -2,6 +2,8 @@ package core
import (
"fmt"
"sort"
"strings"
"testing"
)
@@ -254,6 +256,110 @@ func TestBuildWebServiceURLIPv6(t *testing.T) {
}
}
func TestPortScanCollectorsAndHelpers(t *testing.T) {
t.Run("result collector deduplicates and streams", func(t *testing.T) {
stream := make(chan string, 2)
collector := newResultCollector(stream)
collector.Add("127.0.0.1:80")
collector.Add("127.0.0.1:80")
collector.Add("127.0.0.1:443")
got := collector.GetAll()
sort.Strings(got)
expected := []string{"127.0.0.1:443", "127.0.0.1:80"}
if !stringSlicesEqual(got, expected) {
t.Fatalf("collector results = %v, want %v", got, expected)
}
close(stream)
var streamed []string
for addr := range stream {
streamed = append(streamed, addr)
}
sort.Strings(streamed)
if !stringSlicesEqual(streamed, expected) {
t.Fatalf("streamed results = %v, want %v", streamed, expected)
}
})
t.Run("failed collector counts", func(t *testing.T) {
var collector failedPortCollector
collector.Add("127.0.0.1", 80, "127.0.0.1:80")
collector.Add("127.0.0.1", 443, "127.0.0.1:443")
if got := collector.Count(); got != 2 {
t.Fatalf("failed count = %d, want 2", got)
}
})
t.Run("proxy and closed error helpers", func(t *testing.T) {
if !isProxyErrorResponse([]byte{0x05, 0x01, 0x00, 0x01}) {
t.Fatal("SOCKS5 failure reply should be proxy error")
}
if !isProxyErrorResponse([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) {
t.Fatal("HTTP proxy error text should be detected")
}
if isProxyErrorResponse(nil) || isProxyErrorResponse([]byte{0x05, 0x00}) {
t.Fatal("empty or success response should not be proxy error")
}
if !isConnectionClosed(fmt.Errorf("use of closed network connection")) {
t.Fatal("closed connection error should be detected")
}
if isConnectionClosed(nil) || isConnectionClosed(fmt.Errorf("temporary timeout")) {
t.Fatal("non-closed error should not be detected")
}
})
t.Run("service details and subnet prefix", func(t *testing.T) {
details := buildServiceDetails(8443, &ServiceInfo{
Name: "https",
Version: "1.2.3",
Banner: " hello \r\n",
Extras: map[string]string{
"vendor_product": "nginx",
"os": "linux",
"info": "tls",
"empty": "",
"ignored": "value",
},
})
expected := map[string]interface{}{
"port": 8443,
"service": "https",
"version": "1.2.3",
"banner": "hello",
"product": "nginx",
"os": "linux",
"info": "tls",
}
for key, want := range expected {
if got := details[key]; got != want {
t.Fatalf("details[%s] = %#v, want %#v (all=%#v)", key, got, want, details)
}
}
if _, ok := details["ignored"]; ok {
t.Fatalf("unexpected ignored extra in details: %#v", details)
}
if got := subnetPrefix("192.168.1.25"); got != "192.168.1" {
t.Fatalf("subnetPrefix IPv4 = %q", got)
}
if got := subnetPrefix("localhost"); got != "" {
t.Fatalf("subnetPrefix hostname = %q, want empty", got)
}
})
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// =============================================================================
// 排除端口逻辑测试(从EnhancedPortScan:28-32行提取)
// =============================================================================
@@ -614,6 +720,17 @@ func TestBuildServiceLogMessage(t *testing.T) {
}
}
func TestBuildServiceLogMessageTruncatesBannerByRune(t *testing.T) {
result := buildServiceLogMessage("10.0.0.1:22", &ServiceInfo{
Name: "ssh",
Banner: strings.Repeat("界", 85),
Extras: map[string]string{},
}, false)
if !strings.Contains(result, strings.Repeat("界", 80)+"...") {
t.Fatalf("truncated banner is not rune-safe: %q", result)
}
}
// contains 检查字符串是否包含子串
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
+8 -1
View File
@@ -13,7 +13,7 @@ func (p *Probe) getDirectiveSyntax(data string) (directive Directive) {
directive = Directive{}
// 查找第一个空格的位置
blankIndex := strings.Index(data, " ")
if blankIndex == -1 {
if blankIndex == -1 || blankIndex+3 > len(data) {
return directive
}
@@ -33,6 +33,10 @@ func (p *Probe) getDirectiveSyntax(data string) (directive Directive) {
// parseProbeInfo 解析探测器信息,返回错误替代 panic
func (p *Probe) parseProbeInfo(probeStr string) error {
if len(probeStr) < 5 {
return fmt.Errorf("%s", i18n.GetText("portfinger_probe_protocol_invalid"))
}
// 提取协议和其他信息
proto := probeStr[:4]
other := probeStr[4:]
@@ -49,6 +53,9 @@ func (p *Probe) parseProbeInfo(probeStr string) error {
// 解析指令
directive := p.getDirectiveSyntax(other)
if directive.DirectiveName == "" || directive.Delimiter == "" {
return fmt.Errorf("%s", i18n.GetText("portfinger_probe_name_invalid"))
}
// 设置探测器属性
p.Name = directive.DirectiveName
+33
View File
@@ -0,0 +1,33 @@
package portfinger
import "testing"
func TestProbeParserRejectsShortInputs(t *testing.T) {
tests := []string{
"",
"T",
"TCP",
"TCP ",
"TCP Q",
"TCP GetRequest q",
}
for _, input := range tests {
t.Run(input, func(t *testing.T) {
var probe Probe
if err := probe.fromString(input); err == nil {
t.Fatalf("fromString(%q) error = nil, want malformed input error", input)
}
})
}
}
func TestProbeParserAcceptsMinimalValidProbe(t *testing.T) {
var probe Probe
if err := probe.fromString(`TCP GetRequest q|GET / HTTP/1.0\r\n\r\n|`); err != nil {
t.Fatalf("fromString valid probe error = %v", err)
}
if probe.Name != "GetRequest" || probe.Protocol != "tcp" || probe.Data == "" {
t.Fatalf("probe parsed incorrectly: %#v", probe)
}
}
+34 -12
View File
@@ -112,7 +112,11 @@ func createHTTPClient(config *common.Config, session *common.ScanSession) *http.
networkConfig := config.Network
if networkConfig.HTTPProxy != "" {
// 使用HTTP代理
if proxyURL, err := url.Parse(networkConfig.HTTPProxy); err == nil {
httpProxy := networkConfig.HTTPProxy
if !strings.Contains(httpProxy, "://") {
httpProxy = "http://" + httpProxy
}
if proxyURL, err := url.Parse(httpProxy); err == nil && proxyURL.Host != "" {
transport.Proxy = http.ProxyURL(proxyURL)
} else {
session.LogError(i18n.Tr("http_proxy_config_error", err))
@@ -393,17 +397,31 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf
// 解析URL获取Host和Port信息
parsedURL, err := url.Parse(urlStr)
if err != nil {
session.LogError(i18n.Tr("url_parse_failed", urlStr, err))
if session != nil {
session.LogError(i18n.Tr("url_parse_failed", urlStr, err))
}
return nil
}
urlInfo := baseInfo
urlInfo.URL = urlStr
urlInfo.Host = parsedURL.Hostname()
if urlInfo.Host == "" {
if session != nil {
session.LogError(i18n.Tr("url_parse_failed", urlStr, "empty host"))
}
return nil
}
// 设置端口
portStr := parsedURL.Port()
if portStr == "" {
if hasMalformedURLPort(parsedURL.Host) {
if session != nil {
session.LogError(i18n.Tr("host_port_invalid", urlInfo.Host, ""))
}
return nil
}
// 根据协议设置默认端口
if parsedURL.Scheme == "https" {
urlInfo.Port = 443
@@ -411,18 +429,14 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf
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
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
if session != nil {
session.LogError(i18n.Tr("host_port_invalid", urlInfo.Host, portStr))
}
return nil
}
urlInfo.Port = port
}
// 标记为Web服务,确保Web插件能识别此目标
@@ -430,3 +444,11 @@ func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInf
return &urlInfo
}
func hasMalformedURLPort(host string) bool {
if strings.HasPrefix(host, "[") {
end := strings.LastIndexByte(host, ']')
return end >= 0 && len(host) > end+1 && host[end+1] == ':'
}
return strings.Contains(host, ":")
}
+55 -6
View File
@@ -607,17 +607,43 @@ func TestCreateTargetFromURL_EdgeCases(t *testing.T) {
t.Run("空URL", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "")
// url.Parse("")会成功,但Hostname()返回空
if result == nil {
t.Skip("空URL解析行为依赖于url.Parse实现")
if result != nil {
t.Fatalf("空URL应被拒绝,实际 %#v", result)
}
})
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")
if result != nil {
t.Fatalf("空Host URL应被拒绝,实际 %#v", result)
}
})
t.Run("非法URL不会因nil session panic", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "http://[::1")
if result != nil {
t.Fatalf("非法URL应被拒绝,实际 %#v", result)
}
})
t.Run("越界端口", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:70000")
if result != nil {
t.Fatalf("越界端口应被拒绝,实际 %#v", result)
}
})
t.Run("非数字端口", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:bad")
if result != nil {
t.Fatalf("非数字端口应被拒绝,实际 %#v", result)
}
})
t.Run("空端口", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "http://example.com:")
if result != nil {
t.Fatalf("空端口应被拒绝,实际 %#v", result)
}
})
@@ -642,6 +668,16 @@ func TestCreateTargetFromURL_EdgeCases(t *testing.T) {
}
}
})
t.Run("IPv6无端口使用默认端口", func(t *testing.T) {
result := strategy.createTargetFromURL(common.HostInfo{}, "https://[::1]/")
if result == nil {
t.Fatal("IPv6无端口URL应能正确解析")
}
if result.Host != "::1" || result.Port != 443 {
t.Fatalf("IPv6默认端口解析错误: %#v", result)
}
})
}
// TestIsWebServiceByFingerprint_Priority 测试识别优先级
@@ -816,6 +852,19 @@ func TestCreateHTTPClientUsesPerSessionProxy(t *testing.T) {
}
}
func TestCreateHTTPClientNormalizesHTTPProxyWithoutScheme(t *testing.T) {
cfg := common.NewConfig()
cfg.Network.WebTimeout = time.Second
cfg.Network.HTTPProxy = "127.0.0.1:18080"
session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
client := createHTTPClient(cfg, session)
proxy := proxyForTest(t, client)
if proxy != "http://127.0.0.1:18080" {
t.Fatalf("proxy = %q, want http://127.0.0.1:18080", proxy)
}
}
func proxyForTest(t *testing.T, client *http.Client) string {
t.Helper()