feat: 统一服务缓存 + 指纹驱动插件匹配
测试构建 / 代码检查 (push) Has been cancelled
测试构建 / 单元测试和构建 (push) Has been cancelled
测试构建 / 构建验证 (push) Has been cancelled

将 webServiceCache 扩展为通用 serviceCache,所有指纹识别结果
统一缓存,插件匹配时端口不命中则回退到服务名称匹配。

删除多余的 service_cache.go,复用已有的 ServiceInfo 体系。
补充 nil 防御、Explicit 标记、大量单元/集成/回归测试。
This commit is contained in:
ZacharyZcR
2026-06-12 19:49:07 +08:00
parent 517133f72f
commit 1595c92aed
44 changed files with 1533 additions and 142 deletions
+37 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/hex"
"fmt"
"net"
"net/url"
"strconv"
"strings"
@@ -171,6 +172,7 @@ func parseUserPassPairs(fv *FlagVars) ([]config.CredentialPair, error) {
// 如果命令行同时指定了单个用户名和单个密码(不是逗号分隔的多个)
if fv.Username != "" && fv.Password != "" &&
!strings.Contains(fv.Username, ",") && !strings.Contains(fv.Password, ",") &&
fv.AddUsers == "" && fv.AddPasswords == "" &&
fv.UsersFile == "" && fv.PasswordsFile == "" && fv.UserPassFile == "" {
pairs = append(pairs, config.CredentialPair{
Username: strings.TrimSpace(fv.Username),
@@ -294,9 +296,42 @@ func normalizeURL(rawURL string) string {
}
lowerURL := strings.ToLower(rawURL)
if !strings.HasPrefix(lowerURL, "http://") && !strings.HasPrefix(lowerURL, "https://") {
return "http://" + rawURL
return "http://" + normalizeSchemelessURLTarget(rawURL)
}
return rawURL
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return rawURL
}
normalizedHost := normalizeURLHost(parsed.Host)
if normalizedHost == parsed.Host {
return rawURL
}
parsed.Host = normalizedHost
normalized := parsed.String()
if schemeEnd := strings.Index(rawURL, "://"); schemeEnd >= 0 {
return rawURL[:schemeEnd] + normalized[len(parsed.Scheme):]
}
return normalized
}
func normalizeSchemelessURLTarget(rawURL string) string {
authority := rawURL
suffix := ""
if idx := strings.IndexAny(rawURL, "/?#"); idx >= 0 {
authority = rawURL[:idx]
suffix = rawURL[idx:]
}
return normalizeURLHost(authority) + suffix
}
func normalizeURLHost(host string) string {
if strings.HasPrefix(host, "[") {
return host
}
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
return "[" + host + "]"
}
return host
}
// =============================================================================
+118
View File
@@ -3,6 +3,8 @@ package common
import (
"reflect"
"testing"
fscanconfig "github.com/shadow1ng/fscan/common/config"
)
func TestParsePasswordsKeepsPrimaryPasswordLiteral(t *testing.T) {
@@ -49,6 +51,100 @@ func TestBuildConfigRejectsInvalidHashValue(t *testing.T) {
}
}
func TestBuildConfigDefaultsAreIndependentCopies(t *testing.T) {
cfg, _, err := BuildConfig(&FlagVars{Username: "custom-user"}, &HostInfo{})
if err != nil {
t.Fatalf("BuildConfig error = %v", err)
}
defaultSSHUsers := fscanconfig.DefaultUserDict["ssh"]
if len(defaultSSHUsers) == 1 && defaultSSHUsers[0] == "custom-user" {
t.Fatal("BuildConfig mutated DefaultUserDict")
}
cfg.Credentials.Userdict["ssh"][0] = "mutated-user"
if fscanconfig.DefaultUserDict["ssh"][0] == "mutated-user" {
t.Fatal("Config userdict shares backing storage with DefaultUserDict")
}
cfg.Credentials.Passwords[0] = "mutated-password"
if fscanconfig.DefaultPasswords[0] == "mutated-password" {
t.Fatal("Config passwords share backing storage with DefaultPasswords")
}
port := 80
cfg.PortMap[port][0] = "mutated-probe"
if fscanconfig.DefaultPortMap[port][0] == "mutated-probe" {
t.Fatal("Config port map shares backing storage with DefaultPortMap")
}
cfg.DefaultMap[0] = "mutated-default-probe"
if fscanconfig.DefaultProbeMap[0] == "mutated-default-probe" {
t.Fatal("Config default map shares backing storage with DefaultProbeMap")
}
}
func TestParseUserPassPairsKeepsAdditionalCredentialFlags(t *testing.T) {
tests := []struct {
name string
fv *FlagVars
}{
{
name: "additional passwords",
fv: &FlagVars{
Username: "root",
Password: "primary",
AddPasswords: "extra",
},
},
{
name: "additional users",
fv: &FlagVars{
Username: "root",
Password: "primary",
AddUsers: "admin",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pairs, err := parseUserPassPairs(tt.fv)
if err != nil {
t.Fatalf("parseUserPassPairs error = %v", err)
}
if len(pairs) != 0 {
t.Fatalf("parseUserPassPairs returned exact pairs %#v; additional credential flags would be ignored", pairs)
}
})
}
}
func TestNewConfigDefaultsAreIndependentCopies(t *testing.T) {
cfg := NewConfig()
cfg.Credentials.Userdict["ssh"][0] = "mutated-user"
if fscanconfig.DefaultUserDict["ssh"][0] == "mutated-user" {
t.Fatal("NewConfig userdict shares backing storage with DefaultUserDict")
}
cfg.Credentials.Passwords[0] = "mutated-password"
if fscanconfig.DefaultPasswords[0] == "mutated-password" {
t.Fatal("NewConfig passwords share backing storage with DefaultPasswords")
}
port := 80
cfg.PortMap[port][0] = "mutated-probe"
if fscanconfig.DefaultPortMap[port][0] == "mutated-probe" {
t.Fatal("NewConfig port map shares backing storage with DefaultPortMap")
}
cfg.DefaultMap[0] = "mutated-default-probe"
if fscanconfig.DefaultProbeMap[0] == "mutated-default-probe" {
t.Fatal("NewConfig default map shares backing storage with DefaultProbeMap")
}
}
func TestParseTargetsHostPortDoesNotLeaveSyntheticHost(t *testing.T) {
fv := &FlagVars{Ports: "22"}
info := &HostInfo{Host: "127.0.0.1:8080"}
@@ -73,3 +169,25 @@ func TestNormalizeURLKeepsUppercaseScheme(t *testing.T) {
t.Fatalf("normalizeURL() = %q", got)
}
}
func TestNormalizeURLBracketsIPv6Literals(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "bare ipv6 without scheme", in: "2001:db8::1", want: "http://[2001:db8::1]"},
{name: "bracketed ipv6 without scheme", in: "[2001:db8::1]", want: "http://[2001:db8::1]"},
{name: "bare ipv6 with scheme", in: "http://2001:db8::1", want: "http://[2001:db8::1]"},
{name: "bare ipv6 path without scheme", in: "2001:db8::1/admin", want: "http://[2001:db8::1]/admin"},
{name: "bare ipv6 query without scheme", in: "2001:db8::1?debug=1", want: "http://[2001:db8::1]?debug=1"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeURL(tt.in); got != tt.want {
t.Fatalf("normalizeURL(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
+33 -4
View File
@@ -145,6 +145,35 @@ type LocalExploitConfig struct {
DownloadSavePath string // 下载保存路径
}
func cloneStringSlice(values []string) []string {
if values == nil {
return nil
}
return append([]string(nil), values...)
}
func cloneStringSliceMap(values map[string][]string) map[string][]string {
if values == nil {
return nil
}
cloned := make(map[string][]string, len(values))
for key, value := range values {
cloned[key] = cloneStringSlice(value)
}
return cloned
}
func clonePortMap(values map[int][]string) map[int][]string {
if values == nil {
return nil
}
cloned := make(map[int][]string, len(values))
for key, value := range values {
cloned[key] = cloneStringSlice(value)
}
return cloned
}
// NewConfig 创建带默认值的Config(后备用,正常流程使用BuildConfigFromFlags
func NewConfig() *Config {
return &Config{
@@ -163,13 +192,13 @@ func NewConfig() *Config {
MaxRetries: 3,
// 高级功能 - 使用默认配置
PortMap: config.DefaultPortMap,
DefaultMap: config.DefaultProbeMap,
PortMap: clonePortMap(config.DefaultPortMap),
DefaultMap: cloneStringSlice(config.DefaultProbeMap),
// 分组配置 - 使用默认字典
Credentials: CredentialConfig{
Userdict: config.DefaultUserDict,
Passwords: config.DefaultPasswords,
Userdict: cloneStringSliceMap(config.DefaultUserDict),
Passwords: cloneStringSlice(config.DefaultPasswords),
UserPassPairs: nil,
},
Network: NetworkConfig{
+4 -4
View File
@@ -164,8 +164,8 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
DNSLog: fv.DNSLog,
PersistenceTargetFile: fv.PersistenceTargetFile,
WinPEFile: fv.WinPEFile,
PortMap: config.DefaultPortMap,
DefaultMap: config.DefaultProbeMap,
PortMap: clonePortMap(config.DefaultPortMap),
DefaultMap: cloneStringSlice(config.DefaultProbeMap),
// SOCKS5代理端口
Socks5ProxyPort: fv.Socks5ProxyPort,
@@ -175,8 +175,8 @@ func BuildConfigFromFlags(fv *FlagVars) *Config {
Username: fv.Username,
Password: fv.Password,
Domain: fv.Domain,
Userdict: config.DefaultUserDict,
Passwords: config.DefaultPasswords,
Userdict: cloneStringSliceMap(config.DefaultUserDict),
Passwords: cloneStringSlice(config.DefaultPasswords),
UserPassPairs: nil, // 后续解析
SSHKeyPath: fv.SSHKeyPath,
},
+5 -1
View File
@@ -31,7 +31,11 @@ type HostInfo struct {
// Target 返回 host:port 格式字符串
func (h *HostInfo) Target() string {
return net.JoinHostPort(h.Host, strconv.Itoa(h.Port))
host := h.Host
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
}
return net.JoinHostPort(host, strconv.Itoa(h.Port))
}
// =============================================================================
+7
View File
@@ -8,3 +8,10 @@ func TestHostInfoTargetUsesBracketedIPv6(t *testing.T) {
t.Fatalf("Target() = %q, want %q", got, want)
}
}
func TestHostInfoTargetDoesNotDoubleBracketIPv6(t *testing.T) {
info := &HostInfo{Host: "[2001:db8::1]", Port: 443}
if got, want := info.Target(), "[2001:db8::1]:443"; got != want {
t.Fatalf("Target() = %q, want %q", got, want)
}
}
+17 -2
View File
@@ -52,16 +52,31 @@ func getGlobalDialer(timeout time.Duration) (proxy.Dialer, error) {
// parseProxyURL 解析代理URL,提取地址和认证信息
func parseProxyURL(proxyURL, fallback string) (host, username, password string) {
if !strings.Contains(proxyURL, "://") {
if host, username, password, ok := parseProxyURLCandidate("http://" + proxyURL); ok {
return host, username, password
}
}
if host, username, password, ok := parseProxyURLCandidate(proxyURL); ok {
return host, username, password
}
return fallback, "", ""
}
func parseProxyURLCandidate(proxyURL string) (host, username, password string, ok bool) {
parsedURL, err := url.Parse(proxyURL)
if err != nil {
return fallback, "", ""
return "", "", "", false
}
host = parsedURL.Host
if host == "" {
return "", "", "", false
}
if parsedURL.User != nil {
username = parsedURL.User.Username()
password, _ = parsedURL.User.Password()
}
return
return host, username, password, true
}
// createProxyConfig 根据全局设置创建代理配置
+35
View File
@@ -0,0 +1,35 @@
package output
import "testing"
func TestSplitHostPort(t *testing.T) {
tests := []struct {
name string
target string
wantHost string
wantPort int
wantOK bool
}{
{name: "ipv4", target: "192.168.1.1:80", wantHost: "192.168.1.1", wantPort: 80, wantOK: true},
{name: "hostname", target: "example.com:443", wantHost: "example.com", wantPort: 443, wantOK: true},
{name: "bracketed ipv6", target: "[2001:db8::1]:8443", wantHost: "2001:db8::1", wantPort: 8443, wantOK: true},
{name: "bare ipv6 without port", target: "2001:db8::1", wantOK: false},
{name: "invalid port", target: "example.com:abc", wantOK: false},
{name: "port out of range", target: "example.com:65536", wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
host, port, ok := splitHostPort(tt.target)
if ok != tt.wantOK {
t.Fatalf("splitHostPort(%q) ok = %v, want %v", tt.target, ok, tt.wantOK)
}
if !ok {
return
}
if host != tt.wantHost || port != tt.wantPort {
t.Fatalf("splitHostPort(%q) = (%q, %d), want (%q, %d)", tt.target, host, port, tt.wantHost, tt.wantPort)
}
})
}
}
+6
View File
@@ -46,6 +46,12 @@ func targetWithPort(target string, port interface{}) string {
return target
}
portText := fmt.Sprint(port)
if strings.TrimSpace(portText) == "" {
return target
}
if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
target = strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
}
if strings.Count(target, ":") == 1 {
return target
}
+3
View File
@@ -67,7 +67,10 @@ func TestTargetWithPortIPv6(t *testing.T) {
{name: "ipv4 without port", target: "192.168.1.1", port: 80, want: "192.168.1.1:80"},
{name: "ipv4 with port", target: "192.168.1.1:80", port: 443, want: "192.168.1.1:80"},
{name: "ipv6 without port", target: "2001:db8::1", port: 443, want: "[2001:db8::1]:443"},
{name: "bracketed ipv6 without port", target: "[2001:db8::1]", port: 443, want: "[2001:db8::1]:443"},
{name: "ipv6 with port", target: "[2001:db8::1]:443", port: 80, want: "[2001:db8::1]:443"},
{name: "empty port", target: "example.com", port: "", want: "example.com"},
{name: "blank port", target: "example.com", port: " \t", want: "example.com"},
}
for _, tt := range tests {
+20
View File
@@ -141,6 +141,26 @@ func TestScanSessionProxyStateComesFromConfig(t *testing.T) {
}
}
func TestParseProxyURLFallsBackWhenHostIsEmpty(t *testing.T) {
host, username, password := parseProxyURL("127.0.0.1:8080", "127.0.0.1:8080")
if host != "127.0.0.1:8080" {
t.Fatalf("host = %q, want fallback address", host)
}
if username != "" || password != "" {
t.Fatalf("unexpected credentials: %q/%q", username, password)
}
}
func TestParseProxyURLExtractsAuthWithoutScheme(t *testing.T) {
host, username, password := parseProxyURL("user:[email protected]:8080", "user:[email protected]:8080")
if host != "127.0.0.1:8080" {
t.Fatalf("host = %q, want proxy address", host)
}
if username != "user" || password != "pass" {
t.Fatalf("credentials = %q/%q, want user/pass", username, password)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {